@aliyunrds/ctxdb 0.0.9 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -7
- package/dist/{chunk-TOXXGL5L.js → chunk-F25Q3WM4.js} +3 -1
- package/dist/{chunk-RHWGDY6S.js → chunk-I5HRGFZO.js} +3 -3
- package/dist/{chunk-PZJZ5O62.js → chunk-XWPTIFUK.js} +1 -1
- package/dist/cli/main.js +193 -14
- package/dist/hooks/session-start.js +3 -3
- package/dist/hooks/stop.js +2 -2
- package/dist/hooks/user-prompt-submit.js +3 -3
- package/dist/opencode/src/capture.ts +187 -0
- package/dist/opencode/src/config.ts +160 -0
- package/dist/opencode/src/hooks.ts +252 -0
- package/dist/opencode/src/http-client.ts +147 -0
- package/dist/opencode/src/index.ts +9 -0
- package/dist/opencode/src/kb-catalog.ts +64 -0
- package/dist/opencode/src/recall.ts +87 -0
- package/dist/opencode/src/warmup.ts +51 -0
- package/dist/setup/skills/contextdb-knowledge/SKILL.md +4 -4
- package/dist/setup/skills/contextdb-memory/SKILL.md +4 -4
- package/package.json +4 -3
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Capture path: read transcript via SDK → convert to ParsedMessage[] →
|
|
2
|
+
// run shared B-rule filtering / slicing → fingerprint dedup → POST to
|
|
3
|
+
// /v3/memories/add/.
|
|
4
|
+
//
|
|
5
|
+
// The hook layer wraps this and never throws; this module raises only
|
|
6
|
+
// when the SDK explicitly throws (we set throwOnError: true so 404/500
|
|
7
|
+
// surface as exceptions for the caller's stderr-log path).
|
|
8
|
+
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import {
|
|
11
|
+
filterMessagesForExtraction,
|
|
12
|
+
selectTurnMessages,
|
|
13
|
+
type ParsedMessage,
|
|
14
|
+
} from "@aliyunrds/ctxdb-shared";
|
|
15
|
+
import type { Message, Part } from "@opencode-ai/sdk";
|
|
16
|
+
import type { OpencodeConfig } from "./config.ts";
|
|
17
|
+
import type { HttpClient } from "./http-client.ts";
|
|
18
|
+
|
|
19
|
+
export interface SDKMessage {
|
|
20
|
+
info: Message;
|
|
21
|
+
parts: Part[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CaptureOutcome {
|
|
25
|
+
captured: boolean;
|
|
26
|
+
reason: string;
|
|
27
|
+
messageCount: number;
|
|
28
|
+
fingerprint: string | null;
|
|
29
|
+
serverResponse?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const EMPTY: CaptureOutcome = {
|
|
33
|
+
captured: false,
|
|
34
|
+
reason: "",
|
|
35
|
+
messageCount: 0,
|
|
36
|
+
fingerprint: null,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Convert SDK `messages()` response into ctxdb-shared's ParsedMessage
|
|
41
|
+
* format. Only keeps text parts (skips synthetic/ignored), concatenates
|
|
42
|
+
* multiple text parts in a single message into one content string.
|
|
43
|
+
*
|
|
44
|
+
* `index` is the original index in the input array; `isSummary` is true
|
|
45
|
+
* for assistant messages that the SDK marked summary === true (these
|
|
46
|
+
* get filtered out by the shared turn-selection logic).
|
|
47
|
+
*/
|
|
48
|
+
export function toParsedMessages(messages: unknown[]): ParsedMessage[] {
|
|
49
|
+
const parsed: ParsedMessage[] = [];
|
|
50
|
+
for (let i = 0; i < messages.length; i++) {
|
|
51
|
+
const m = messages[i];
|
|
52
|
+
const converted = toParsedMessage(m, i);
|
|
53
|
+
if (converted) parsed.push(converted);
|
|
54
|
+
}
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function unwrapSessionMessagesResponse(resp: unknown): unknown[] {
|
|
59
|
+
if (Array.isArray(resp)) return resp;
|
|
60
|
+
if (!resp || typeof resp !== "object") return [];
|
|
61
|
+
const root = resp as Record<string, unknown>;
|
|
62
|
+
if (Array.isArray(root.data)) return root.data;
|
|
63
|
+
if (root.data && typeof root.data === "object") {
|
|
64
|
+
const nested = root.data as Record<string, unknown>;
|
|
65
|
+
if (Array.isArray(nested.data)) return nested.data;
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(root.messages)) return root.messages;
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toParsedMessage(m: unknown, index: number): ParsedMessage | null {
|
|
72
|
+
if (!m || typeof m !== "object") return null;
|
|
73
|
+
const obj = m as Record<string, unknown>;
|
|
74
|
+
|
|
75
|
+
if (obj.info && typeof obj.info === "object" && Array.isArray(obj.parts)) {
|
|
76
|
+
const info = obj.info as { role?: unknown; summary?: unknown };
|
|
77
|
+
const role = info.role;
|
|
78
|
+
if (role !== "user" && role !== "assistant") return null;
|
|
79
|
+
const textChunks: string[] = [];
|
|
80
|
+
for (const rawPart of obj.parts) {
|
|
81
|
+
if (!rawPart || typeof rawPart !== "object") continue;
|
|
82
|
+
const p = rawPart as { type?: unknown; synthetic?: unknown; ignored?: unknown; text?: unknown };
|
|
83
|
+
if (p.type !== "text") continue;
|
|
84
|
+
if (p.synthetic === true || p.ignored === true) continue;
|
|
85
|
+
if (typeof p.text === "string" && p.text) textChunks.push(p.text);
|
|
86
|
+
}
|
|
87
|
+
const content = textChunks.join("\n").trim();
|
|
88
|
+
if (!content) return null;
|
|
89
|
+
const isSummary =
|
|
90
|
+
role === "assistant" && info.summary === true;
|
|
91
|
+
return { role, content, index, isSummary };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (obj.type === "user") {
|
|
95
|
+
const content = typeof obj.text === "string" ? obj.text.trim() : "";
|
|
96
|
+
return content ? { role: "user", content, index, isSummary: false } : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (obj.type === "assistant" && Array.isArray(obj.content)) {
|
|
100
|
+
const textChunks: string[] = [];
|
|
101
|
+
for (const rawPart of obj.content) {
|
|
102
|
+
if (!rawPart || typeof rawPart !== "object") continue;
|
|
103
|
+
const p = rawPart as { type?: unknown; text?: unknown };
|
|
104
|
+
if (p.type === "text" && typeof p.text === "string" && p.text) {
|
|
105
|
+
textChunks.push(p.text);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const content = textChunks.join("\n").trim();
|
|
109
|
+
return content ? { role: "assistant", content, index, isSummary: false } : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function fingerprintMessages(messages: { role: string; content: string }[]): string {
|
|
116
|
+
const h = createHash("sha256");
|
|
117
|
+
for (const m of messages) {
|
|
118
|
+
h.update(m.role);
|
|
119
|
+
h.update("\u0000");
|
|
120
|
+
h.update(m.content);
|
|
121
|
+
h.update("\u0001");
|
|
122
|
+
}
|
|
123
|
+
return h.digest("hex");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run the full capture pipeline given a transcript already fetched from
|
|
128
|
+
* the SDK. Returns `captured: true` only when the POST to
|
|
129
|
+
* /v3/memories/add/ succeeds. Dedup is the caller's job (compare
|
|
130
|
+
* `fingerprint` against last successful capture for this session).
|
|
131
|
+
*/
|
|
132
|
+
export async function runCapture(
|
|
133
|
+
messages: unknown[],
|
|
134
|
+
cfg: OpencodeConfig,
|
|
135
|
+
client: HttpClient,
|
|
136
|
+
timeoutMs: number,
|
|
137
|
+
lastFingerprint: string | null,
|
|
138
|
+
): Promise<CaptureOutcome> {
|
|
139
|
+
if (!cfg.autoCapture) return { ...EMPTY, reason: "auto_capture_disabled" };
|
|
140
|
+
if (!cfg.baseUrl) return { ...EMPTY, reason: "config_incomplete" };
|
|
141
|
+
if (messages.length === 0) return { ...EMPTY, reason: "empty_transcript" };
|
|
142
|
+
|
|
143
|
+
const parsed = toParsedMessages(messages);
|
|
144
|
+
if (parsed.length === 0) return { ...EMPTY, reason: "transcript_no_text_parts" };
|
|
145
|
+
|
|
146
|
+
const turn = selectTurnMessages(parsed);
|
|
147
|
+
if (turn.length === 0) return { ...EMPTY, reason: "empty_turn_slice" };
|
|
148
|
+
if (!turn.some((m) => m.role === "user")) return { ...EMPTY, reason: "no_user_in_turn" };
|
|
149
|
+
if (!turn.some((m) => m.role === "assistant")) {
|
|
150
|
+
return { ...EMPTY, reason: "no_assistant_in_turn" };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const raw = turn.map((m) => ({ role: m.role, content: m.content }));
|
|
154
|
+
const filtered = filterMessagesForExtraction(raw);
|
|
155
|
+
if (filtered.length === 0) return { ...EMPTY, reason: "all_filtered" };
|
|
156
|
+
|
|
157
|
+
const fingerprint = fingerprintMessages(filtered);
|
|
158
|
+
if (fingerprint === lastFingerprint) {
|
|
159
|
+
return { ...EMPTY, reason: "duplicate_fingerprint", fingerprint };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const payload = {
|
|
163
|
+
messages: filtered,
|
|
164
|
+
user_id: cfg.userId,
|
|
165
|
+
async_mode: true,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
let resp: unknown;
|
|
169
|
+
try {
|
|
170
|
+
resp = await client.postJson("/v3/memories/add/", payload, { timeoutMs });
|
|
171
|
+
} catch (err) {
|
|
172
|
+
return {
|
|
173
|
+
...EMPTY,
|
|
174
|
+
reason: `http_error: ${(err as Error).message}`,
|
|
175
|
+
messageCount: filtered.length,
|
|
176
|
+
fingerprint,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
captured: true,
|
|
182
|
+
reason: "ok",
|
|
183
|
+
messageCount: filtered.length,
|
|
184
|
+
fingerprint,
|
|
185
|
+
serverResponse: resp,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Read-only loader for `~/.ctxdb/ctxdb.json` `agents.opencode` section.
|
|
2
|
+
//
|
|
3
|
+
// The plugin never writes config — `ctxdb setup --agent opencode` is the
|
|
4
|
+
// sole writer (via the ctxdb package's lib/config.ts). This file mirrors
|
|
5
|
+
// just enough of that schema to drive the in-process hooks, with the same
|
|
6
|
+
// CTXDB_* env-var precedence as other agent integrations.
|
|
7
|
+
//
|
|
8
|
+
// Schema reminder (v2):
|
|
9
|
+
// { version: 2, agents: { opencode: { base_url, auto_recall, ... } } }
|
|
10
|
+
// Anything else (missing file, missing section, malformed JSON) → defaults,
|
|
11
|
+
// but the plugin is considered disabled until an API key is present via
|
|
12
|
+
// agents.opencode.api_key or CTXDB_API_KEY.
|
|
13
|
+
|
|
14
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_BASE_URL = "https://context-database.aliyuncs.com";
|
|
19
|
+
export const DEFAULT_USER_ID = "default";
|
|
20
|
+
export const DEFAULT_TOP_K = 5;
|
|
21
|
+
export const DEFAULT_THRESHOLD = 0.4;
|
|
22
|
+
export const DEFAULT_KNOWLEDGE_TOP_K = 6;
|
|
23
|
+
|
|
24
|
+
export type KbCatalogInjection = "session_start" | "user_prompt_submit" | "off";
|
|
25
|
+
export const DEFAULT_KB_CATALOG_INJECTION: KbCatalogInjection = "session_start";
|
|
26
|
+
|
|
27
|
+
export interface OpencodeConfig {
|
|
28
|
+
apiKey: string | null;
|
|
29
|
+
baseUrl: string;
|
|
30
|
+
userId: string;
|
|
31
|
+
autoCapture: boolean;
|
|
32
|
+
autoRecall: boolean;
|
|
33
|
+
warmupRecall: boolean;
|
|
34
|
+
recallKnowledge: boolean;
|
|
35
|
+
topK: number;
|
|
36
|
+
threshold: number;
|
|
37
|
+
knowledgeTopK: number;
|
|
38
|
+
debug: boolean;
|
|
39
|
+
kbCatalogInjection: KbCatalogInjection;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface LoadOptions {
|
|
43
|
+
path?: string;
|
|
44
|
+
env?: Record<string, string | undefined>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type RawObject = Record<string, unknown>;
|
|
48
|
+
|
|
49
|
+
function defaultPath(env: Record<string, string | undefined>): string {
|
|
50
|
+
if (env.CTXDB_CONFIG_PATH) return env.CTXDB_CONFIG_PATH;
|
|
51
|
+
return join(homedir(), ".ctxdb", "ctxdb.json");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeDefault(): OpencodeConfig {
|
|
55
|
+
return {
|
|
56
|
+
apiKey: null,
|
|
57
|
+
baseUrl: DEFAULT_BASE_URL,
|
|
58
|
+
userId: DEFAULT_USER_ID,
|
|
59
|
+
autoCapture: true,
|
|
60
|
+
autoRecall: true,
|
|
61
|
+
warmupRecall: true,
|
|
62
|
+
recallKnowledge: false,
|
|
63
|
+
topK: DEFAULT_TOP_K,
|
|
64
|
+
threshold: DEFAULT_THRESHOLD,
|
|
65
|
+
knowledgeTopK: DEFAULT_KNOWLEDGE_TOP_K,
|
|
66
|
+
debug: false,
|
|
67
|
+
kbCatalogInjection: DEFAULT_KB_CATALOG_INJECTION,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function coerceInt(v: unknown, fallback: number): number {
|
|
72
|
+
if (v === null || v === undefined || v === "") return fallback;
|
|
73
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
74
|
+
return Number.isFinite(n) ? Math.trunc(n) : fallback;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function coerceFloat(v: unknown, fallback: number): number {
|
|
78
|
+
if (v === null || v === undefined || v === "") return fallback;
|
|
79
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
80
|
+
return Number.isFinite(n) ? n : fallback;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function coerceBool(v: unknown, fallback: boolean): boolean {
|
|
84
|
+
if (typeof v === "boolean") return v;
|
|
85
|
+
if (v === undefined || v === null) return fallback;
|
|
86
|
+
return Boolean(v);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function coerceKbCatalogInjection(v: unknown): KbCatalogInjection {
|
|
90
|
+
if (v === "session_start" || v === "user_prompt_submit" || v === "off") return v;
|
|
91
|
+
return DEFAULT_KB_CATALOG_INJECTION;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readRaw(path: string): RawObject {
|
|
95
|
+
if (!existsSync(path)) return {};
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
98
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
99
|
+
return parsed as RawObject;
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// malformed → defaults
|
|
103
|
+
}
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function agentRaw(raw: RawObject): RawObject {
|
|
108
|
+
if (!(raw.version === 2)) return {};
|
|
109
|
+
const agents = raw.agents;
|
|
110
|
+
if (!agents || typeof agents !== "object" || Array.isArray(agents)) return {};
|
|
111
|
+
const section = (agents as RawObject).opencode;
|
|
112
|
+
if (!section || typeof section !== "object" || Array.isArray(section)) return {};
|
|
113
|
+
return section as RawObject;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function applyEnv(cfg: OpencodeConfig, env: Record<string, string | undefined>): OpencodeConfig {
|
|
117
|
+
if (env.CTXDB_API_KEY) cfg.apiKey = env.CTXDB_API_KEY;
|
|
118
|
+
if (env.CTXDB_BASE_URL) cfg.baseUrl = env.CTXDB_BASE_URL.replace(/\/+$/, "");
|
|
119
|
+
if (env.CTXDB_USER_ID) cfg.userId = env.CTXDB_USER_ID;
|
|
120
|
+
return cfg;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function loadOpencodeConfig(options: LoadOptions = {}): OpencodeConfig {
|
|
124
|
+
const env = options.env ?? process.env;
|
|
125
|
+
const path = options.path ?? defaultPath(env);
|
|
126
|
+
const raw = readRaw(path);
|
|
127
|
+
const section = agentRaw(raw);
|
|
128
|
+
|
|
129
|
+
const cfg: OpencodeConfig = {
|
|
130
|
+
apiKey:
|
|
131
|
+
typeof section.api_key === "string" && section.api_key ? section.api_key : null,
|
|
132
|
+
baseUrl:
|
|
133
|
+
typeof section.base_url === "string" && section.base_url
|
|
134
|
+
? String(section.base_url).replace(/\/+$/, "")
|
|
135
|
+
: DEFAULT_BASE_URL,
|
|
136
|
+
userId:
|
|
137
|
+
typeof section.user_id === "string" && section.user_id
|
|
138
|
+
? section.user_id
|
|
139
|
+
: DEFAULT_USER_ID,
|
|
140
|
+
autoCapture: coerceBool(section.auto_capture, true),
|
|
141
|
+
autoRecall: coerceBool(section.auto_recall, true),
|
|
142
|
+
warmupRecall: coerceBool(section.warmup_recall, true),
|
|
143
|
+
recallKnowledge: coerceBool(section.recall_knowledge, false),
|
|
144
|
+
topK: coerceInt(section.top_k, DEFAULT_TOP_K),
|
|
145
|
+
threshold: coerceFloat(section.threshold, DEFAULT_THRESHOLD),
|
|
146
|
+
knowledgeTopK: coerceInt(section.knowledge_top_k, DEFAULT_KNOWLEDGE_TOP_K),
|
|
147
|
+
debug: coerceBool(section.debug, false),
|
|
148
|
+
kbCatalogInjection: coerceKbCatalogInjection(section.kb_catalog_injection),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return applyEnv(cfg, env);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function isConfigured(cfg: OpencodeConfig): boolean {
|
|
155
|
+
return Boolean(cfg.apiKey && cfg.baseUrl);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function makeDefaultConfig(): OpencodeConfig {
|
|
159
|
+
return makeDefault();
|
|
160
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Plugin hooks: chat.message (prompt capture), system.transform (recall +
|
|
2
|
+
// KB injection), event (session.idle → capture). All three share state
|
|
3
|
+
// via the per-plugin-instance sessionState map.
|
|
4
|
+
//
|
|
5
|
+
// State eviction:
|
|
6
|
+
// - TTL 15 min: any session entry older than that is dropped on touch
|
|
7
|
+
// - LRU max 100: keep memory bounded across long-running OpenCode runs
|
|
8
|
+
//
|
|
9
|
+
// Failure policy: every hook handler wraps its work in try/catch and
|
|
10
|
+
// resolves normally on any error. Debug logs go to stderr only when
|
|
11
|
+
// agents.opencode.debug is true; nothing is rethrown.
|
|
12
|
+
|
|
13
|
+
import type { Hooks, PluginInput } from "@opencode-ai/plugin";
|
|
14
|
+
import type { Part } from "@opencode-ai/sdk";
|
|
15
|
+
|
|
16
|
+
import { isConfigured, loadOpencodeConfig, type OpencodeConfig } from "./config.ts";
|
|
17
|
+
import { HttpClient } from "./http-client.ts";
|
|
18
|
+
import { searchAndFormatRecall } from "./recall.ts";
|
|
19
|
+
import { fetchKbCatalogBlock } from "./kb-catalog.ts";
|
|
20
|
+
import { buildWarmupQuery, collectGitSignals } from "./warmup.ts";
|
|
21
|
+
import { runCapture, unwrapSessionMessagesResponse } from "./capture.ts";
|
|
22
|
+
|
|
23
|
+
const SESSION_TTL_MS = 15 * 60 * 1000;
|
|
24
|
+
const SESSION_MAX = 100;
|
|
25
|
+
|
|
26
|
+
const RECALL_TIMEOUT_MS = 5_000;
|
|
27
|
+
const CAPTURE_TIMEOUT_MS = 8_000;
|
|
28
|
+
|
|
29
|
+
export interface SessionState {
|
|
30
|
+
lastPrompt: string;
|
|
31
|
+
initialized: boolean;
|
|
32
|
+
lastFingerprint: string | null;
|
|
33
|
+
touched: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PluginRuntime {
|
|
37
|
+
config: OpencodeConfig;
|
|
38
|
+
http: HttpClient;
|
|
39
|
+
sessionState: Map<string, SessionState>;
|
|
40
|
+
cwd: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildRuntime(config: OpencodeConfig, cwd: string): PluginRuntime {
|
|
44
|
+
return {
|
|
45
|
+
config,
|
|
46
|
+
http: new HttpClient({ baseUrl: config.baseUrl, apiKey: config.apiKey }),
|
|
47
|
+
sessionState: new Map(),
|
|
48
|
+
cwd,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function touchSession(rt: PluginRuntime, sessionID: string): SessionState {
|
|
53
|
+
pruneSessions(rt.sessionState);
|
|
54
|
+
let s = rt.sessionState.get(sessionID);
|
|
55
|
+
if (!s) {
|
|
56
|
+
s = { lastPrompt: "", initialized: false, lastFingerprint: null, touched: Date.now() };
|
|
57
|
+
rt.sessionState.set(sessionID, s);
|
|
58
|
+
} else {
|
|
59
|
+
s.touched = Date.now();
|
|
60
|
+
}
|
|
61
|
+
return s;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function pruneSessions(state: Map<string, SessionState>): void {
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
for (const [id, s] of state) {
|
|
67
|
+
if (now - s.touched > SESSION_TTL_MS) state.delete(id);
|
|
68
|
+
}
|
|
69
|
+
if (state.size > SESSION_MAX) {
|
|
70
|
+
const overflow = state.size - SESSION_MAX;
|
|
71
|
+
const sorted = [...state.entries()].sort((a, b) => a[1].touched - b[1].touched);
|
|
72
|
+
for (let i = 0; i < overflow; i++) state.delete(sorted[i][0]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function extractTextPrompt(parts: Part[]): string {
|
|
77
|
+
const chunks: string[] = [];
|
|
78
|
+
for (const p of parts) {
|
|
79
|
+
if (p.type !== "text") continue;
|
|
80
|
+
if (p.synthetic === true || p.ignored === true) continue;
|
|
81
|
+
if (typeof p.text === "string" && p.text) chunks.push(p.text);
|
|
82
|
+
}
|
|
83
|
+
return chunks.join("\n").trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function buildHooks(input: PluginInput): Promise<Hooks> {
|
|
87
|
+
const config = loadOpencodeConfig();
|
|
88
|
+
if (!isConfigured(config)) {
|
|
89
|
+
logDebug(
|
|
90
|
+
config,
|
|
91
|
+
"config",
|
|
92
|
+
"opencode integration disabled: missing agents.opencode.api_key in ~/.ctxdb/ctxdb.json or CTXDB_API_KEY",
|
|
93
|
+
);
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const cwd = input.directory || input.worktree || process.cwd();
|
|
98
|
+
const rt = buildRuntime(config, cwd);
|
|
99
|
+
|
|
100
|
+
const hooks: Hooks = {
|
|
101
|
+
"chat.message": async (input, output) => {
|
|
102
|
+
try {
|
|
103
|
+
const text = extractTextPrompt(output.parts);
|
|
104
|
+
if (!text) return;
|
|
105
|
+
const s = touchSession(rt, input.sessionID);
|
|
106
|
+
s.lastPrompt = text;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
logError(rt.config, "chat.message", err);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
113
|
+
try {
|
|
114
|
+
const sessionID = input.sessionID;
|
|
115
|
+
// First-message detection only works when sessionID is present;
|
|
116
|
+
// when undefined we degrade to "every-message" recall and skip
|
|
117
|
+
// first-time-only branches.
|
|
118
|
+
const state = sessionID ? touchSession(rt, sessionID) : null;
|
|
119
|
+
const isFirstMessage = state ? !state.initialized : false;
|
|
120
|
+
|
|
121
|
+
const prompt = state?.lastPrompt ?? "";
|
|
122
|
+
|
|
123
|
+
const tasks: Promise<string>[] = [];
|
|
124
|
+
|
|
125
|
+
if (rt.config.autoRecall && prompt) {
|
|
126
|
+
tasks.push(
|
|
127
|
+
searchAndLogRecall("system.transform.recall", prompt, rt),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (rt.config.warmupRecall && isFirstMessage) {
|
|
132
|
+
const git = collectGitSignals(rt.cwd);
|
|
133
|
+
const query = buildWarmupQuery(rt.cwd, git);
|
|
134
|
+
tasks.push(
|
|
135
|
+
searchAndLogRecall("system.transform.warmupRecall", query, rt),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const kbMode = rt.config.kbCatalogInjection;
|
|
140
|
+
const kbThisTurn =
|
|
141
|
+
kbMode === "user_prompt_submit" ||
|
|
142
|
+
(kbMode === "session_start" && isFirstMessage);
|
|
143
|
+
if (kbThisTurn) {
|
|
144
|
+
tasks.push(fetchKbCatalogBlock(rt.http, RECALL_TIMEOUT_MS));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const blocks = (await Promise.all(tasks)).filter((b) => b && b.length > 0);
|
|
148
|
+
for (const b of blocks) output.system.push(b);
|
|
149
|
+
|
|
150
|
+
if (state) state.initialized = true;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
logError(rt.config, "system.transform", err);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
"event": async (evtInput) => {
|
|
156
|
+
try {
|
|
157
|
+
const ev = evtInput.event;
|
|
158
|
+
if (!ev || ev.type !== "session.idle") return;
|
|
159
|
+
const sessionID = ev.properties?.sessionID;
|
|
160
|
+
if (!sessionID) return;
|
|
161
|
+
if (!rt.config.autoCapture) return;
|
|
162
|
+
|
|
163
|
+
const state = touchSession(rt, sessionID);
|
|
164
|
+
|
|
165
|
+
let messages: unknown[];
|
|
166
|
+
try {
|
|
167
|
+
const data = await withTimeout(
|
|
168
|
+
input.client.session.messages({
|
|
169
|
+
path: { id: sessionID },
|
|
170
|
+
query: { limit: 24 },
|
|
171
|
+
throwOnError: true,
|
|
172
|
+
}),
|
|
173
|
+
CAPTURE_TIMEOUT_MS,
|
|
174
|
+
"session.messages",
|
|
175
|
+
);
|
|
176
|
+
messages = unwrapSessionMessagesResponse(data);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
logError(rt.config, "event.fetchMessages", err);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (messages.length === 0) return;
|
|
182
|
+
|
|
183
|
+
const outcome = await runCapture(
|
|
184
|
+
messages,
|
|
185
|
+
rt.config,
|
|
186
|
+
rt.http,
|
|
187
|
+
CAPTURE_TIMEOUT_MS,
|
|
188
|
+
state.lastFingerprint,
|
|
189
|
+
);
|
|
190
|
+
if (outcome.captured && outcome.fingerprint) {
|
|
191
|
+
state.lastFingerprint = outcome.fingerprint;
|
|
192
|
+
}
|
|
193
|
+
if (!outcome.captured && outcome.reason.startsWith("http_error:")) {
|
|
194
|
+
logError(rt.config, "event.capture", new Error(outcome.reason));
|
|
195
|
+
}
|
|
196
|
+
logDebug(
|
|
197
|
+
rt.config,
|
|
198
|
+
"event.capture",
|
|
199
|
+
`sessionID=${sessionID} captured=${outcome.captured} reason=${outcome.reason} count=${outcome.messageCount}`,
|
|
200
|
+
);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
logError(rt.config, "event", err);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
return hooks;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function searchAndLogRecall(
|
|
211
|
+
scope: string,
|
|
212
|
+
query: string,
|
|
213
|
+
rt: PluginRuntime,
|
|
214
|
+
): Promise<string> {
|
|
215
|
+
const result = await searchAndFormatRecall(query, rt.config, rt.http, RECALL_TIMEOUT_MS);
|
|
216
|
+
if (result.reason.startsWith("http_error:")) {
|
|
217
|
+
logError(rt.config, scope, new Error(result.reason));
|
|
218
|
+
}
|
|
219
|
+
return result.block;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function withTimeout<T>(
|
|
223
|
+
promise: Promise<T>,
|
|
224
|
+
timeoutMs: number,
|
|
225
|
+
label: string,
|
|
226
|
+
): Promise<T> {
|
|
227
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
228
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
229
|
+
timer = setTimeout(() => {
|
|
230
|
+
reject(new Error(`${label} timeout after ${timeoutMs}ms`));
|
|
231
|
+
}, timeoutMs);
|
|
232
|
+
});
|
|
233
|
+
try {
|
|
234
|
+
return await Promise.race([promise, timeout]);
|
|
235
|
+
} finally {
|
|
236
|
+
if (timer) clearTimeout(timer);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function logDebug(config: OpencodeConfig, scope: string, message: string): void {
|
|
241
|
+
if (!config.debug) return;
|
|
242
|
+
try {
|
|
243
|
+
process.stderr.write(`[ctxdb-opencode] ${scope}: ${message}\n`);
|
|
244
|
+
} catch {
|
|
245
|
+
// never block hook return on logging failure
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function logError(config: OpencodeConfig, scope: string, err: unknown): void {
|
|
250
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
251
|
+
logDebug(config, scope, msg);
|
|
252
|
+
}
|