dsh-trivium 0.2.0
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/LICENSE +21 -0
- package/README.md +112 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +804 -0
- package/lib/embed.js +110 -0
- package/lib/extract.js +454 -0
- package/lib/index.js +388 -0
- package/lib/schema.js +66 -0
- package/lib/server.js +222 -0
- package/lib/settings.js +68 -0
- package/lib/store.js +1042 -0
- package/lib/tokens.js +28 -0
- package/lib/tools.js +216 -0
- package/lib/until.js +85 -0
- package/package.json +68 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
import { cwdOf, registerTools } from "./tools.js";
|
|
3
|
+
import {
|
|
4
|
+
buildShortMapReport,
|
|
5
|
+
closeAll,
|
|
6
|
+
formatHit,
|
|
7
|
+
namedRecallHits,
|
|
8
|
+
openWorkspaceDb,
|
|
9
|
+
searchNodes,
|
|
10
|
+
setActiveCwd,
|
|
11
|
+
embedQueryVector,
|
|
12
|
+
} from "./store.js";
|
|
13
|
+
import { clipToTokenBudget } from "./tokens.js";
|
|
14
|
+
import { clip } from "./schema.js";
|
|
15
|
+
import {
|
|
16
|
+
clearPending,
|
|
17
|
+
distill,
|
|
18
|
+
loadPending,
|
|
19
|
+
savePending,
|
|
20
|
+
textFromContent,
|
|
21
|
+
} from "./extract.js";
|
|
22
|
+
import { readUiSettings, registerMemoryApi } from "./server.js";
|
|
23
|
+
import { applyRecallMode, recallModeOf } from "./settings.js";
|
|
24
|
+
import { configureEmbedding } from "./embed.js";
|
|
25
|
+
|
|
26
|
+
export const name = "dsh-trivium";
|
|
27
|
+
export const inject = ["tools"];
|
|
28
|
+
|
|
29
|
+
const DEFAULTS = {
|
|
30
|
+
autoRecall: false,
|
|
31
|
+
anchorRecall: false,
|
|
32
|
+
recallMode: "off",
|
|
33
|
+
extractEnabled: true,
|
|
34
|
+
writeApproval: false,
|
|
35
|
+
mapTokenBudget: 400,
|
|
36
|
+
expandDepth: 1,
|
|
37
|
+
topK: 8,
|
|
38
|
+
embeddingEnabled: false,
|
|
39
|
+
embeddingUrl: "",
|
|
40
|
+
embeddingModel: "text-embedding-3-small",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const buffers = new Map();
|
|
44
|
+
const idleTimers = new Map();
|
|
45
|
+
const status = {
|
|
46
|
+
lastInjectTokens: 0,
|
|
47
|
+
lastInjectAt: null,
|
|
48
|
+
lastExtract: null,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function liveOptions(config) {
|
|
52
|
+
const merged = { ...DEFAULTS, ...config, ...readUiSettings() };
|
|
53
|
+
applyRecallMode(merged, recallModeOf(merged));
|
|
54
|
+
configureEmbedding(merged);
|
|
55
|
+
return merged;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sessionKey(session) {
|
|
59
|
+
return String(session?.id || session?.header?.id || "");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function bufOf(session) {
|
|
63
|
+
const id = sessionKey(session);
|
|
64
|
+
if (!buffers.has(id)) {
|
|
65
|
+
buffers.set(id, {
|
|
66
|
+
sessionId: id,
|
|
67
|
+
cwd: session?.header?.cwd || "",
|
|
68
|
+
turns: [],
|
|
69
|
+
calls: Object.create(null),
|
|
70
|
+
dirty: false,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const buf = buffers.get(id);
|
|
74
|
+
if (session?.header?.cwd) buf.cwd = session.header.cwd;
|
|
75
|
+
return buf;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function clearIdle(id) {
|
|
79
|
+
const timer = idleTimers.get(id);
|
|
80
|
+
if (timer) clearTimeout(timer);
|
|
81
|
+
idleTimers.delete(id);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveLlmTarget(session, agent) {
|
|
85
|
+
const header = typeof session?.requestHeader === "function" ? session.requestHeader() : null;
|
|
86
|
+
const cfg = header?.config;
|
|
87
|
+
if (cfg?.provider && cfg?.model) return { provider: cfg.provider, model: cfg.model };
|
|
88
|
+
if (agent?.options?.provider && agent?.options?.model) {
|
|
89
|
+
return { provider: agent.options.provider, model: agent.options.model };
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function makeLlmCall(ctx, session, agent) {
|
|
95
|
+
if (!ctx.llm) return null;
|
|
96
|
+
const target = resolveLlmTarget(session, agent);
|
|
97
|
+
if (!target) return null;
|
|
98
|
+
return async (prompt) => {
|
|
99
|
+
const assembler = new BlockAssembler();
|
|
100
|
+
const options = {
|
|
101
|
+
provider: target.provider,
|
|
102
|
+
model: target.model,
|
|
103
|
+
maxTokens: 400,
|
|
104
|
+
sessionId: session?.id,
|
|
105
|
+
messages: [
|
|
106
|
+
createUserMessage({
|
|
107
|
+
content: [{ type: "text", text: prompt }],
|
|
108
|
+
source: { kind: "plugin", plugin: "dsh-trivium" },
|
|
109
|
+
}),
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk);
|
|
113
|
+
const finish = assembler.finish;
|
|
114
|
+
if (finish?.kind === "error" || finish?.kind === "aborted") {
|
|
115
|
+
throw new Error(finish.failure?.message || finish.kind);
|
|
116
|
+
}
|
|
117
|
+
return (assembler.blocks() || [])
|
|
118
|
+
.filter((b) => b.type === "text")
|
|
119
|
+
.map((b) => b.text)
|
|
120
|
+
.join("\n");
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function runExtract(ctx, { cwd, turns, sessionId, session, agent, log, options, reason }) {
|
|
125
|
+
if (options.extractEnabled === false) return;
|
|
126
|
+
if (!cwd || !turns?.length) return;
|
|
127
|
+
try {
|
|
128
|
+
const db = await openWorkspaceDb(cwd);
|
|
129
|
+
const result = await distill({
|
|
130
|
+
db,
|
|
131
|
+
turns,
|
|
132
|
+
sessionId,
|
|
133
|
+
llmCall: makeLlmCall(ctx, session, agent),
|
|
134
|
+
log,
|
|
135
|
+
});
|
|
136
|
+
status.lastExtract = {
|
|
137
|
+
at: new Date().toISOString(),
|
|
138
|
+
reason,
|
|
139
|
+
applied: result.applied.length,
|
|
140
|
+
llmFailed: result.llmFailed,
|
|
141
|
+
};
|
|
142
|
+
if (result.llmFailed) {
|
|
143
|
+
savePending(cwd, { sessionId, turns, error: "llm failed" });
|
|
144
|
+
} else {
|
|
145
|
+
clearPending(cwd);
|
|
146
|
+
}
|
|
147
|
+
} catch (err) {
|
|
148
|
+
log.warn?.(`[dsh-trivium] extract skipped: ${err.message}`);
|
|
149
|
+
try {
|
|
150
|
+
savePending(cwd, { sessionId, turns, error: String(err.message || err) });
|
|
151
|
+
} catch {
|
|
152
|
+
// ignore
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function onSessionEvent(session, event) {
|
|
158
|
+
const buf = bufOf(session);
|
|
159
|
+
switch (event.type) {
|
|
160
|
+
case "user/message": {
|
|
161
|
+
const msg = event.data;
|
|
162
|
+
if (msg?.source?.kind && msg.source.kind !== "user") break;
|
|
163
|
+
const text = clip(textFromContent(msg?.content), 500);
|
|
164
|
+
if (text) {
|
|
165
|
+
buf.turns.push({ role: "user", text, turn: event.data?.turn });
|
|
166
|
+
buf.dirty = true;
|
|
167
|
+
}
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case "assistant/message": {
|
|
171
|
+
const msg = event.data?.message || event.data;
|
|
172
|
+
const text = clip(textFromContent(msg?.content), 400);
|
|
173
|
+
if (text) {
|
|
174
|
+
buf.turns.push({ role: "assistant", text, turn: event.data?.turn });
|
|
175
|
+
buf.dirty = true;
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case "tool/call": {
|
|
180
|
+
buf.calls[event.data.callId] = event.data.name;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
case "tool/result": {
|
|
184
|
+
const callId = event.data?.message?.source?.callId || event.data?.callId;
|
|
185
|
+
const name = buf.calls[callId] || "tool";
|
|
186
|
+
const msg = event.data?.message || event.data;
|
|
187
|
+
const content0 = Array.isArray(msg?.content) ? msg.content[0] : null;
|
|
188
|
+
const err = event.data?.error || msg?.error;
|
|
189
|
+
const isError =
|
|
190
|
+
Boolean(err) ||
|
|
191
|
+
msg?.isError === true ||
|
|
192
|
+
content0?.isError === true ||
|
|
193
|
+
event.data?.ok === false ||
|
|
194
|
+
msg?.ok === false;
|
|
195
|
+
buf.turns.push({
|
|
196
|
+
role: "tool",
|
|
197
|
+
name,
|
|
198
|
+
ok: !isError,
|
|
199
|
+
text: clip(textFromContent(event.data?.message?.content), 200),
|
|
200
|
+
turn: event.data?.turn,
|
|
201
|
+
});
|
|
202
|
+
buf.dirty = true;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case "turn/end": {
|
|
206
|
+
if (buf.dirty && buf.cwd && buf.turns.length) {
|
|
207
|
+
savePending(buf.cwd, { sessionId: buf.sessionId, turns: buf.turns });
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
default:
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
if (buf.turns.length > 80) buf.turns = buf.turns.slice(-80);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function apply(ctx, config = {}) {
|
|
218
|
+
const log = ctx.logger?.("dsh-trivium") ?? console;
|
|
219
|
+
const optionsOf = () => liveOptions(config);
|
|
220
|
+
|
|
221
|
+
let runtime = ctx;
|
|
222
|
+
ctx.inject(["llm"], (llmCtx) => {
|
|
223
|
+
runtime = llmCtx;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
registerTools(ctx, optionsOf);
|
|
227
|
+
|
|
228
|
+
ctx.on("agent/session-start", async ({ agent }) => {
|
|
229
|
+
try {
|
|
230
|
+
const cwd = cwdOf(agent);
|
|
231
|
+
setActiveCwd(cwd);
|
|
232
|
+
const db = await openWorkspaceDb(cwd);
|
|
233
|
+
const budget = optionsOf().mapTokenBudget || 400;
|
|
234
|
+
const map = buildShortMapReport(db, budget);
|
|
235
|
+
status.lastInjectTokens = map.tokens;
|
|
236
|
+
status.lastInjectAt = new Date().toISOString();
|
|
237
|
+
agent.inject(
|
|
238
|
+
createUserMessage({
|
|
239
|
+
content: [{ type: "text", text: map.text }],
|
|
240
|
+
source: {
|
|
241
|
+
kind: "plugin",
|
|
242
|
+
plugin: "dsh-trivium",
|
|
243
|
+
form: "snapshot",
|
|
244
|
+
sections: [{ name: "dsh-trivium", text: map.text }],
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
const pending = loadPending(cwd);
|
|
249
|
+
if (pending?.turns?.length && optionsOf().extractEnabled !== false) {
|
|
250
|
+
runExtract(runtime, {
|
|
251
|
+
cwd,
|
|
252
|
+
turns: pending.turns,
|
|
253
|
+
sessionId: pending.sessionId || agent.session?.id,
|
|
254
|
+
session: agent.session,
|
|
255
|
+
agent,
|
|
256
|
+
log,
|
|
257
|
+
options: optionsOf(),
|
|
258
|
+
reason: "pending-replay",
|
|
259
|
+
}).catch((err) => log.warn?.(`[dsh-trivium] pending replay: ${err.message}`));
|
|
260
|
+
}
|
|
261
|
+
} catch (err) {
|
|
262
|
+
log.warn?.(`[dsh-trivium] session-start skipped: ${err.message}`);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
ctx.on("session/event", (session, event) => {
|
|
267
|
+
try {
|
|
268
|
+
onSessionEvent(session, event);
|
|
269
|
+
if (event.type === "compaction/end" && !event.data?.error) {
|
|
270
|
+
clearIdle(sessionKey(session));
|
|
271
|
+
const buf = bufOf(session);
|
|
272
|
+
runExtract(runtime, {
|
|
273
|
+
cwd: buf.cwd || session.header?.cwd,
|
|
274
|
+
turns: buf.turns,
|
|
275
|
+
sessionId: buf.sessionId || session.id,
|
|
276
|
+
session,
|
|
277
|
+
log,
|
|
278
|
+
options: optionsOf(),
|
|
279
|
+
reason: "compaction/end",
|
|
280
|
+
}).catch((err) => log.warn?.(`[dsh-trivium] compaction extract: ${err.message}`));
|
|
281
|
+
}
|
|
282
|
+
if (event.type === "turn/end") {
|
|
283
|
+
const id = sessionKey(session);
|
|
284
|
+
clearIdle(id);
|
|
285
|
+
const timer = setTimeout(() => {
|
|
286
|
+
idleTimers.delete(id);
|
|
287
|
+
const buf = bufOf(session);
|
|
288
|
+
if (!buf.dirty || !buf.turns.length || optionsOf().extractEnabled === false) return;
|
|
289
|
+
runExtract(runtime, {
|
|
290
|
+
cwd: buf.cwd || session.header?.cwd,
|
|
291
|
+
turns: buf.turns,
|
|
292
|
+
sessionId: buf.sessionId || session.id,
|
|
293
|
+
session,
|
|
294
|
+
log,
|
|
295
|
+
options: optionsOf(),
|
|
296
|
+
reason: "idle",
|
|
297
|
+
})
|
|
298
|
+
.then(() => {
|
|
299
|
+
buf.dirty = false;
|
|
300
|
+
})
|
|
301
|
+
.catch((err) => log.warn?.(`[dsh-trivium] idle extract: ${err.message}`));
|
|
302
|
+
}, 12000);
|
|
303
|
+
idleTimers.set(id, timer);
|
|
304
|
+
}
|
|
305
|
+
} catch (err) {
|
|
306
|
+
log.warn?.(`[dsh-trivium] session/event skipped: ${err.message}`);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
ctx.on("agent/pre-step", async ({ agent, messages }, next) => {
|
|
311
|
+
const decision = await next();
|
|
312
|
+
try {
|
|
313
|
+
const options = optionsOf();
|
|
314
|
+
const mode = recallModeOf(options);
|
|
315
|
+
if (mode === "off") return decision;
|
|
316
|
+
if (!decision || decision.kind !== "enter") return decision;
|
|
317
|
+
const proposed = [...(decision.messages || []), ...(messages || [])];
|
|
318
|
+
const lastUser = [...proposed].reverse().find((m) => m?.source?.kind === "user");
|
|
319
|
+
if (!lastUser) return decision;
|
|
320
|
+
const query = textFromContent(lastUser.content).trim();
|
|
321
|
+
if (!query) return decision;
|
|
322
|
+
const db = await openWorkspaceDb(cwdOf(agent));
|
|
323
|
+
let hits;
|
|
324
|
+
if (mode === "anchor") {
|
|
325
|
+
hits = namedRecallHits(db, query, { topK: 8 }).slice(0, 8);
|
|
326
|
+
} else {
|
|
327
|
+
const queryVector = await embedQueryVector(query);
|
|
328
|
+
hits = searchNodes(db, query, {
|
|
329
|
+
topK: 3,
|
|
330
|
+
expandDepth: 1,
|
|
331
|
+
queryVector: queryVector || undefined,
|
|
332
|
+
}).slice(0, 3);
|
|
333
|
+
}
|
|
334
|
+
if (!hits.length) return decision;
|
|
335
|
+
const lines = hits.map((hit) => {
|
|
336
|
+
const row = formatHit(db, hit);
|
|
337
|
+
return `- [${row.type}#${row.id}] ${row.l0} (${row.path.join(" | ") || "no path"})`;
|
|
338
|
+
});
|
|
339
|
+
const form = mode === "anchor" ? "anchorRecall" : "autoRecall";
|
|
340
|
+
const text = clipToTokenBudget(
|
|
341
|
+
`dsh-trivium ${form} (L0 only; use ctx_read for full text):\n${lines.join("\n")}`,
|
|
342
|
+
300,
|
|
343
|
+
);
|
|
344
|
+
return {
|
|
345
|
+
kind: "enter",
|
|
346
|
+
messages: [
|
|
347
|
+
createUserMessage({
|
|
348
|
+
content: [{ type: "text", text }],
|
|
349
|
+
source: {
|
|
350
|
+
kind: "plugin",
|
|
351
|
+
plugin: "dsh-trivium",
|
|
352
|
+
form: "recall",
|
|
353
|
+
},
|
|
354
|
+
}),
|
|
355
|
+
...decision.messages,
|
|
356
|
+
],
|
|
357
|
+
};
|
|
358
|
+
} catch (err) {
|
|
359
|
+
log.warn?.(`[dsh-trivium] pre-step recall skipped: ${err.message}`);
|
|
360
|
+
return decision;
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
ctx.on("session/disposed", (session) => {
|
|
365
|
+
try {
|
|
366
|
+
clearIdle(sessionKey(session));
|
|
367
|
+
const buf = bufOf(session);
|
|
368
|
+
if (buf.dirty && buf.cwd && buf.turns.length) {
|
|
369
|
+
savePending(buf.cwd, { sessionId: buf.sessionId, turns: buf.turns });
|
|
370
|
+
}
|
|
371
|
+
buffers.delete(sessionKey(session));
|
|
372
|
+
} catch (err) {
|
|
373
|
+
log.warn?.(`[dsh-trivium] dispose skipped: ${err.message}`);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
378
|
+
try {
|
|
379
|
+
registerMemoryApi(webCtx, { liveOptions: optionsOf, status });
|
|
380
|
+
} catch (err) {
|
|
381
|
+
log.warn?.(`[dsh-trivium] settings api skipped: ${err.message}`);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
ctx.effect(() => () => {
|
|
386
|
+
closeAll();
|
|
387
|
+
});
|
|
388
|
+
}
|
package/lib/schema.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Node and edge conventions. Keep this the single source of payload shape. */
|
|
2
|
+
|
|
3
|
+
export const DIM = 1536;
|
|
4
|
+
|
|
5
|
+
export const NODE_TYPES = Object.freeze([
|
|
6
|
+
"workspace",
|
|
7
|
+
"entity",
|
|
8
|
+
"preference",
|
|
9
|
+
"decision",
|
|
10
|
+
"experience",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export const EDGE_LABELS = Object.freeze({
|
|
14
|
+
inWorkspace: "in_workspace",
|
|
15
|
+
about: "about",
|
|
16
|
+
decided: "decided",
|
|
17
|
+
broke: "broke",
|
|
18
|
+
fixed: "fixed",
|
|
19
|
+
sameAs: "same_as",
|
|
20
|
+
fromSession: "from_session",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const BUSINESS_EDGES = Object.freeze(["about", "decided", "broke", "fixed"]);
|
|
24
|
+
|
|
25
|
+
/** Weights written on link(). Engine expand currently ignores labels; we still persist them. */
|
|
26
|
+
export const EDGE_WEIGHTS = Object.freeze({
|
|
27
|
+
about: 1,
|
|
28
|
+
decided: 1,
|
|
29
|
+
broke: 0.9,
|
|
30
|
+
fixed: 0.9,
|
|
31
|
+
in_workspace: 0.15,
|
|
32
|
+
same_as: 0.5,
|
|
33
|
+
from_session: 0.1,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export function weightOf(label) {
|
|
37
|
+
return EDGE_WEIGHTS[label] ?? 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const SECRET_RE =
|
|
41
|
+
/(api[_-]?key|secret|password|token|bearer\s+[a-z0-9]|sk-[a-z0-9])/i;
|
|
42
|
+
|
|
43
|
+
export function zeroVector() {
|
|
44
|
+
return new Array(DIM).fill(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function nowIso() {
|
|
48
|
+
return new Date().toISOString();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function clip(text, max = 200) {
|
|
52
|
+
const s = String(text || "").replace(/\s+/g, " ").trim();
|
|
53
|
+
if (s.length <= max) return s;
|
|
54
|
+
return `${s.slice(0, max - 1)}…`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function looksSecret(text) {
|
|
58
|
+
return SECRET_RE.test(String(text || ""));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function indexTextFor(payload) {
|
|
62
|
+
const parts = [payload.type, payload.name, payload.text, payload.uri, payload.fail, payload.fix, payload.until]
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.map(String);
|
|
65
|
+
return parts.join(" ");
|
|
66
|
+
}
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
archiveNode,
|
|
4
|
+
backfillEmbeddings,
|
|
5
|
+
dbPathFor,
|
|
6
|
+
deleteNode,
|
|
7
|
+
exportGraph,
|
|
8
|
+
getActiveCwd,
|
|
9
|
+
importGraph,
|
|
10
|
+
listNodes,
|
|
11
|
+
listOpenWorkspaces,
|
|
12
|
+
mergeNodes,
|
|
13
|
+
nodeCountOf,
|
|
14
|
+
openWorkspaceDb,
|
|
15
|
+
updateNode,
|
|
16
|
+
} from "./store.js";
|
|
17
|
+
import { embeddingPublicStatus } from "./embed.js";
|
|
18
|
+
import { recallModeOf, writeUiSettings } from "./settings.js";
|
|
19
|
+
|
|
20
|
+
export { readUiSettings, writeUiSettings } from "./settings.js";
|
|
21
|
+
|
|
22
|
+
function isLoopback(req) {
|
|
23
|
+
const ra = req.socket && req.socket.remoteAddress;
|
|
24
|
+
return ra === "127.0.0.1" || ra === "::1" || ra === "::ffff:127.0.0.1";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sendJson(res, status, body) {
|
|
28
|
+
const data = Buffer.from(JSON.stringify(body), "utf8");
|
|
29
|
+
res.writeHead(status, {
|
|
30
|
+
"content-type": "application/json; charset=utf-8",
|
|
31
|
+
"cache-control": "no-store",
|
|
32
|
+
"content-length": String(data.length),
|
|
33
|
+
});
|
|
34
|
+
res.end(data);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readBody(req) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const chunks = [];
|
|
40
|
+
req.on("data", (c) => chunks.push(c));
|
|
41
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
42
|
+
req.on("error", reject);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function dbForRequest(url) {
|
|
47
|
+
const cwd = url.searchParams.get("cwd") || getActiveCwd();
|
|
48
|
+
if (!cwd) return { cwd: null, db: null };
|
|
49
|
+
return { cwd, db: await openWorkspaceDb(cwd) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function registerMemoryApi(ctx, { liveOptions, status }) {
|
|
53
|
+
const dispose = ctx.webServer.register({
|
|
54
|
+
kind: "prefix",
|
|
55
|
+
path: "/api/dsh-trivium",
|
|
56
|
+
handler: async (req, res) => {
|
|
57
|
+
if (!isLoopback(req)) {
|
|
58
|
+
res.writeHead(403);
|
|
59
|
+
res.end("forbidden");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
await handle(req, res, { liveOptions, status });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
sendJson(res, 500, { ok: false, message: String(err.message || err) });
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
return dispose;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function handle(req, res, { liveOptions, status }) {
|
|
73
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
74
|
+
const path = url.pathname.replace(/\/$/, "") || "/";
|
|
75
|
+
const method = req.method || "GET";
|
|
76
|
+
|
|
77
|
+
if (path === "/api/dsh-trivium/status" && method === "GET") {
|
|
78
|
+
const opts = liveOptions();
|
|
79
|
+
const cwd = url.searchParams.get("cwd") || getActiveCwd();
|
|
80
|
+
let nodeCount = 0;
|
|
81
|
+
let dbPath = cwd ? dbPathFor(cwd) : "";
|
|
82
|
+
if (cwd) {
|
|
83
|
+
const db = await openWorkspaceDb(cwd);
|
|
84
|
+
nodeCount = nodeCountOf(db);
|
|
85
|
+
dbPath = dbPathFor(cwd);
|
|
86
|
+
}
|
|
87
|
+
const mode = recallModeOf(opts);
|
|
88
|
+
sendJson(res, 200, {
|
|
89
|
+
ok: true,
|
|
90
|
+
cwd: cwd || "",
|
|
91
|
+
dbPath,
|
|
92
|
+
dbExists: dbPath ? existsSync(dbPath) : false,
|
|
93
|
+
nodeCount,
|
|
94
|
+
lastInjectTokens: status.lastInjectTokens || 0,
|
|
95
|
+
lastInjectAt: status.lastInjectAt || null,
|
|
96
|
+
lastExtract: status.lastExtract || null,
|
|
97
|
+
recallMode: mode,
|
|
98
|
+
autoRecall: mode === "auto",
|
|
99
|
+
anchorRecall: mode === "anchor",
|
|
100
|
+
extractEnabled: opts.extractEnabled !== false,
|
|
101
|
+
writeApproval: !!opts.writeApproval,
|
|
102
|
+
mapTokenBudget: opts.mapTokenBudget || 400,
|
|
103
|
+
workspaces: listOpenWorkspaces(),
|
|
104
|
+
...embeddingPublicStatus(),
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (path === "/api/dsh-trivium/settings" && method === "POST") {
|
|
110
|
+
const body = JSON.parse((await readBody(req)) || "{}");
|
|
111
|
+
const saved = writeUiSettings(body);
|
|
112
|
+
const opts = liveOptions();
|
|
113
|
+
sendJson(res, 200, {
|
|
114
|
+
ok: true,
|
|
115
|
+
...opts,
|
|
116
|
+
recallMode: recallModeOf(opts),
|
|
117
|
+
...embeddingPublicStatus(),
|
|
118
|
+
embeddingApiKey: undefined,
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (path === "/api/dsh-trivium/export" && method === "GET") {
|
|
124
|
+
const { cwd, db } = await dbForRequest(url);
|
|
125
|
+
if (!db) {
|
|
126
|
+
sendJson(res, 400, { ok: false, message: "no workspace" });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
sendJson(res, 200, { ok: true, cwd, graph: exportGraph(db, cwd) });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (path === "/api/dsh-trivium/import" && method === "POST") {
|
|
134
|
+
const { db } = await dbForRequest(url);
|
|
135
|
+
if (!db) {
|
|
136
|
+
sendJson(res, 400, { ok: false, message: "还没有打开工作区" });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const body = JSON.parse((await readBody(req)) || "{}");
|
|
140
|
+
const result = importGraph(db, body.graph || body);
|
|
141
|
+
sendJson(res, result.ok ? 200 : 400, result);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (path === "/api/dsh-trivium/embed-backfill" && method === "POST") {
|
|
146
|
+
const { db } = await dbForRequest(url);
|
|
147
|
+
if (!db) {
|
|
148
|
+
sendJson(res, 400, { ok: false, message: "no workspace" });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
liveOptions();
|
|
152
|
+
const result = await backfillEmbeddings(db);
|
|
153
|
+
sendJson(res, result.ok ? 200 : 400, result);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (path === "/api/dsh-trivium/nodes" && method === "GET") {
|
|
158
|
+
const { cwd, db } = await dbForRequest(url);
|
|
159
|
+
if (!db) {
|
|
160
|
+
sendJson(res, 200, { ok: true, cwd: "", nodes: [] });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const type = url.searchParams.get("type") || "";
|
|
164
|
+
const q = url.searchParams.get("q") || "";
|
|
165
|
+
const includeArchived = url.searchParams.get("archived") === "1";
|
|
166
|
+
const includeStale = url.searchParams.get("stale") === "1";
|
|
167
|
+
const about = url.searchParams.get("about") || "";
|
|
168
|
+
sendJson(res, 200, {
|
|
169
|
+
ok: true,
|
|
170
|
+
cwd,
|
|
171
|
+
nodes: listNodes(db, {
|
|
172
|
+
type: type || undefined,
|
|
173
|
+
q: q || undefined,
|
|
174
|
+
includeArchived,
|
|
175
|
+
includeStale,
|
|
176
|
+
aboutId: about || undefined,
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const mergeMatch = path.match(/^\/api\/dsh-trivium\/nodes\/(\d+)\/merge$/);
|
|
183
|
+
if (mergeMatch && method === "POST") {
|
|
184
|
+
const { db } = await dbForRequest(url);
|
|
185
|
+
if (!db) {
|
|
186
|
+
sendJson(res, 400, { ok: false, message: "no workspace" });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const body = JSON.parse((await readBody(req)) || "{}");
|
|
190
|
+
const result = mergeNodes(db, mergeMatch[1], body.dropId);
|
|
191
|
+
sendJson(res, result.ok ? 200 : 400, result);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const patchMatch = path.match(/^\/api\/dsh-trivium\/nodes\/(\d+)$/);
|
|
196
|
+
if (patchMatch && method === "POST") {
|
|
197
|
+
const { db } = await dbForRequest(url);
|
|
198
|
+
if (!db) {
|
|
199
|
+
sendJson(res, 400, { ok: false, message: "no workspace" });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const body = JSON.parse((await readBody(req)) || "{}");
|
|
203
|
+
const payload = updateNode(db, patchMatch[1], body);
|
|
204
|
+
sendJson(res, payload ? 200 : 404, { ok: Boolean(payload), id: Number(patchMatch[1]), payload });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const nodeMatch = path.match(/^\/api\/dsh-trivium\/nodes\/(\d+)\/(archive|delete)$/);
|
|
209
|
+
if (nodeMatch && method === "POST") {
|
|
210
|
+
const { db } = await dbForRequest(url);
|
|
211
|
+
if (!db) {
|
|
212
|
+
sendJson(res, 400, { ok: false, message: "no workspace" });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const id = Number(nodeMatch[1]);
|
|
216
|
+
const ok = nodeMatch[2] === "archive" ? archiveNode(db, id) : deleteNode(db, id);
|
|
217
|
+
sendJson(res, ok ? 200 : 404, { ok, id });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
sendJson(res, 404, { ok: false, message: "not found" });
|
|
222
|
+
}
|