Skip to content

Commit dd7a129

Browse files
committed
refactor(adapter): 抽出 StdinHookAdapter 消除三個 hook adapter 的重複
- 新增 StdinHookAdapter 基底,集中 event dispatch、inject 時 recall + 緩衝 prompt、 stop 時去重 + 寫入,以及注入文字格式化(原本三份逐字相同的 formatRecallText) - codex/antigravity/claude-code 改為僅宣告事件名、default id、extractTurn 與 output shape - 行為零變更(各 provider 的 dispatch 與 output shape 均實測一致) - 新增 StdinHookAdapter / HookInput / HookTurn 為公開 export,既有 Hook 型別維持匯出
1 parent 01cbe33 commit dd7a129

6 files changed

Lines changed: 185 additions & 261 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Changed
8+
- The Codex, Antigravity, and Claude Code hook adapters now share a `StdinHookAdapter` base (new SDK export) that centralizes hook-event dispatch, recall + prompt-buffering on inject, dedupe + write on stop, and injected-context formatting. Each concrete adapter now only declares its event names, conversation-id default, turn extraction, and output shape — removing the ~90% duplication between them with no behavior change.
9+
710
## [1.15.1] - 2026-07-02
811

912
### Fixed

src/adapter/antigravity-adapter.ts

Lines changed: 21 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,14 @@
1717
// }
1818
//
1919
// The same command handles both events; it dispatches on hook_event_name.
20-
// `PreInvocation` recalls memory and injects it before the model runs; `Stop`
21-
// persists the completed turn. Antigravity's context-injection output field is
22-
// `additionalContext`; some builds expect it at the top level and some nested
23-
// under hookSpecificOutput (Claude Code compatible), so we emit both. The
24-
// handler is fail-open: if a payload lacks the prompt / assistant text, it
25-
// degrades to a no-op rather than disturbing the agent loop.
20+
// `PreInvocation` recalls memory and injects it before the model runs (buffering
21+
// the prompt); `Stop` persists the completed turn. Antigravity's context-injection
22+
// field is `additionalContext`; some builds expect it at the top level and some
23+
// nested under hookSpecificOutput (Claude Code compatible), so we emit both.
2624

27-
import { BaseAdapter } from './adapter.js'
25+
import { StdinHookAdapter } from './stdin-hook-adapter.js'
26+
import type { HookInput, HookTurn } from './stdin-hook-adapter.js'
2827
import type { MemoriaAdapterConfig } from './adapter.js'
29-
import { hashTurn } from './hook-state.js'
30-
import type { RecallHit } from '../core/types.js'
3128

3229
export interface AntigravityAdapterConfig extends MemoriaAdapterConfig {}
3330

@@ -53,88 +50,24 @@ export interface AntigravityHookOutput {
5350
}
5451
}
5552

56-
export class AntigravityAdapter extends BaseAdapter {
57-
constructor(config: AntigravityAdapterConfig) {
58-
super(config)
59-
}
53+
export class AntigravityAdapter extends StdinHookAdapter {
54+
// PreInvocation is Antigravity's context-injection point; UserPromptSubmit is the Claude-compatible alias.
55+
protected readonly injectEvents = ['PreInvocation', 'UserPromptSubmit'] as const
56+
// Stop / PostInvocation mark turn completion.
57+
protected readonly stopEvents = ['Stop', 'PostInvocation'] as const
58+
protected readonly defaultConversationId = 'antigravity-session'
6059

61-
/**
62-
* Dispatch on hook_event_name. Always resolves; never throws.
63-
* Errors are swallowed when failOpen is true (the default).
64-
*/
65-
async handleHookEvent(input: AntigravityHookInput): Promise<AntigravityHookOutput> {
66-
const event = input.hook_event_name ?? ''
67-
// PreInvocation is Antigravity's context-injection point; UserPromptSubmit is the Claude-compatible alias.
68-
if (event === 'PreInvocation' || event === 'UserPromptSubmit') return this.handlePreInvocation(input)
69-
// Stop / PostInvocation mark turn completion.
70-
if (event === 'Stop' || event === 'PostInvocation') {
71-
await this.handleStop(input)
72-
return {}
73-
}
74-
return {}
60+
protected extractTurn(input: HookInput, conversationId: string): HookTurn | null {
61+
const assistant = (typeof input.last_assistant_message === 'string' ? input.last_assistant_message : '').trim()
62+
if (!assistant) return null
63+
return { user: this.takeUserPrompt(conversationId), assistant }
7564
}
7665

77-
/** Recall relevant memory and inject it before the model runs. */
78-
async handlePreInvocation(input: AntigravityHookInput): Promise<AntigravityHookOutput> {
79-
const userMessage = (input.prompt ?? '').trim()
80-
const conversationId = input.session_id ?? 'antigravity-session'
81-
const eventName = input.hook_event_name ?? 'PreInvocation'
82-
if (!userMessage) return {}
83-
// Buffer the prompt so the later Stop hook (a separate process) can attach it to the turn.
84-
this.rememberUserPrompt(conversationId, userMessage)
85-
86-
try {
87-
const rc = await this.recallForContext({ userMessage, conversationId })
88-
if (!rc.injectedText) return {}
89-
return {
90-
additionalContext: rc.injectedText,
91-
hookSpecificOutput: { hookEventName: eventName, additionalContext: rc.injectedText }
92-
}
93-
} catch (error) {
94-
if (!this.config.failOpen) throw error
95-
return {}
66+
protected buildInjectOutput(eventName: string, text?: string): AntigravityHookOutput {
67+
if (!text) return {}
68+
return {
69+
additionalContext: text,
70+
hookSpecificOutput: { hookEventName: eventName, additionalContext: text }
9671
}
9772
}
98-
99-
/** Persist the completed turn using the payload's last_assistant_message. */
100-
async handleStop(input: AntigravityHookInput): Promise<void> {
101-
try {
102-
const conversationId = input.session_id ?? 'antigravity-session'
103-
const assistant = (input.last_assistant_message ?? '').trim()
104-
if (!assistant) return
105-
const user = this.takeUserPrompt(conversationId)
106-
const contentHash = hashTurn(`${user}\n${assistant}`)
107-
if (!this.shouldWrite(conversationId, contentHash)) return
108-
109-
await this.client.remember({
110-
timestamp: new Date().toISOString(),
111-
project: this.config.project,
112-
summary: assistant.slice(0, 200),
113-
events: [
114-
{
115-
event_type: 'ConversationTurn',
116-
timestamp: new Date().toISOString(),
117-
content: { user, assistant }
118-
}
119-
]
120-
})
121-
this.markWritten(conversationId, contentHash)
122-
} catch (error) {
123-
if (!this.config.failOpen) throw error
124-
}
125-
}
126-
127-
protected override formatRecallText(hits: RecallHit[]): string {
128-
if (hits.length === 0) return ''
129-
const lines = hits.map((h) => {
130-
const date = h.timestamp.slice(0, 10)
131-
const tag = h.type === 'decision' ? 'Decision' : h.type === 'skill' ? 'Skill' : 'Session'
132-
return `- [${tag} ${date} @ ${h.project}] ${h.snippet}`
133-
})
134-
return [
135-
'## Memoria — Relevant past memory',
136-
...lines,
137-
''
138-
].join('\n')
139-
}
14073
}

src/adapter/claude-code-adapter.ts

Lines changed: 20 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@
1515
// }
1616
// }
1717
//
18-
// The same command handles both events; it dispatches on hook_event_name
19-
// from the JSON payload.
18+
// The same command handles both events; it dispatches on hook_event_name.
19+
// Unlike the Codex/Antigravity adapters, `Stop` recovers both the user and
20+
// assistant text from the session transcript file rather than the payload.
2021

2122
import { readFile } from 'node:fs/promises'
22-
import { BaseAdapter } from './adapter.js'
23+
import { StdinHookAdapter } from './stdin-hook-adapter.js'
24+
import type { HookInput, HookTurn } from './stdin-hook-adapter.js'
2325
import type { MemoriaAdapterConfig } from './adapter.js'
24-
import { hashTurn } from './hook-state.js'
25-
import type { RecallHit } from '../core/types.js'
2626

2727
export interface ClaudeCodeAdapterConfig extends MemoriaAdapterConfig {
2828
/** Max transcript lines to scan backwards when locating the last turn (default: 50) */
@@ -46,86 +46,37 @@ export interface ClaudeCodeHookOutput {
4646
}
4747
}
4848

49-
export class ClaudeCodeAdapter extends BaseAdapter {
49+
export class ClaudeCodeAdapter extends StdinHookAdapter {
50+
protected readonly injectEvents = ['UserPromptSubmit'] as const
51+
protected readonly stopEvents = ['Stop'] as const
52+
protected readonly defaultConversationId = 'claude-code-session'
53+
5054
private readonly transcriptScanLimit: number
5155

5256
constructor(config: ClaudeCodeAdapterConfig) {
5357
super(config)
5458
this.transcriptScanLimit = config.transcriptScanLimit ?? 50
5559
}
5660

57-
/**
58-
* Dispatch on hook_event_name. Always resolves; never throws.
59-
* Errors are swallowed when failOpen is true (the default).
60-
*/
61-
async handleHookEvent(input: ClaudeCodeHookInput): Promise<ClaudeCodeHookOutput> {
62-
const event = input.hook_event_name ?? ''
63-
if (event === 'UserPromptSubmit') return this.handleUserPromptSubmit(input)
64-
if (event === 'Stop') {
65-
await this.handleStop(input)
66-
return {}
67-
}
68-
return {}
61+
/** Recover the completed turn from the session transcript rather than the payload. */
62+
protected async extractTurn(input: HookInput): Promise<HookTurn | null> {
63+
const transcriptPath = typeof input.transcript_path === 'string' ? input.transcript_path : ''
64+
if (!transcriptPath) return null
65+
return this.readLastTurn(transcriptPath)
6966
}
7067

71-
/** Recall relevant memory and inject it via additionalContext. */
72-
async handleUserPromptSubmit(input: ClaudeCodeHookInput): Promise<ClaudeCodeHookOutput> {
73-
const userMessage = (input.prompt ?? '').trim()
74-
const conversationId = input.session_id ?? 'claude-code-session'
75-
if (!userMessage) {
76-
return { hookSpecificOutput: { hookEventName: 'UserPromptSubmit' } }
77-
}
78-
79-
try {
80-
const rc = await this.recallForContext({ userMessage, conversationId })
81-
return {
82-
hookSpecificOutput: {
83-
hookEventName: 'UserPromptSubmit',
84-
additionalContext: rc.injectedText
85-
}
86-
}
87-
} catch (error) {
88-
if (!this.config.failOpen) throw error
89-
return { hookSpecificOutput: { hookEventName: 'UserPromptSubmit' } }
90-
}
91-
}
92-
93-
/** Persist the last user/assistant turn from the transcript file. */
94-
async handleStop(input: ClaudeCodeHookInput): Promise<void> {
95-
try {
96-
const transcriptPath = input.transcript_path
97-
const conversationId = input.session_id ?? 'claude-code-session'
98-
if (!transcriptPath) return
99-
100-
const turn = await this.readLastTurn(transcriptPath)
101-
if (!turn) return
102-
const contentHash = hashTurn(`${turn.user}\n${turn.assistant}`)
103-
if (!this.shouldWrite(conversationId, contentHash)) return
104-
105-
await this.client.remember({
106-
timestamp: new Date().toISOString(),
107-
project: this.config.project,
108-
summary: turn.assistant.slice(0, 200),
109-
events: [
110-
{
111-
event_type: 'ConversationTurn',
112-
timestamp: new Date().toISOString(),
113-
content: { user: turn.user, assistant: turn.assistant }
114-
}
115-
]
116-
})
117-
this.markWritten(conversationId, contentHash)
118-
} catch (error) {
119-
if (!this.config.failOpen) throw error
120-
}
68+
protected buildInjectOutput(eventName: string, text?: string): ClaudeCodeHookOutput {
69+
const output: ClaudeCodeHookOutput = { hookSpecificOutput: { hookEventName: eventName } }
70+
if (text !== undefined) output.hookSpecificOutput!.additionalContext = text
71+
return output
12172
}
12273

12374
/**
12475
* Scan the JSONL transcript backwards and extract the most recent
12576
* user/assistant pair. Each transcript line is a message envelope
12677
* like `{"type":"user","message":{"role":"user","content":[{"type":"text","text":"..."}]}}`.
12778
*/
128-
private async readLastTurn(transcriptPath: string): Promise<{ user: string; assistant: string } | null> {
79+
private async readLastTurn(transcriptPath: string): Promise<HookTurn | null> {
12980
const raw = await readFile(transcriptPath, 'utf8').catch(() => '')
13081
if (!raw) return null
13182
const lines = raw.split('\n').filter(Boolean).slice(-this.transcriptScanLimit)
@@ -143,20 +94,6 @@ export class ClaudeCodeAdapter extends BaseAdapter {
14394
if (!lastUser || !lastAssistant) return null
14495
return { user: lastUser, assistant: lastAssistant }
14596
}
146-
147-
protected override formatRecallText(hits: RecallHit[]): string {
148-
if (hits.length === 0) return ''
149-
const lines = hits.map((h) => {
150-
const date = h.timestamp.slice(0, 10)
151-
const tag = h.type === 'decision' ? 'Decision' : h.type === 'skill' ? 'Skill' : 'Session'
152-
return `- [${tag} ${date} @ ${h.project}] ${h.snippet}`
153-
})
154-
return [
155-
'## Memoria — Relevant past memory',
156-
...lines,
157-
''
158-
].join('\n')
159-
}
16097
}
16198

16299
function extractRoleFromTranscriptLine(line: string): 'user' | 'assistant' | null {

0 commit comments

Comments
 (0)