@aliyunrds/ctxdb 0.0.10 → 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.
- package/README.md +4 -4
- package/dist/{chunk-XWPTIFUK.js → chunk-PLWEIEFH.js} +1 -1
- package/dist/{chunk-F25Q3WM4.js → chunk-QHYJ7OXC.js} +1 -0
- package/dist/{chunk-I5HRGFZO.js → chunk-QPFFMW52.js} +2 -2
- package/dist/cli/main.js +46 -7
- 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/index.js +630 -0
- package/package.json +2 -2
- package/dist/opencode/src/capture.ts +0 -187
- package/dist/opencode/src/config.ts +0 -160
- package/dist/opencode/src/hooks.ts +0 -252
- package/dist/opencode/src/http-client.ts +0 -147
- package/dist/opencode/src/index.ts +0 -9
- package/dist/opencode/src/kb-catalog.ts +0 -64
- package/dist/opencode/src/recall.ts +0 -87
- package/dist/opencode/src/warmup.ts +0 -51
|
@@ -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
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aliyunrds/ctxdb",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Unified access layer for RDS ContextDatabase: `ctxdb` CLI (memory + KB ops), one-shot `setup --agent <qoder|qoderwork|codex|claude|opencode>` installer, per-agent config, hooks/plugins, and SKILL.md.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"vitest": "^4.0.18"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
|
-
"build": "tsup && mkdir -p dist/setup dist/opencode && cp
|
|
42
|
+
"build": "tsup && pnpm --dir ../opencode build && mkdir -p dist/setup dist/opencode && cp ../opencode/dist/index.js dist/opencode/index.js && cp -r src/setup/skills dist/setup/ && chmod +x dist/hooks/*.js dist/cli/main.js",
|
|
43
43
|
"test": "vitest run"
|
|
44
44
|
}
|
|
45
45
|
}
|