@aliyunrds/ctxdb 0.0.9 → 1.0.0-beta.1

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.
@@ -4,7 +4,7 @@ import {
4
4
  isConnectionError,
5
5
  resetCircuit,
6
6
  tripCircuit
7
- } from "../chunk-PZJZ5O62.js";
7
+ } from "../chunk-PLWEIEFH.js";
8
8
  import {
9
9
  CtxdbError,
10
10
  HttpClient,
@@ -13,7 +13,7 @@ import {
13
13
  isDebug,
14
14
  load,
15
15
  setDebug
16
- } from "../chunk-TOXXGL5L.js";
16
+ } from "../chunk-QHYJ7OXC.js";
17
17
 
18
18
  // src/lib/capture-orchestrator.ts
19
19
  import {
@@ -2,11 +2,11 @@
2
2
  import {
3
3
  fetchKbCatalogBlock,
4
4
  recallTurn
5
- } from "../chunk-RHWGDY6S.js";
5
+ } from "../chunk-QPFFMW52.js";
6
6
  import "../chunk-6S5RJYBC.js";
7
7
  import {
8
8
  isCircuitOpen
9
- } from "../chunk-PZJZ5O62.js";
9
+ } from "../chunk-PLWEIEFH.js";
10
10
  import {
11
11
  HttpClient,
12
12
  agentFromArgvWithFallback,
@@ -14,7 +14,7 @@ import {
14
14
  isComplete,
15
15
  load,
16
16
  setDebug
17
- } from "../chunk-TOXXGL5L.js";
17
+ } from "../chunk-QHYJ7OXC.js";
18
18
 
19
19
  // src/hooks/user-prompt-submit.ts
20
20
  import { pathToFileURL } from "url";
@@ -0,0 +1,630 @@
1
+ // src/config.ts
2
+ import { readFileSync, existsSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ var DEFAULT_BASE_URL = "https://context-database.aliyuncs.com";
6
+ var DEFAULT_USER_ID = "default";
7
+ var DEFAULT_TOP_K = 5;
8
+ var DEFAULT_THRESHOLD = 0.4;
9
+ var DEFAULT_KNOWLEDGE_TOP_K = 6;
10
+ var DEFAULT_KB_CATALOG_INJECTION = "session_start";
11
+ function defaultPath(env) {
12
+ if (env.CTXDB_CONFIG_PATH) return env.CTXDB_CONFIG_PATH;
13
+ return join(homedir(), ".ctxdb", "ctxdb.json");
14
+ }
15
+ function coerceInt(v, fallback) {
16
+ if (v === null || v === void 0 || v === "") return fallback;
17
+ const n = typeof v === "number" ? v : Number(v);
18
+ return Number.isFinite(n) ? Math.trunc(n) : fallback;
19
+ }
20
+ function coerceFloat(v, fallback) {
21
+ if (v === null || v === void 0 || v === "") return fallback;
22
+ const n = typeof v === "number" ? v : Number(v);
23
+ return Number.isFinite(n) ? n : fallback;
24
+ }
25
+ function coerceBool(v, fallback) {
26
+ if (typeof v === "boolean") return v;
27
+ if (v === void 0 || v === null) return fallback;
28
+ return Boolean(v);
29
+ }
30
+ function coerceKbCatalogInjection(v) {
31
+ if (v === "session_start" || v === "user_prompt_submit" || v === "off") return v;
32
+ return DEFAULT_KB_CATALOG_INJECTION;
33
+ }
34
+ function readRaw(path) {
35
+ if (!existsSync(path)) return {};
36
+ try {
37
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
38
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
39
+ return parsed;
40
+ }
41
+ } catch {
42
+ }
43
+ return {};
44
+ }
45
+ function agentRaw(raw) {
46
+ if (!(raw.version === 2)) return {};
47
+ const agents = raw.agents;
48
+ if (!agents || typeof agents !== "object" || Array.isArray(agents)) return {};
49
+ const section = agents.opencode;
50
+ if (!section || typeof section !== "object" || Array.isArray(section)) return {};
51
+ return section;
52
+ }
53
+ function applyEnv(cfg, env) {
54
+ if (env.CTXDB_API_KEY) cfg.apiKey = env.CTXDB_API_KEY;
55
+ if (env.CTXDB_BASE_URL) cfg.baseUrl = env.CTXDB_BASE_URL.replace(/\/+$/, "");
56
+ if (env.CTXDB_USER_ID) cfg.userId = env.CTXDB_USER_ID;
57
+ return cfg;
58
+ }
59
+ function loadOpencodeConfig(options = {}) {
60
+ const env = options.env ?? process.env;
61
+ const path = options.path ?? defaultPath(env);
62
+ const raw = readRaw(path);
63
+ const section = agentRaw(raw);
64
+ const cfg = {
65
+ apiKey: typeof section.api_key === "string" && section.api_key ? section.api_key : null,
66
+ baseUrl: typeof section.base_url === "string" && section.base_url ? String(section.base_url).replace(/\/+$/, "") : DEFAULT_BASE_URL,
67
+ userId: typeof section.user_id === "string" && section.user_id ? section.user_id : DEFAULT_USER_ID,
68
+ autoCapture: coerceBool(section.auto_capture, true),
69
+ autoRecall: coerceBool(section.auto_recall, true),
70
+ warmupRecall: coerceBool(section.warmup_recall, false),
71
+ recallKnowledge: coerceBool(section.recall_knowledge, false),
72
+ topK: coerceInt(section.top_k, DEFAULT_TOP_K),
73
+ threshold: coerceFloat(section.threshold, DEFAULT_THRESHOLD),
74
+ knowledgeTopK: coerceInt(section.knowledge_top_k, DEFAULT_KNOWLEDGE_TOP_K),
75
+ debug: coerceBool(section.debug, false),
76
+ kbCatalogInjection: coerceKbCatalogInjection(section.kb_catalog_injection)
77
+ };
78
+ return applyEnv(cfg, env);
79
+ }
80
+ function isConfigured(cfg) {
81
+ return Boolean(cfg.apiKey && cfg.baseUrl);
82
+ }
83
+
84
+ // src/http-client.ts
85
+ var DEFAULT_TIMEOUT_MS = 3e4;
86
+ var CtxdbHttpError = class extends Error {
87
+ status;
88
+ path;
89
+ constructor(path, status, message) {
90
+ super(message);
91
+ this.name = "CtxdbHttpError";
92
+ this.path = path;
93
+ this.status = status;
94
+ }
95
+ };
96
+ var HttpClient = class {
97
+ baseUrl;
98
+ apiKey;
99
+ userAgent;
100
+ fetchImpl;
101
+ constructor(opts) {
102
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
103
+ this.apiKey = opts.apiKey;
104
+ this.userAgent = opts.userAgent ?? "ctxdb-opencode-plugin/0.0.0";
105
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
106
+ }
107
+ headers(contentType) {
108
+ const h = {
109
+ "User-Agent": this.userAgent,
110
+ Connection: "close"
111
+ };
112
+ if (this.apiKey) h.Authorization = `Token ${this.apiKey}`;
113
+ if (contentType) h["Content-Type"] = contentType;
114
+ return h;
115
+ }
116
+ async get(path, params, options = {}) {
117
+ let url = `${this.baseUrl}${path}`;
118
+ if (params) {
119
+ const qs = new URLSearchParams();
120
+ for (const [k, v] of Object.entries(params)) {
121
+ if (v !== void 0 && v !== null) qs.append(k, String(v));
122
+ }
123
+ const s = qs.toString();
124
+ if (s) url = `${url}?${s}`;
125
+ }
126
+ return this.request("GET", url, path, void 0, void 0, options.timeoutMs);
127
+ }
128
+ async postJson(path, body, options = {}) {
129
+ const url = `${this.baseUrl}${path}`;
130
+ return this.request("POST", url, path, JSON.stringify(body), "application/json", options.timeoutMs);
131
+ }
132
+ async request(method, url, path, body, contentType, timeoutMs) {
133
+ const effectiveTimeout = timeoutMs ?? DEFAULT_TIMEOUT_MS;
134
+ const controller = new AbortController();
135
+ const timer = setTimeout(() => controller.abort(), effectiveTimeout);
136
+ try {
137
+ let resp;
138
+ try {
139
+ resp = await this.fetchImpl(url, {
140
+ method,
141
+ headers: this.headers(contentType),
142
+ body,
143
+ signal: controller.signal
144
+ });
145
+ } catch (err) {
146
+ if (err?.name === "AbortError") {
147
+ throw new CtxdbHttpError(path, null, `timeout after ${effectiveTimeout}ms`);
148
+ }
149
+ throw new CtxdbHttpError(path, null, `network error: ${err?.message ?? err}`);
150
+ }
151
+ if (resp.status === 204) return {};
152
+ let text;
153
+ try {
154
+ text = await resp.text();
155
+ } catch (err) {
156
+ throw new CtxdbHttpError(path, resp.status, `body read failed: ${err?.message ?? err}`);
157
+ }
158
+ if (!resp.ok) {
159
+ throw new CtxdbHttpError(path, resp.status, extractDetail(text) || `HTTP ${resp.status}`);
160
+ }
161
+ if (!text) return {};
162
+ try {
163
+ return JSON.parse(text);
164
+ } catch {
165
+ return text;
166
+ }
167
+ } finally {
168
+ clearTimeout(timer);
169
+ }
170
+ }
171
+ };
172
+ function extractDetail(text) {
173
+ if (!text) return "";
174
+ try {
175
+ const parsed = JSON.parse(text);
176
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
177
+ for (const k of ["detail", "message", "error"]) {
178
+ const v = parsed[k];
179
+ if (typeof v === "string" && v) return v;
180
+ }
181
+ return JSON.stringify(parsed);
182
+ }
183
+ return String(parsed);
184
+ } catch {
185
+ return text;
186
+ }
187
+ }
188
+
189
+ // src/recall.ts
190
+ import {
191
+ formatRecalledMemoriesBlock,
192
+ buildExternalKnowledgeBlock
193
+ } from "@aliyunrds/ctxdb-shared";
194
+ var EMPTY = { block: "", memoryCount: 0, knowledgeChunkCount: 0, reason: "" };
195
+ function stripSystemReminders(raw) {
196
+ const cleaned = raw.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "").trim();
197
+ return cleaned || raw;
198
+ }
199
+ async function searchAndFormatRecall(prompt, cfg, client, timeoutMs) {
200
+ if (!prompt.trim()) return { ...EMPTY, reason: "empty_prompt" };
201
+ if (!cfg.baseUrl) return { ...EMPTY, reason: "config_incomplete" };
202
+ const body = {
203
+ query: stripSystemReminders(prompt),
204
+ user_id: cfg.userId,
205
+ top_k: cfg.topK,
206
+ threshold: cfg.threshold
207
+ };
208
+ if (cfg.recallKnowledge) {
209
+ body.knowledge = { enable: true, top_k: cfg.knowledgeTopK };
210
+ }
211
+ let resp;
212
+ try {
213
+ resp = await client.postJson("/v3/memories/search/", body, { timeoutMs });
214
+ } catch (err) {
215
+ return { ...EMPTY, reason: `http_error: ${err.message}` };
216
+ }
217
+ if (!resp || typeof resp !== "object") {
218
+ return { ...EMPTY, reason: "bad_response_shape" };
219
+ }
220
+ const r = resp;
221
+ const memories = Array.isArray(r.results) ? r.results : Array.isArray(r.memories) ? r.memories : [];
222
+ const knowledge = r.knowledge && typeof r.knowledge === "object" ? r.knowledge : null;
223
+ const chunks = Array.isArray(knowledge?.chunks) ? knowledge.chunks : [];
224
+ const blocks = [];
225
+ if (memories.length > 0) blocks.push(formatRecalledMemoriesBlock(memories, cfg.userId));
226
+ if (chunks.length > 0) blocks.push(buildExternalKnowledgeBlock(chunks, cfg.userId));
227
+ if (blocks.length === 0) return { ...EMPTY, reason: "nothing_to_inject" };
228
+ return {
229
+ block: blocks.join("\n\n"),
230
+ memoryCount: memories.length,
231
+ knowledgeChunkCount: chunks.length,
232
+ reason: "ok"
233
+ };
234
+ }
235
+
236
+ // src/kb-catalog.ts
237
+ var KB_LIST_PATH = "/v1/knowledge/knowledge_bases";
238
+ function sanitizeKeyEntities(raw) {
239
+ if (!Array.isArray(raw)) return [];
240
+ const out = [];
241
+ for (const e of raw) {
242
+ if (typeof e !== "string") continue;
243
+ const cleaned = e.replace(/\s+/g, " ").trim();
244
+ if (cleaned) out.push(cleaned);
245
+ }
246
+ return out;
247
+ }
248
+ async function fetchKbCatalogBlock(client, timeoutMs, agent = "opencode") {
249
+ let resp;
250
+ try {
251
+ resp = await client.get(KB_LIST_PATH, void 0, { timeoutMs });
252
+ } catch {
253
+ return "";
254
+ }
255
+ let kbs = [];
256
+ if (Array.isArray(resp)) {
257
+ kbs = resp;
258
+ } else if (resp && typeof resp === "object") {
259
+ const o = resp;
260
+ const list = o.knowledge_bases ?? o.results;
261
+ if (Array.isArray(list)) kbs = list;
262
+ }
263
+ const active = kbs.filter((kb) => kb.status === "active" && typeof kb.name === "string" && kb.name);
264
+ if (active.length === 0) return "";
265
+ const lines = active.map((kb) => {
266
+ const ents = sanitizeKeyEntities(kb.key_entities);
267
+ return ents.length > 0 ? `\xB7 ${kb.name}: ${ents.join(", ")}` : `\xB7 ${kb.name}`;
268
+ });
269
+ return [
270
+ "<available-knowledge-bases>",
271
+ `When you identify that relevant information may exist in the knowledge bases below, you MUST run \`ctxdb kb search "<query>" --kb=<name> --agent=${agent}\` with targeted keywords after initial analysis to supplement and correct your approach. For keyword-based KB search, keep each single query focused: use at most 5 keywords or short phrases; run multiple targeted searches if more are needed. Knowledge bases:`,
272
+ ...lines,
273
+ "</available-knowledge-bases>"
274
+ ].join("\n");
275
+ }
276
+
277
+ // src/warmup.ts
278
+ import { execSync } from "child_process";
279
+ import { basename } from "path";
280
+ function collectGitSignals(cwd) {
281
+ const result = { branch: "", recentCommits: [] };
282
+ try {
283
+ result.branch = execSync("git rev-parse --abbrev-ref HEAD", {
284
+ cwd,
285
+ timeout: 500,
286
+ encoding: "utf-8",
287
+ stdio: ["ignore", "pipe", "ignore"]
288
+ }).trim();
289
+ } catch {
290
+ }
291
+ try {
292
+ const log = execSync("git log --oneline -3 --no-decorate", {
293
+ cwd,
294
+ timeout: 500,
295
+ encoding: "utf-8",
296
+ stdio: ["ignore", "pipe", "ignore"]
297
+ }).trim();
298
+ if (log) {
299
+ result.recentCommits = log.split("\n").map((l) => {
300
+ const idx = l.indexOf(" ");
301
+ return idx > 0 ? l.slice(idx + 1) : l;
302
+ });
303
+ }
304
+ } catch {
305
+ }
306
+ return result;
307
+ }
308
+ function buildWarmupQuery(cwd, git) {
309
+ const project = basename(cwd) || "unknown";
310
+ const parts = [`project: ${project}`];
311
+ if (git.branch) parts.push(`branch: ${git.branch}`);
312
+ if (git.recentCommits.length > 0) parts.push(`recent work: ${git.recentCommits.join("; ")}`);
313
+ return parts.join(", ");
314
+ }
315
+
316
+ // src/capture.ts
317
+ import { createHash } from "crypto";
318
+ import {
319
+ filterMessagesForExtraction,
320
+ selectTurnMessages
321
+ } from "@aliyunrds/ctxdb-shared";
322
+ var EMPTY2 = {
323
+ captured: false,
324
+ reason: "",
325
+ messageCount: 0,
326
+ fingerprint: null
327
+ };
328
+ function toParsedMessages(messages) {
329
+ const parsed = [];
330
+ for (let i = 0; i < messages.length; i++) {
331
+ const m = messages[i];
332
+ const converted = toParsedMessage(m, i);
333
+ if (converted) parsed.push(converted);
334
+ }
335
+ return parsed;
336
+ }
337
+ function unwrapSessionMessagesResponse(resp) {
338
+ if (Array.isArray(resp)) return resp;
339
+ if (!resp || typeof resp !== "object") return [];
340
+ const root = resp;
341
+ if (Array.isArray(root.data)) return root.data;
342
+ if (root.data && typeof root.data === "object") {
343
+ const nested = root.data;
344
+ if (Array.isArray(nested.data)) return nested.data;
345
+ }
346
+ if (Array.isArray(root.messages)) return root.messages;
347
+ return [];
348
+ }
349
+ function toParsedMessage(m, index) {
350
+ if (!m || typeof m !== "object") return null;
351
+ const obj = m;
352
+ if (obj.info && typeof obj.info === "object" && Array.isArray(obj.parts)) {
353
+ const info = obj.info;
354
+ const role = info.role;
355
+ if (role !== "user" && role !== "assistant") return null;
356
+ const textChunks = [];
357
+ for (const rawPart of obj.parts) {
358
+ if (!rawPart || typeof rawPart !== "object") continue;
359
+ const p = rawPart;
360
+ if (p.type !== "text") continue;
361
+ if (p.synthetic === true || p.ignored === true) continue;
362
+ if (typeof p.text === "string" && p.text) textChunks.push(p.text);
363
+ }
364
+ const content = textChunks.join("\n").trim();
365
+ if (!content) return null;
366
+ const isSummary = role === "assistant" && info.summary === true;
367
+ return { role, content, index, isSummary };
368
+ }
369
+ if (obj.type === "user") {
370
+ const content = typeof obj.text === "string" ? obj.text.trim() : "";
371
+ return content ? { role: "user", content, index, isSummary: false } : null;
372
+ }
373
+ if (obj.type === "assistant" && Array.isArray(obj.content)) {
374
+ const textChunks = [];
375
+ for (const rawPart of obj.content) {
376
+ if (!rawPart || typeof rawPart !== "object") continue;
377
+ const p = rawPart;
378
+ if (p.type === "text" && typeof p.text === "string" && p.text) {
379
+ textChunks.push(p.text);
380
+ }
381
+ }
382
+ const content = textChunks.join("\n").trim();
383
+ return content ? { role: "assistant", content, index, isSummary: false } : null;
384
+ }
385
+ return null;
386
+ }
387
+ function fingerprintMessages(messages) {
388
+ const h = createHash("sha256");
389
+ for (const m of messages) {
390
+ h.update(m.role);
391
+ h.update("\0");
392
+ h.update(m.content);
393
+ h.update("");
394
+ }
395
+ return h.digest("hex");
396
+ }
397
+ async function runCapture(messages, cfg, client, timeoutMs, lastFingerprint) {
398
+ if (!cfg.autoCapture) return { ...EMPTY2, reason: "auto_capture_disabled" };
399
+ if (!cfg.baseUrl) return { ...EMPTY2, reason: "config_incomplete" };
400
+ if (messages.length === 0) return { ...EMPTY2, reason: "empty_transcript" };
401
+ const parsed = toParsedMessages(messages);
402
+ if (parsed.length === 0) return { ...EMPTY2, reason: "transcript_no_text_parts" };
403
+ const turn = selectTurnMessages(parsed);
404
+ if (turn.length === 0) return { ...EMPTY2, reason: "empty_turn_slice" };
405
+ if (!turn.some((m) => m.role === "user")) return { ...EMPTY2, reason: "no_user_in_turn" };
406
+ if (!turn.some((m) => m.role === "assistant")) {
407
+ return { ...EMPTY2, reason: "no_assistant_in_turn" };
408
+ }
409
+ const raw = turn.map((m) => ({ role: m.role, content: m.content }));
410
+ const filtered = filterMessagesForExtraction(raw);
411
+ if (filtered.length === 0) return { ...EMPTY2, reason: "all_filtered" };
412
+ const fingerprint = fingerprintMessages(filtered);
413
+ if (fingerprint === lastFingerprint) {
414
+ return { ...EMPTY2, reason: "duplicate_fingerprint", fingerprint };
415
+ }
416
+ const payload = {
417
+ messages: filtered,
418
+ user_id: cfg.userId,
419
+ async_mode: true
420
+ };
421
+ let resp;
422
+ try {
423
+ resp = await client.postJson("/v3/memories/add/", payload, { timeoutMs });
424
+ } catch (err) {
425
+ return {
426
+ ...EMPTY2,
427
+ reason: `http_error: ${err.message}`,
428
+ messageCount: filtered.length,
429
+ fingerprint
430
+ };
431
+ }
432
+ return {
433
+ captured: true,
434
+ reason: "ok",
435
+ messageCount: filtered.length,
436
+ fingerprint,
437
+ serverResponse: resp
438
+ };
439
+ }
440
+
441
+ // src/hooks.ts
442
+ var SESSION_TTL_MS = 15 * 60 * 1e3;
443
+ var SESSION_MAX = 100;
444
+ var RECALL_TIMEOUT_MS = 5e3;
445
+ var CAPTURE_TIMEOUT_MS = 8e3;
446
+ function buildRuntime(config, cwd) {
447
+ return {
448
+ config,
449
+ http: new HttpClient({ baseUrl: config.baseUrl, apiKey: config.apiKey }),
450
+ sessionState: /* @__PURE__ */ new Map(),
451
+ cwd
452
+ };
453
+ }
454
+ function touchSession(rt, sessionID) {
455
+ pruneSessions(rt.sessionState);
456
+ let s = rt.sessionState.get(sessionID);
457
+ if (!s) {
458
+ s = { lastPrompt: "", initialized: false, lastFingerprint: null, touched: Date.now() };
459
+ rt.sessionState.set(sessionID, s);
460
+ } else {
461
+ s.touched = Date.now();
462
+ }
463
+ return s;
464
+ }
465
+ function pruneSessions(state) {
466
+ const now = Date.now();
467
+ for (const [id, s] of state) {
468
+ if (now - s.touched > SESSION_TTL_MS) state.delete(id);
469
+ }
470
+ if (state.size > SESSION_MAX) {
471
+ const overflow = state.size - SESSION_MAX;
472
+ const sorted = [...state.entries()].sort((a, b) => a[1].touched - b[1].touched);
473
+ for (let i = 0; i < overflow; i++) state.delete(sorted[i][0]);
474
+ }
475
+ }
476
+ function extractTextPrompt(parts) {
477
+ const chunks = [];
478
+ for (const p of parts) {
479
+ if (p.type !== "text") continue;
480
+ if (p.synthetic === true || p.ignored === true) continue;
481
+ if (typeof p.text === "string" && p.text) chunks.push(p.text);
482
+ }
483
+ return chunks.join("\n").trim();
484
+ }
485
+ async function buildHooks(input) {
486
+ const config = loadOpencodeConfig();
487
+ if (!isConfigured(config)) {
488
+ logDebug(
489
+ config,
490
+ "config",
491
+ "opencode integration disabled: missing agents.opencode.api_key in ~/.ctxdb/ctxdb.json or CTXDB_API_KEY"
492
+ );
493
+ return {};
494
+ }
495
+ const cwd = input.directory || input.worktree || process.cwd();
496
+ const rt = buildRuntime(config, cwd);
497
+ const hooks = {
498
+ "chat.message": async (input2, output) => {
499
+ try {
500
+ const text = extractTextPrompt(output.parts);
501
+ if (!text) return;
502
+ const s = touchSession(rt, input2.sessionID);
503
+ s.lastPrompt = text;
504
+ } catch (err) {
505
+ logError(rt.config, "chat.message", err);
506
+ }
507
+ },
508
+ "experimental.chat.system.transform": async (input2, output) => {
509
+ try {
510
+ const sessionID = input2.sessionID;
511
+ const state = sessionID ? touchSession(rt, sessionID) : null;
512
+ const isFirstMessage = state ? !state.initialized : false;
513
+ const prompt = state?.lastPrompt ?? "";
514
+ const tasks = [];
515
+ if (rt.config.autoRecall && prompt) {
516
+ tasks.push(
517
+ searchAndLogRecall("system.transform.recall", prompt, rt)
518
+ );
519
+ }
520
+ if (rt.config.warmupRecall && isFirstMessage) {
521
+ const git = collectGitSignals(rt.cwd);
522
+ const query = buildWarmupQuery(rt.cwd, git);
523
+ tasks.push(
524
+ searchAndLogRecall("system.transform.warmupRecall", query, rt)
525
+ );
526
+ }
527
+ const kbMode = rt.config.kbCatalogInjection;
528
+ const kbThisTurn = kbMode === "user_prompt_submit" || kbMode === "session_start" && isFirstMessage;
529
+ if (kbThisTurn) {
530
+ tasks.push(fetchKbCatalogBlock(rt.http, RECALL_TIMEOUT_MS));
531
+ }
532
+ const blocks = (await Promise.all(tasks)).filter((b) => b && b.length > 0);
533
+ for (const b of blocks) output.system.push(b);
534
+ if (state) state.initialized = true;
535
+ } catch (err) {
536
+ logError(rt.config, "system.transform", err);
537
+ }
538
+ },
539
+ "event": async (evtInput) => {
540
+ try {
541
+ const ev = evtInput.event;
542
+ if (!ev || ev.type !== "session.idle") return;
543
+ const sessionID = ev.properties?.sessionID;
544
+ if (!sessionID) return;
545
+ if (!rt.config.autoCapture) return;
546
+ const state = touchSession(rt, sessionID);
547
+ let messages;
548
+ try {
549
+ const data = await withTimeout(
550
+ input.client.session.messages({
551
+ path: { id: sessionID },
552
+ query: { limit: 24 },
553
+ throwOnError: true
554
+ }),
555
+ CAPTURE_TIMEOUT_MS,
556
+ "session.messages"
557
+ );
558
+ messages = unwrapSessionMessagesResponse(data);
559
+ } catch (err) {
560
+ logError(rt.config, "event.fetchMessages", err);
561
+ return;
562
+ }
563
+ if (messages.length === 0) return;
564
+ const outcome = await runCapture(
565
+ messages,
566
+ rt.config,
567
+ rt.http,
568
+ CAPTURE_TIMEOUT_MS,
569
+ state.lastFingerprint
570
+ );
571
+ if (outcome.captured && outcome.fingerprint) {
572
+ state.lastFingerprint = outcome.fingerprint;
573
+ }
574
+ if (!outcome.captured && outcome.reason.startsWith("http_error:")) {
575
+ logError(rt.config, "event.capture", new Error(outcome.reason));
576
+ }
577
+ logDebug(
578
+ rt.config,
579
+ "event.capture",
580
+ `sessionID=${sessionID} captured=${outcome.captured} reason=${outcome.reason} count=${outcome.messageCount}`
581
+ );
582
+ } catch (err) {
583
+ logError(rt.config, "event", err);
584
+ }
585
+ }
586
+ };
587
+ return hooks;
588
+ }
589
+ async function searchAndLogRecall(scope, query, rt) {
590
+ const result = await searchAndFormatRecall(query, rt.config, rt.http, RECALL_TIMEOUT_MS);
591
+ if (result.reason.startsWith("http_error:")) {
592
+ logError(rt.config, scope, new Error(result.reason));
593
+ }
594
+ return result.block;
595
+ }
596
+ async function withTimeout(promise, timeoutMs, label) {
597
+ let timer;
598
+ const timeout = new Promise((_, reject) => {
599
+ timer = setTimeout(() => {
600
+ reject(new Error(`${label} timeout after ${timeoutMs}ms`));
601
+ }, timeoutMs);
602
+ });
603
+ try {
604
+ return await Promise.race([promise, timeout]);
605
+ } finally {
606
+ if (timer) clearTimeout(timer);
607
+ }
608
+ }
609
+ function logDebug(config, scope, message) {
610
+ if (!config.debug) return;
611
+ try {
612
+ process.stderr.write(`[ctxdb-opencode] ${scope}: ${message}
613
+ `);
614
+ } catch {
615
+ }
616
+ }
617
+ function logError(config, scope, err) {
618
+ const msg = err instanceof Error ? err.message : String(err);
619
+ logDebug(config, scope, msg);
620
+ }
621
+
622
+ // src/index.ts
623
+ var plugin = async (input) => buildHooks(input);
624
+ var index_default = {
625
+ id: "ctxdb",
626
+ server: plugin
627
+ };
628
+ export {
629
+ index_default as default
630
+ };
@@ -18,7 +18,7 @@ description: contextdb 知识库高频操作(查询 / 浏览 KB / 上传文档
18
18
 
19
19
  ## 2. 高频 recipes
20
20
 
21
- > 命令里的 `--agent={{agent}}` 是当前 skill 安装目标。不要把示例改成 `qoder`;如果读到未替换的双花括号 agent 模板,先按 §6 判定真实 agent,再替换成 `qoder` / `qoderwork` / `codex` / `claude` / `default`。
21
+ > 命令里的 `--agent={{agent}}` 是当前 skill 安装目标。不要把示例改成 `qoder`;如果读到未替换的双花括号 agent 模板,先按 §6 判定真实 agent,再替换成 `qoder` / `qoderwork` / `codex` / `claude` / `opencode` / `default`。
22
22
 
23
23
  ### Recipe 1:在已知 KB 中查内容(最常见)
24
24
 
@@ -172,18 +172,18 @@ ctxdb kb upload-file <kb-name> <local-path> --agent={{agent}}
172
172
 
173
173
  | 参数 | 说明 |
174
174
  |------|------|
175
- | `--agent=<name>` | **必填**。指定操作哪个 agent 的配置桶(`qoder` / `qoderwork` / `codex` / `claude` / `default`) |
175
+ | `--agent=<name>` | **必填**。指定操作哪个 agent 的配置桶(`qoder` / `qoderwork` / `codex` / `claude` / `opencode` / `default`) |
176
176
 
177
177
  `--agent` 解析顺序:
178
178
  1. 命令行显式 `--agent=<name>` → 用它
179
179
  2. 缺省 → 读 `CTXDB_AGENT` 环境变量
180
180
  3. env 也没有 → 落到 `"default"` 桶
181
- 4. 新版本 `ctxdb setup --agent <qoder|qoderwork|codex|claude>` 会在 `agents.default` 缺失时复制当前 agent 配置作为兜底
181
+ 4. 新版本 `ctxdb setup --agent <qoder|qoderwork|codex|claude|opencode>` 会在 `agents.default` 缺失时复制当前 agent 配置作为兜底
182
182
  5. 若 `"default"` 桶仍不存在或未配置完整 → exit 2 "config incomplete for agent default"
183
183
 
184
184
  ## 7. 配置
185
185
 
186
- - 配置文件:`~/.ctxdb/ctxdb.json`,分 `agents.<name>` 段(`qoder` / `qoderwork` / `codex` / `claude` / `default`),互不复用
186
+ - 配置文件:`~/.ctxdb/ctxdb.json`,分 `agents.<name>` 段(`qoder` / `qoderwork` / `codex` / `claude` / `opencode` / `default`),互不复用
187
187
  - 配置命令:`ctxdb setup --agent <name> --api-key=<key> --base-url=<url> [--user-id=<id>]`
188
188
  - 带 `--agent` 时还会装 hooks + skill;若 `agents.default` 缺失,会复制当前 agent 配置作为 CLI 兜底
189
189
  - `--agent default` 只写 `agents.default` 段(仅 CLI 用,不装 hooks / skill)