dsh-trivium 0.2.0 → 0.4.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/README.md +122 -79
- package/lib/client.js +1443 -2
- package/lib/episode.js +278 -0
- package/lib/extract.js +39 -7
- package/lib/index.js +127 -39
- package/lib/markdown.js +359 -0
- package/lib/pins.js +155 -0
- package/lib/schema.js +5 -0
- package/lib/server.js +113 -0
- package/lib/settings.js +26 -0
- package/lib/store.js +36 -8
- package/package.json +8 -3
package/lib/markdown.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only Markdown projection + one-shot WorkBuddy MEMORY.md import.
|
|
3
|
+
* The Markdown file is not a write-back format.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { BUSINESS_EDGES, clip, looksSecret, nowIso } from "./schema.js";
|
|
9
|
+
import {
|
|
10
|
+
CHITCHAT_CUES,
|
|
11
|
+
DECISION_CUES,
|
|
12
|
+
MAX_LINE_CHARS,
|
|
13
|
+
MAX_WRITE_CHARS,
|
|
14
|
+
MAX_WRITE_ITEMS,
|
|
15
|
+
ONESHOT_CUES,
|
|
16
|
+
PREFERENCE_CUES,
|
|
17
|
+
applyCandidates,
|
|
18
|
+
firstEntityName,
|
|
19
|
+
} from "./extract.js";
|
|
20
|
+
import { parseUntilFromText } from "./until.js";
|
|
21
|
+
import { listOutgoingBusiness } from "./store.js";
|
|
22
|
+
|
|
23
|
+
export const EXPORT_MARKER = "dsh-trivium-export";
|
|
24
|
+
|
|
25
|
+
const WRITE_TYPES = ["preference", "decision", "entity", "experience"];
|
|
26
|
+
|
|
27
|
+
const HEADING_STOP = new Set([
|
|
28
|
+
"memory",
|
|
29
|
+
"memories",
|
|
30
|
+
"notes",
|
|
31
|
+
"note",
|
|
32
|
+
"user",
|
|
33
|
+
"soul",
|
|
34
|
+
"identity",
|
|
35
|
+
"about",
|
|
36
|
+
"overview",
|
|
37
|
+
"todo",
|
|
38
|
+
"log",
|
|
39
|
+
"logs",
|
|
40
|
+
"rules",
|
|
41
|
+
"记忆",
|
|
42
|
+
"笔记",
|
|
43
|
+
"用户",
|
|
44
|
+
"关于",
|
|
45
|
+
"日志",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/** Imperative / contract language — used only for curated-file import, not live extract. */
|
|
49
|
+
const RULE_CUES =
|
|
50
|
+
/必须|务必|禁止|永远不要|始终|prefer|\balways\b|\bnever\b|\bmust\b|\bdo not\b|\bdon't\b|mandatory|用.{1,24}不用|\buse\b.{1,24}\bnot\b/i;
|
|
51
|
+
|
|
52
|
+
const DATE_HEADING = /^\d{4}-\d{2}-\d{2}\b/;
|
|
53
|
+
const KV_LINE =
|
|
54
|
+
/^(`[^`]{2,64}`|[A-Z][A-Za-z0-9._-]{1,63}|[a-z][a-z0-9]+(?:-[a-z0-9]+)+)\s*[::]\s+(\S[\s\S]*)$/;
|
|
55
|
+
|
|
56
|
+
export function isOurMarkdownExport(text) {
|
|
57
|
+
return String(text || "").slice(0, 800).includes(EXPORT_MARKER);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function targetName(db, edge) {
|
|
61
|
+
const payload = db.get(edge.to)?.payload || {};
|
|
62
|
+
if (payload.type === "entity") return String(payload.name || payload.text || "").trim();
|
|
63
|
+
return String(payload.name || payload.text || edge.l0 || "").replace(/\s+/g, " ").trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function lineOf(db, payload, edges) {
|
|
67
|
+
const type = payload.type;
|
|
68
|
+
let body = String(payload.text || payload.name || "").replace(/\s+/g, " ").trim();
|
|
69
|
+
if (type === "entity") body = String(payload.name || payload.text || "").trim();
|
|
70
|
+
if (type === "decision" && payload.until && !/\buntil\b|周五|下周/i.test(body)) {
|
|
71
|
+
body = `${body} until ${payload.until}`;
|
|
72
|
+
}
|
|
73
|
+
body = clip(body, MAX_LINE_CHARS);
|
|
74
|
+
const bits = [];
|
|
75
|
+
for (const e of edges || []) {
|
|
76
|
+
if (!BUSINESS_EDGES.includes(e.label)) continue;
|
|
77
|
+
const short = clip(targetName(db, e), 48);
|
|
78
|
+
if (!short) continue;
|
|
79
|
+
bits.push(`—${e.label}→ ${short}`);
|
|
80
|
+
}
|
|
81
|
+
const suffix = bits.length ? ` ${bits.join(" ")}` : "";
|
|
82
|
+
return `- ${body}${suffix}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function exportMarkdown(db, { cwd = "" } = {}) {
|
|
86
|
+
const buckets = {
|
|
87
|
+
preference: [],
|
|
88
|
+
decision: [],
|
|
89
|
+
entity: [],
|
|
90
|
+
experience: [],
|
|
91
|
+
};
|
|
92
|
+
for (const id of db.allNodeIds?.() || []) {
|
|
93
|
+
const node = db.get(id);
|
|
94
|
+
if (!node) continue;
|
|
95
|
+
const payload = node.payload || {};
|
|
96
|
+
if (!WRITE_TYPES.includes(payload.type)) continue;
|
|
97
|
+
if (payload.status === "archived") continue;
|
|
98
|
+
const edges = listOutgoingBusiness(db, id);
|
|
99
|
+
const line = lineOf(db, payload, edges);
|
|
100
|
+
const extra = [];
|
|
101
|
+
if (payload.type === "entity" && Array.isArray(payload.aliases) && payload.aliases.length) {
|
|
102
|
+
extra.push(` aliases: ${payload.aliases.slice(0, 8).join(", ")}`);
|
|
103
|
+
}
|
|
104
|
+
if (payload.type === "experience") {
|
|
105
|
+
if (payload.fail) extra.push(` fail: ${clip(payload.fail, 160)}`);
|
|
106
|
+
if (payload.fix) extra.push(` fix: ${clip(payload.fix, 160)}`);
|
|
107
|
+
}
|
|
108
|
+
if (payload.type === "decision" && payload.untilAt) {
|
|
109
|
+
extra.push(` untilAt: ${payload.untilAt}`);
|
|
110
|
+
}
|
|
111
|
+
buckets[payload.type].push({ updatedAt: payload.updatedAt || "", line, extra });
|
|
112
|
+
}
|
|
113
|
+
for (const type of WRITE_TYPES) {
|
|
114
|
+
buckets[type].sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const lines = [
|
|
118
|
+
`<!-- ${EXPORT_MARKER} v1 -->`,
|
|
119
|
+
"# dsh-trivium MEMORY.md",
|
|
120
|
+
"",
|
|
121
|
+
"Read-only projection of the graph. Do not edit this file to write back — use Settings or JSON import.",
|
|
122
|
+
`Generated ${nowIso()}${cwd ? ` from \`${cwd}\`` : ""}.`,
|
|
123
|
+
`Each line clipped to ${MAX_LINE_CHARS} chars (WorkBuddy-style write discipline).`,
|
|
124
|
+
"",
|
|
125
|
+
];
|
|
126
|
+
for (const type of WRITE_TYPES) {
|
|
127
|
+
const rows = buckets[type];
|
|
128
|
+
if (!rows.length) continue;
|
|
129
|
+
lines.push(`## ${type}`, "");
|
|
130
|
+
for (const row of rows) {
|
|
131
|
+
lines.push(row.line);
|
|
132
|
+
lines.push(...row.extra);
|
|
133
|
+
}
|
|
134
|
+
lines.push("");
|
|
135
|
+
}
|
|
136
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
137
|
+
return `${lines.join("\n")}\n`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function headingEntity(title) {
|
|
141
|
+
const t = String(title || "")
|
|
142
|
+
.replace(/^#+\s*/, "")
|
|
143
|
+
.trim();
|
|
144
|
+
if (!t || DATE_HEADING.test(t)) return { skipSection: DATE_HEADING.test(t), name: undefined };
|
|
145
|
+
const key = t.toLowerCase().replace(/[::].*$/, "").trim();
|
|
146
|
+
if (HEADING_STOP.has(key)) return { skipSection: false, name: undefined };
|
|
147
|
+
const name = firstEntityName(t);
|
|
148
|
+
if (!name) return { skipSection: false, name: undefined };
|
|
149
|
+
if (t.length > 64) return { skipSection: false, name: undefined };
|
|
150
|
+
return { skipSection: false, name };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function kvParts(text) {
|
|
154
|
+
const m = String(text || "").match(KV_LINE);
|
|
155
|
+
if (!m) return null;
|
|
156
|
+
const name = String(m[1]).replace(/`/g, "").trim();
|
|
157
|
+
const rest = String(m[2] || "").trim();
|
|
158
|
+
if (!name || !rest) return null;
|
|
159
|
+
if (HEADING_STOP.has(name.toLowerCase())) return null;
|
|
160
|
+
const ident =
|
|
161
|
+
firstEntityName(name) === name ||
|
|
162
|
+
/^[A-Z][A-Za-z0-9]*[A-Z][A-Za-z0-9._-]*$/.test(name) ||
|
|
163
|
+
/^[A-Z]{2,12}$/.test(name) ||
|
|
164
|
+
/^[a-z][a-z0-9]+(?:-[a-z0-9]+)+$/.test(name);
|
|
165
|
+
if (!ident) return null;
|
|
166
|
+
return { name, rest };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function classifyBullet(text, sectionEntity) {
|
|
170
|
+
const body = String(text || "").replace(/\s+/g, " ").trim();
|
|
171
|
+
if (!body) return { skip: "empty" };
|
|
172
|
+
if (body.length < 8) return { skip: "too-short" };
|
|
173
|
+
if (body.length > 800) return { skip: "too-long" };
|
|
174
|
+
if (looksSecret(body)) return { skip: "secret" };
|
|
175
|
+
if (CHITCHAT_CUES.test(body)) return { skip: "chitchat" };
|
|
176
|
+
if (ONESHOT_CUES.test(body)) return { skip: "oneshot" };
|
|
177
|
+
if (/^\d{1,2}:\d{2}\b/.test(body) || /^#{1,6}\s/.test(body)) return { skip: "log-line" };
|
|
178
|
+
|
|
179
|
+
const kv = kvParts(body);
|
|
180
|
+
const name = kv?.name || firstEntityName(body) || sectionEntity;
|
|
181
|
+
const fact = kv ? `${kv.name}: ${clip(kv.rest, MAX_LINE_CHARS - kv.name.length - 2)}` : clip(body, MAX_LINE_CHARS);
|
|
182
|
+
|
|
183
|
+
if (DECISION_CUES.test(body)) {
|
|
184
|
+
return {
|
|
185
|
+
skip: null,
|
|
186
|
+
candidates: [
|
|
187
|
+
kv ? { type: "entity", name: kv.name, text: kv.name, via: "workbuddy-import" } : null,
|
|
188
|
+
{
|
|
189
|
+
type: "decision",
|
|
190
|
+
name,
|
|
191
|
+
text: fact,
|
|
192
|
+
until: parseUntilFromText(body),
|
|
193
|
+
linkName: name,
|
|
194
|
+
linkLabel: "decided",
|
|
195
|
+
quote: clip(body, 120),
|
|
196
|
+
via: "workbuddy-import",
|
|
197
|
+
},
|
|
198
|
+
].filter(Boolean),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const ruleLike = PREFERENCE_CUES.test(body) || RULE_CUES.test(body) || Boolean(kv);
|
|
203
|
+
if (ruleLike) {
|
|
204
|
+
const out = [];
|
|
205
|
+
if (kv) out.push({ type: "entity", name: kv.name, text: kv.name, via: "workbuddy-import" });
|
|
206
|
+
else if (sectionEntity && name === sectionEntity) {
|
|
207
|
+
out.push({ type: "entity", name: sectionEntity, text: sectionEntity, via: "workbuddy-import" });
|
|
208
|
+
}
|
|
209
|
+
out.push({
|
|
210
|
+
type: "preference",
|
|
211
|
+
name,
|
|
212
|
+
text: fact,
|
|
213
|
+
linkName: name,
|
|
214
|
+
linkLabel: "about",
|
|
215
|
+
quote: clip(body, 120),
|
|
216
|
+
via: "workbuddy-import",
|
|
217
|
+
});
|
|
218
|
+
return { skip: null, candidates: out };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { skip: "no-cue" };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function parseWorkbuddyMarkdown(text, { sourcePath = "" } = {}) {
|
|
225
|
+
const raw = String(text || "");
|
|
226
|
+
if (!raw.trim()) {
|
|
227
|
+
return { ok: false, message: "空文件", candidates: [], skipped: 0, sourcePath };
|
|
228
|
+
}
|
|
229
|
+
if (isOurMarkdownExport(raw)) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
message: "这是本插件导出的 MEMORY.md,不能当 WorkBuddy 文件回写。请用 JSON 导入或 Settings。",
|
|
233
|
+
candidates: [],
|
|
234
|
+
skipped: 0,
|
|
235
|
+
sourcePath,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const candidates = [];
|
|
240
|
+
let skipped = 0;
|
|
241
|
+
let sectionEntity;
|
|
242
|
+
let skipSection = false;
|
|
243
|
+
let inFence = false;
|
|
244
|
+
|
|
245
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
246
|
+
const line = rawLine.trim();
|
|
247
|
+
if (/^```/.test(line)) {
|
|
248
|
+
inFence = !inFence;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (inFence) continue;
|
|
252
|
+
if (!line) continue;
|
|
253
|
+
if (/^#{1,6}\s+/.test(line)) {
|
|
254
|
+
const heading = headingEntity(line);
|
|
255
|
+
skipSection = heading.skipSection;
|
|
256
|
+
sectionEntity = heading.skipSection ? undefined : heading.name;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (skipSection) {
|
|
260
|
+
skipped += 1;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const bullet = line.match(/^[-*+]\s+(?:\[[ xX]\]\s+)?(.+)$/);
|
|
264
|
+
if (!bullet) {
|
|
265
|
+
skipped += 1;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const result = classifyBullet(bullet[1], sectionEntity);
|
|
269
|
+
if (result.skip) {
|
|
270
|
+
skipped += 1;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
candidates.push(...result.candidates);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { ok: true, candidates, skipped, sourcePath };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function discoverWorkbuddyFiles(cwd = "") {
|
|
280
|
+
const home = homedir();
|
|
281
|
+
const rows = [
|
|
282
|
+
{ id: "user", path: join(home, ".workbuddy", "MEMORY.md") },
|
|
283
|
+
{ id: "workspace", path: cwd ? join(cwd, ".workbuddy", "memory", "MEMORY.md") : "" },
|
|
284
|
+
{ id: "workspaceRoot", path: cwd ? join(cwd, ".workbuddy", "MEMORY.md") : "" },
|
|
285
|
+
];
|
|
286
|
+
return rows
|
|
287
|
+
.filter((row) => row.path)
|
|
288
|
+
.map((row) => {
|
|
289
|
+
const exists = existsSync(row.path);
|
|
290
|
+
let bytes = 0;
|
|
291
|
+
if (exists) {
|
|
292
|
+
try {
|
|
293
|
+
bytes = statSync(row.path).size;
|
|
294
|
+
} catch {
|
|
295
|
+
bytes = 0;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return { ...row, exists, bytes };
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function readIfExists(path) {
|
|
303
|
+
try {
|
|
304
|
+
return readFileSync(path, "utf8");
|
|
305
|
+
} catch {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* One-shot import of discovered WorkBuddy MEMORY.md files. No watch, no reverse sync.
|
|
312
|
+
*/
|
|
313
|
+
export function importWorkbuddy(db, { cwd = "", ids } = {}) {
|
|
314
|
+
const found = discoverWorkbuddyFiles(cwd);
|
|
315
|
+
const want = Array.isArray(ids) && ids.length ? new Set(ids) : null;
|
|
316
|
+
const files = found.filter((f) => f.exists && (!want || want.has(f.id)));
|
|
317
|
+
if (!files.length) {
|
|
318
|
+
return {
|
|
319
|
+
ok: false,
|
|
320
|
+
message: "没有找到 ~/.workbuddy/MEMORY.md 或工作区 .workbuddy/memory/MEMORY.md",
|
|
321
|
+
created: 0,
|
|
322
|
+
merged: 0,
|
|
323
|
+
skipped: 0,
|
|
324
|
+
files: found,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const candidates = [];
|
|
329
|
+
let skipped = 0;
|
|
330
|
+
const used = [];
|
|
331
|
+
for (const file of files) {
|
|
332
|
+
const text = readIfExists(file.path);
|
|
333
|
+
if (text == null) continue;
|
|
334
|
+
const parsed = parseWorkbuddyMarkdown(text, { sourcePath: file.path });
|
|
335
|
+
if (!parsed.ok) {
|
|
336
|
+
return { ...parsed, created: 0, merged: 0, files: found };
|
|
337
|
+
}
|
|
338
|
+
skipped += parsed.skipped;
|
|
339
|
+
candidates.push(...parsed.candidates);
|
|
340
|
+
used.push({ id: file.id, path: file.path, bytes: file.bytes, parsed: parsed.candidates.length });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const applied = applyCandidates(db, candidates, { via: "workbuddy-import" });
|
|
344
|
+
const created = applied.filter((a) => a.action === "insert").length;
|
|
345
|
+
const merged = applied.filter((a) => a.action === "merge").length;
|
|
346
|
+
const dropped = Math.max(0, candidates.length - applied.length);
|
|
347
|
+
return {
|
|
348
|
+
ok: true,
|
|
349
|
+
created,
|
|
350
|
+
merged,
|
|
351
|
+
linked: applied.filter((a) => a.type !== "entity").length,
|
|
352
|
+
skipped: skipped + dropped,
|
|
353
|
+
applied: applied.length,
|
|
354
|
+
maxWriteChars: MAX_WRITE_CHARS,
|
|
355
|
+
maxWriteItems: MAX_WRITE_ITEMS,
|
|
356
|
+
files: found,
|
|
357
|
+
used,
|
|
358
|
+
};
|
|
359
|
+
}
|
package/lib/pins.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** Per-session memory chips. Pins inject into the next turn of the current session. */
|
|
2
|
+
|
|
3
|
+
import { listEpisodeRecords } from "./episode.js";
|
|
4
|
+
import { clipToTokenBudget, estimateTokens } from "./tokens.js";
|
|
5
|
+
import { pinsOf, writePins } from "./settings.js";
|
|
6
|
+
import { suggestedNeighborIds } from "./store.js";
|
|
7
|
+
|
|
8
|
+
export const PIN_TOKEN_BUDGET = 300;
|
|
9
|
+
export const CHIP_TYPES = Object.freeze(["preference", "decision", "entity"]);
|
|
10
|
+
|
|
11
|
+
const CHIP_SET = new Set(CHIP_TYPES);
|
|
12
|
+
|
|
13
|
+
function l0Of(payload) {
|
|
14
|
+
return String(payload?.text || payload?.name || "")
|
|
15
|
+
.replace(/\s+/g, " ")
|
|
16
|
+
.trim()
|
|
17
|
+
.slice(0, 200);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function chipMatches(row, q) {
|
|
21
|
+
const needle = String(q || "").trim().toLowerCase();
|
|
22
|
+
if (!needle) return true;
|
|
23
|
+
return [row.name, row.text, row.type, row.l0].some((s) => String(s || "").toLowerCase().includes(needle));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function episodeSuggestText(db, sessionId) {
|
|
27
|
+
const rows = listEpisodeRecords(db, sessionId);
|
|
28
|
+
const tail = rows.find((e) => e.kind === "tail");
|
|
29
|
+
const lastCheck = [...rows].reverse().find((e) => e.kind === "checkpoint");
|
|
30
|
+
const tailText = String(tail?.summary || tail?.text || "").trim();
|
|
31
|
+
if (tailText && tailText !== "当前" && tailText !== "分叉") return tailText;
|
|
32
|
+
return String(lastCheck?.summary || lastCheck?.text || tailText || "").trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function suggestedChipIdSet(db, sessionId) {
|
|
36
|
+
const sid = String(sessionId || "");
|
|
37
|
+
if (!sid) return new Set();
|
|
38
|
+
const text = episodeSuggestText(db, sid);
|
|
39
|
+
if (!text) return new Set();
|
|
40
|
+
const ids = new Set();
|
|
41
|
+
for (const nid of suggestedNeighborIds(db, text)) {
|
|
42
|
+
const payload = db.get(Number(nid))?.payload || {};
|
|
43
|
+
if (payload.status === "archived") continue;
|
|
44
|
+
if (!CHIP_SET.has(payload.type)) continue;
|
|
45
|
+
ids.add(Number(nid));
|
|
46
|
+
}
|
|
47
|
+
return ids;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parentSessionOf(db, sessionId) {
|
|
51
|
+
const rows = listEpisodeRecords(db, sessionId);
|
|
52
|
+
const tail = rows.find((e) => e.kind === "tail");
|
|
53
|
+
for (const e of tail?.edges || []) {
|
|
54
|
+
if (e.label !== "forks_from") continue;
|
|
55
|
+
const payload = db.get(Number(e.targetId ?? e.target_id))?.payload;
|
|
56
|
+
if (payload?.sessionId) return String(payload.sessionId);
|
|
57
|
+
}
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function listChips(db, { q, sessionId } = {}) {
|
|
62
|
+
const suggested = suggestedChipIdSet(db, sessionId);
|
|
63
|
+
const rows = [];
|
|
64
|
+
for (const id of db.allNodeIds?.() || []) {
|
|
65
|
+
const payload = db.get(id)?.payload || {};
|
|
66
|
+
if (payload.status === "archived") continue;
|
|
67
|
+
if (!CHIP_SET.has(payload.type)) continue;
|
|
68
|
+
const row = {
|
|
69
|
+
id: Number(id),
|
|
70
|
+
type: payload.type,
|
|
71
|
+
name: payload.name || "",
|
|
72
|
+
text: payload.text || "",
|
|
73
|
+
l0: l0Of(payload),
|
|
74
|
+
updatedAt: payload.updatedAt || payload.createdAt || "",
|
|
75
|
+
suggested: suggested.has(Number(id)),
|
|
76
|
+
};
|
|
77
|
+
if (!chipMatches(row, q)) continue;
|
|
78
|
+
rows.push(row);
|
|
79
|
+
}
|
|
80
|
+
return rows.sort((a, b) => {
|
|
81
|
+
if (a.suggested !== b.suggested) return a.suggested ? -1 : 1;
|
|
82
|
+
return String(b.updatedAt).localeCompare(String(a.updatedAt));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function livePinIds(db, sessionId) {
|
|
87
|
+
const ids = pinsOf(sessionId);
|
|
88
|
+
return ids.filter((id) => {
|
|
89
|
+
const payload = db.get(Number(id))?.payload;
|
|
90
|
+
return payload && payload.status !== "archived" && CHIP_SET.has(payload.type);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function prunePins(db, sessionId) {
|
|
95
|
+
const ids = pinsOf(sessionId);
|
|
96
|
+
const kept = livePinIds(db, sessionId);
|
|
97
|
+
if (kept.length !== ids.length) writePins(sessionId, kept);
|
|
98
|
+
return kept;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function buildPinInject(db, sessionId, budget = PIN_TOKEN_BUDGET) {
|
|
102
|
+
const ids = prunePins(db, sessionId);
|
|
103
|
+
if (!ids.length) return { text: "", tokens: 0, clipped: false, ids: [] };
|
|
104
|
+
const lines = ["dsh-trivium pins (L0 only; use ctx_read for full text):"];
|
|
105
|
+
const used = [];
|
|
106
|
+
for (const id of ids) {
|
|
107
|
+
const payload = db.get(Number(id))?.payload;
|
|
108
|
+
if (!payload) continue;
|
|
109
|
+
lines.push(`- [${payload.type}#${id}] ${l0Of(payload)}`);
|
|
110
|
+
used.push(Number(id));
|
|
111
|
+
}
|
|
112
|
+
const full = lines.join("\n");
|
|
113
|
+
const cap = Math.max(1, Number(budget) || PIN_TOKEN_BUDGET);
|
|
114
|
+
const text = clipToTokenBudget(full, cap);
|
|
115
|
+
return {
|
|
116
|
+
text,
|
|
117
|
+
tokens: estimateTokens(text),
|
|
118
|
+
clipped: estimateTokens(full) > cap,
|
|
119
|
+
ids: used,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function setSessionPins(db, sessionId, ids) {
|
|
124
|
+
writePins(sessionId, ids);
|
|
125
|
+
return buildPinInject(db, sessionId);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function readSessionPins(db, sessionId) {
|
|
129
|
+
return buildPinInject(db, sessionId);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Copy live (unarchived chip-type) pin ids. Child stays empty unless this is called. */
|
|
133
|
+
export function copyPins(db, fromSessionId, toSessionId) {
|
|
134
|
+
const ids = livePinIds(db, fromSessionId);
|
|
135
|
+
writePins(toSessionId, ids);
|
|
136
|
+
return ids;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* POST /pins body. inherit defaults false — `{ sessionId, ids }` still replaces.
|
|
141
|
+
* inherit true copies live ids from inheritFrom (or graph parent), then merges ids if given.
|
|
142
|
+
*/
|
|
143
|
+
export function applyPinsUpdate(db, body = {}) {
|
|
144
|
+
const sessionId = String(body.sessionId || "");
|
|
145
|
+
if (body.inherit === true) {
|
|
146
|
+
const from = String(body.inheritFrom || body.parentSessionId || parentSessionOf(db, sessionId) || "");
|
|
147
|
+
const copied = from && from !== sessionId ? copyPins(db, from, sessionId) : livePinIds(db, sessionId);
|
|
148
|
+
if (Array.isArray(body.ids)) {
|
|
149
|
+
const merged = [...new Set([...copied, ...body.ids.map(Number).filter((id) => Number.isFinite(id))])];
|
|
150
|
+
return setSessionPins(db, sessionId, merged);
|
|
151
|
+
}
|
|
152
|
+
return readSessionPins(db, sessionId);
|
|
153
|
+
}
|
|
154
|
+
return setSessionPins(db, sessionId, Array.isArray(body.ids) ? body.ids : []);
|
|
155
|
+
}
|
package/lib/schema.js
CHANGED
|
@@ -8,6 +8,7 @@ export const NODE_TYPES = Object.freeze([
|
|
|
8
8
|
"preference",
|
|
9
9
|
"decision",
|
|
10
10
|
"experience",
|
|
11
|
+
"episode",
|
|
11
12
|
]);
|
|
12
13
|
|
|
13
14
|
export const EDGE_LABELS = Object.freeze({
|
|
@@ -18,6 +19,8 @@ export const EDGE_LABELS = Object.freeze({
|
|
|
18
19
|
fixed: "fixed",
|
|
19
20
|
sameAs: "same_as",
|
|
20
21
|
fromSession: "from_session",
|
|
22
|
+
continues: "continues",
|
|
23
|
+
forksFrom: "forks_from",
|
|
21
24
|
});
|
|
22
25
|
|
|
23
26
|
export const BUSINESS_EDGES = Object.freeze(["about", "decided", "broke", "fixed"]);
|
|
@@ -31,6 +34,8 @@ export const EDGE_WEIGHTS = Object.freeze({
|
|
|
31
34
|
in_workspace: 0.15,
|
|
32
35
|
same_as: 0.5,
|
|
33
36
|
from_session: 0.1,
|
|
37
|
+
continues: 0.4,
|
|
38
|
+
forks_from: 0.6,
|
|
34
39
|
});
|
|
35
40
|
|
|
36
41
|
export function weightOf(label) {
|
package/lib/server.js
CHANGED
|
@@ -16,6 +16,9 @@ import {
|
|
|
16
16
|
} from "./store.js";
|
|
17
17
|
import { embeddingPublicStatus } from "./embed.js";
|
|
18
18
|
import { recallModeOf, writeUiSettings } from "./settings.js";
|
|
19
|
+
import { discoverWorkbuddyFiles, exportMarkdown, importWorkbuddy } from "./markdown.js";
|
|
20
|
+
import { renameEpisode, sessionMapSnapshot } from "./episode.js";
|
|
21
|
+
import { applyPinsUpdate, listChips, readSessionPins } from "./pins.js";
|
|
19
22
|
|
|
20
23
|
export { readUiSettings, writeUiSettings } from "./settings.js";
|
|
21
24
|
|
|
@@ -24,6 +27,16 @@ function isLoopback(req) {
|
|
|
24
27
|
return ra === "127.0.0.1" || ra === "::1" || ra === "::ffff:127.0.0.1";
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
function sendText(res, status, body, contentType) {
|
|
31
|
+
const data = Buffer.from(String(body || ""), "utf8");
|
|
32
|
+
res.writeHead(status, {
|
|
33
|
+
"content-type": contentType || "text/plain; charset=utf-8",
|
|
34
|
+
"cache-control": "no-store",
|
|
35
|
+
"content-length": String(data.length),
|
|
36
|
+
});
|
|
37
|
+
res.end(data);
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
function sendJson(res, status, body) {
|
|
28
41
|
const data = Buffer.from(JSON.stringify(body), "utf8");
|
|
29
42
|
res.writeHead(status, {
|
|
@@ -130,6 +143,35 @@ async function handle(req, res, { liveOptions, status }) {
|
|
|
130
143
|
return;
|
|
131
144
|
}
|
|
132
145
|
|
|
146
|
+
if (path === "/api/dsh-trivium/export.md" && method === "GET") {
|
|
147
|
+
const { cwd, db } = await dbForRequest(url);
|
|
148
|
+
if (!db) {
|
|
149
|
+
sendJson(res, 400, { ok: false, message: "no workspace" });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
sendText(res, 200, exportMarkdown(db, { cwd }), "text/markdown; charset=utf-8");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (path === "/api/dsh-trivium/workbuddy" && method === "GET") {
|
|
157
|
+
const cwd = url.searchParams.get("cwd") || getActiveCwd() || "";
|
|
158
|
+
sendJson(res, 200, { ok: true, cwd, files: discoverWorkbuddyFiles(cwd) });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (path === "/api/dsh-trivium/import-workbuddy" && method === "POST") {
|
|
163
|
+
const { cwd, db } = await dbForRequest(url);
|
|
164
|
+
if (!db) {
|
|
165
|
+
sendJson(res, 400, { ok: false, message: "还没有打开工作区" });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const body = JSON.parse((await readBody(req)) || "{}");
|
|
169
|
+
const ids = Array.isArray(body.ids) ? body.ids.filter((id) => typeof id === "string") : undefined;
|
|
170
|
+
const result = importWorkbuddy(db, { cwd, ids });
|
|
171
|
+
sendJson(res, result.ok ? 200 : 400, result);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
133
175
|
if (path === "/api/dsh-trivium/import" && method === "POST") {
|
|
134
176
|
const { db } = await dbForRequest(url);
|
|
135
177
|
if (!db) {
|
|
@@ -218,5 +260,76 @@ async function handle(req, res, { liveOptions, status }) {
|
|
|
218
260
|
return;
|
|
219
261
|
}
|
|
220
262
|
|
|
263
|
+
if (path === "/api/dsh-trivium/map" && method === "GET") {
|
|
264
|
+
const { cwd, db } = await dbForRequest(url);
|
|
265
|
+
const sessionId = url.searchParams.get("sessionId") || "";
|
|
266
|
+
if (!db) {
|
|
267
|
+
sendJson(res, 200, { ok: true, cwd: cwd || "", sessionId, nodes: [], edges: [] });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const snap = sessionMapSnapshot(db, sessionId);
|
|
271
|
+
sendJson(res, 200, { ok: true, cwd, ...snap });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (path === "/api/dsh-trivium/chips" && method === "GET") {
|
|
276
|
+
const { cwd, db } = await dbForRequest(url);
|
|
277
|
+
const q = url.searchParams.get("q") || "";
|
|
278
|
+
const sessionId = url.searchParams.get("sessionId") || "";
|
|
279
|
+
if (!db) {
|
|
280
|
+
sendJson(res, 200, { ok: true, cwd: cwd || "", sessionId, q, chips: [] });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
sendJson(res, 200, { ok: true, cwd, sessionId, q, chips: listChips(db, { q, sessionId }) });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (path === "/api/dsh-trivium/pins" && method === "GET") {
|
|
288
|
+
const { cwd, db } = await dbForRequest(url);
|
|
289
|
+
const sessionId = url.searchParams.get("sessionId") || "";
|
|
290
|
+
if (!db) {
|
|
291
|
+
sendJson(res, 200, { ok: true, cwd: cwd || "", sessionId, ids: [], clipped: false, tokens: 0 });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const pin = readSessionPins(db, sessionId);
|
|
295
|
+
sendJson(res, 200, { ok: true, cwd, sessionId, ids: pin.ids, clipped: pin.clipped, tokens: pin.tokens });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (path === "/api/dsh-trivium/pins" && method === "POST") {
|
|
300
|
+
const { cwd, db } = await dbForRequest(url);
|
|
301
|
+
if (!db) {
|
|
302
|
+
sendJson(res, 400, { ok: false, message: "no workspace" });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const body = JSON.parse((await readBody(req)) || "{}");
|
|
306
|
+
const sessionId = body.sessionId || url.searchParams.get("sessionId") || "";
|
|
307
|
+
const pin = applyPinsUpdate(db, { ...body, sessionId });
|
|
308
|
+
sendJson(res, 200, { ok: true, cwd, sessionId, ids: pin.ids, clipped: pin.clipped, tokens: pin.tokens });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const episodeMatch = path.match(/^\/api\/dsh-trivium\/episodes\/(\d+)$/);
|
|
313
|
+
if (episodeMatch && method === "POST") {
|
|
314
|
+
const { cwd, db } = await dbForRequest(url);
|
|
315
|
+
if (!db) {
|
|
316
|
+
sendJson(res, 400, { ok: false, message: "no workspace" });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const body = JSON.parse((await readBody(req)) || "{}");
|
|
320
|
+
const result = renameEpisode(db, episodeMatch[1], body.name);
|
|
321
|
+
sendJson(res, result.ok ? 200 : result.status || 400, {
|
|
322
|
+
ok: result.ok,
|
|
323
|
+
cwd,
|
|
324
|
+
id: Number(episodeMatch[1]),
|
|
325
|
+
name: result.name || "",
|
|
326
|
+
summary: result.summary || "",
|
|
327
|
+
message: result.message,
|
|
328
|
+
});
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
221
332
|
sendJson(res, 404, { ok: false, message: "not found" });
|
|
222
333
|
}
|
|
334
|
+
|
|
335
|
+
export { handle as handleMemoryApi };
|