dsh-trivium 0.4.10 → 0.4.13
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 +70 -34
- package/README.zh-CN.md +71 -37
- package/lib/client.js +374 -45
- package/lib/index.js +9 -0
- package/lib/purge.js +7 -1
- package/lib/server.js +29 -1
- package/lib/settings.js +49 -0
- package/lib/sidecar.js +422 -0
- package/lib/store.js +63 -8
- package/package.json +2 -1
package/lib/server.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import {
|
|
3
|
+
generateSidecarFromDb,
|
|
3
4
|
archiveNode,
|
|
4
5
|
backfillEmbeddings,
|
|
5
6
|
dbPathFor,
|
|
7
|
+
deleteGitSidecars,
|
|
6
8
|
deleteNode,
|
|
7
9
|
exportGraph,
|
|
8
10
|
getActiveCwd,
|
|
@@ -15,7 +17,7 @@ import {
|
|
|
15
17
|
updateNode,
|
|
16
18
|
} from "./store.js";
|
|
17
19
|
import { embeddingPublicStatus } from "./embed.js";
|
|
18
|
-
import { chipsEnabledOf, recallModeOf, sessionLayerEnabledOf, writeUiSettings } from "./settings.js";
|
|
20
|
+
import { chipsEnabledOf, configPanelOpenOf, gitSidecarEnabledOf, pluginLocaleOf, recallModeOf, sessionLayerEnabledOf, writeUiSettings } from "./settings.js";
|
|
19
21
|
import { discoverWorkbuddyFiles, discoverExternalFiles, exportMarkdown, importWorkbuddy, importExternal } from "./markdown.js";
|
|
20
22
|
import { checkNpmUpdate, installedVersion } from "./update.js";
|
|
21
23
|
import { writeGateMessage } from "./hygiene.js";
|
|
@@ -116,6 +118,9 @@ async function handle(req, res, { liveOptions, status }) {
|
|
|
116
118
|
extractEnabled: opts.extractEnabled !== false,
|
|
117
119
|
chipsEnabled: chipsEnabledOf(opts),
|
|
118
120
|
sessionLayerEnabled: opts.sessionLayerEnabled === true,
|
|
121
|
+
gitSidecarEnabled: gitSidecarEnabledOf(opts),
|
|
122
|
+
pluginLocale: pluginLocaleOf(opts),
|
|
123
|
+
configPanelOpen: configPanelOpenOf(opts),
|
|
119
124
|
writeApproval: !!opts.writeApproval,
|
|
120
125
|
mapTokenBudget: opts.mapTokenBudget || 400,
|
|
121
126
|
workspaces: listOpenWorkspaces(),
|
|
@@ -138,6 +143,29 @@ async function handle(req, res, { liveOptions, status }) {
|
|
|
138
143
|
return;
|
|
139
144
|
}
|
|
140
145
|
|
|
146
|
+
if (path === "/api/dsh-trivium/sidecar/generate" && method === "POST") {
|
|
147
|
+
if (!gitSidecarEnabledOf(liveOptions())) {
|
|
148
|
+
sendJson(res, 400, { ok: false, message: "git sidecar off" });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const { cwd, db } = await dbForRequest(url);
|
|
152
|
+
if (!db) {
|
|
153
|
+
sendJson(res, 400, { ok: false, message: "no workspace" });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
sendJson(res, 200, generateSidecarFromDb(cwd, db));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (path === "/api/dsh-trivium/sidecar/delete" && method === "POST") {
|
|
161
|
+
if (gitSidecarEnabledOf(liveOptions())) {
|
|
162
|
+
sendJson(res, 400, { ok: false, message: "turn sidecar off first" });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
sendJson(res, 200, deleteGitSidecars());
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
141
169
|
if (path === "/api/dsh-trivium/export" && method === "GET") {
|
|
142
170
|
const { cwd, db } = await dbForRequest(url);
|
|
143
171
|
if (!db) {
|
package/lib/settings.js
CHANGED
|
@@ -82,6 +82,44 @@ export function sessionLayerEnabledOf(opts = {}) {
|
|
|
82
82
|
return chipsEnabledOf(opts) && opts.sessionLayerEnabled === true;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/** Git JSONL sidecar write. Missing key = off. Import still runs when this is off. */
|
|
86
|
+
export function gitSidecarEnabledOf(opts = {}) {
|
|
87
|
+
return opts.gitSidecarEnabled === true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Plugin UI language. Missing key = follow the DSH host. */
|
|
91
|
+
export function pluginLocaleOf(opts = {}) {
|
|
92
|
+
const v = String(opts.pluginLocale || "").toLowerCase();
|
|
93
|
+
if (v === "zh" || v === "en" || v === "host") return v;
|
|
94
|
+
return "host";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Settings fold. Missing key = collapsed. */
|
|
98
|
+
export function configPanelOpenOf(opts = {}) {
|
|
99
|
+
return opts.configPanelOpen === true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function sidecarHashOf(cwd) {
|
|
103
|
+
const raw = readUiSettings().sidecarHashByCwd;
|
|
104
|
+
if (!raw || typeof raw !== "object") return "";
|
|
105
|
+
const key = resolve(String(cwd || ""));
|
|
106
|
+
return String(raw[key] || "");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function writeSidecarHash(cwd, hash) {
|
|
110
|
+
const key = resolve(String(cwd || ""));
|
|
111
|
+
if (!process.env.DSH_TRIVIUM_SETTINGS && isInsideDir(tmpdir(), key)) {
|
|
112
|
+
return readUiSettings();
|
|
113
|
+
}
|
|
114
|
+
const prev = readUiSettings();
|
|
115
|
+
const sidecarHashByCwd = {
|
|
116
|
+
...(prev.sidecarHashByCwd && typeof prev.sidecarHashByCwd === "object" ? prev.sidecarHashByCwd : {}),
|
|
117
|
+
};
|
|
118
|
+
if (!hash) delete sidecarHashByCwd[key];
|
|
119
|
+
else sidecarHashByCwd[key] = String(hash);
|
|
120
|
+
return persistSettings({ ...prev, sidecarHashByCwd });
|
|
121
|
+
}
|
|
122
|
+
|
|
85
123
|
export function applyRecallMode(target, mode) {
|
|
86
124
|
const next = mode === "auto" || mode === "anchor" ? mode : "off";
|
|
87
125
|
target.recallMode = next;
|
|
@@ -110,6 +148,12 @@ export function writeUiSettings(patch) {
|
|
|
110
148
|
if (typeof patch.extractEnabled === "boolean") next.extractEnabled = patch.extractEnabled;
|
|
111
149
|
if (typeof patch.chipsEnabled === "boolean") next.chipsEnabled = patch.chipsEnabled;
|
|
112
150
|
if (typeof patch.sessionLayerEnabled === "boolean") next.sessionLayerEnabled = patch.sessionLayerEnabled;
|
|
151
|
+
if (typeof patch.gitSidecarEnabled === "boolean") next.gitSidecarEnabled = patch.gitSidecarEnabled;
|
|
152
|
+
if (typeof patch.configPanelOpen === "boolean") next.configPanelOpen = patch.configPanelOpen;
|
|
153
|
+
if (typeof patch.pluginLocale === "string") {
|
|
154
|
+
const loc = String(patch.pluginLocale || "").toLowerCase();
|
|
155
|
+
next.pluginLocale = loc === "zh" || loc === "en" ? loc : "host";
|
|
156
|
+
}
|
|
113
157
|
if (typeof patch.embeddingEnabled === "boolean") next.embeddingEnabled = patch.embeddingEnabled;
|
|
114
158
|
if (typeof patch.embeddingUrl === "string") {
|
|
115
159
|
const url = patch.embeddingUrl.trim();
|
|
@@ -126,6 +170,11 @@ export function writeUiSettings(patch) {
|
|
|
126
170
|
}
|
|
127
171
|
if (Array.isArray(patch.workspaces)) next.workspaces = patch.workspaces;
|
|
128
172
|
else if (Array.isArray(prev.workspaces)) next.workspaces = prev.workspaces;
|
|
173
|
+
if (patch.sidecarHashByCwd && typeof patch.sidecarHashByCwd === "object") {
|
|
174
|
+
next.sidecarHashByCwd = patch.sidecarHashByCwd;
|
|
175
|
+
} else if (prev.sidecarHashByCwd && typeof prev.sidecarHashByCwd === "object") {
|
|
176
|
+
next.sidecarHashByCwd = prev.sidecarHashByCwd;
|
|
177
|
+
}
|
|
129
178
|
return persistSettings(next);
|
|
130
179
|
}
|
|
131
180
|
|
package/lib/sidecar.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/** Git-friendly JSONL sidecar next to trivium.tdb. No embeddings, episodes, or chip pins. */
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { BUSINESS_EDGES } from "./schema.js";
|
|
7
|
+
import {
|
|
8
|
+
gitSidecarEnabledOf,
|
|
9
|
+
listedWorkspaces,
|
|
10
|
+
readUiSettings,
|
|
11
|
+
sidecarHashOf,
|
|
12
|
+
writeSidecarHash,
|
|
13
|
+
} from "./settings.js";
|
|
14
|
+
|
|
15
|
+
export const SIDECAR_FORMAT = "dsh-trivium-jsonl";
|
|
16
|
+
export const SIDECAR_VERSION = 1;
|
|
17
|
+
export const SIDECAR_DEBOUNCE_MS = 1500;
|
|
18
|
+
export const BUSINESS_NODE_TYPES = Object.freeze(["entity", "preference", "decision", "experience"]);
|
|
19
|
+
|
|
20
|
+
const timers = new Map();
|
|
21
|
+
const memoryHash = new Map();
|
|
22
|
+
|
|
23
|
+
export function sidecarPath(cwd) {
|
|
24
|
+
return join(resolve(cwd || ""), ".dsh", "trivium.jsonl");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function hashText(text) {
|
|
28
|
+
return createHash("sha256").update(String(text || ""), "utf8").digest("hex");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isBusinessType(type) {
|
|
32
|
+
return BUSINESS_NODE_TYPES.includes(String(type || ""));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function payloadOf(db, id) {
|
|
36
|
+
return db.get(Number(id))?.payload || {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function edgeTarget(e) {
|
|
40
|
+
return Number(e?.targetId ?? e?.target_id);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mintUri(payload) {
|
|
44
|
+
const h = createHash("sha256")
|
|
45
|
+
.update(
|
|
46
|
+
[payload.type || "", payload.name || "", payload.text || "", payload.createdAt || ""].join("\0"),
|
|
47
|
+
"utf8",
|
|
48
|
+
)
|
|
49
|
+
.digest("hex")
|
|
50
|
+
.slice(0, 16);
|
|
51
|
+
return `ctx://${payload.type}/${h}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function nodeRecord(payload) {
|
|
55
|
+
const rec = {
|
|
56
|
+
op: "node",
|
|
57
|
+
uri: payload.uri,
|
|
58
|
+
type: payload.type,
|
|
59
|
+
name: payload.name || "",
|
|
60
|
+
text: payload.text || "",
|
|
61
|
+
status: payload.status || "active",
|
|
62
|
+
};
|
|
63
|
+
const aliases = Array.isArray(payload.aliases)
|
|
64
|
+
? payload.aliases.map((a) => String(a || "").trim()).filter(Boolean)
|
|
65
|
+
: [];
|
|
66
|
+
if (aliases.length) rec.aliases = aliases;
|
|
67
|
+
if (payload.until) rec.until = payload.until;
|
|
68
|
+
if (payload.untilAt) rec.untilAt = payload.untilAt;
|
|
69
|
+
if (payload.fail) rec.fail = payload.fail;
|
|
70
|
+
if (payload.fix) rec.fix = payload.fix;
|
|
71
|
+
if (payload.createdAt) rec.createdAt = payload.createdAt;
|
|
72
|
+
if (payload.updatedAt) rec.updatedAt = payload.updatedAt;
|
|
73
|
+
return rec;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Assign stable uris on business nodes that still lack one. Flushes the db; does not schedule a sidecar write. */
|
|
77
|
+
export function ensureBusinessUris(db) {
|
|
78
|
+
let changed = 0;
|
|
79
|
+
const used = new Set();
|
|
80
|
+
for (const id of db.allNodeIds?.() || []) {
|
|
81
|
+
const payload = payloadOf(db, id);
|
|
82
|
+
if (!isBusinessType(payload.type)) continue;
|
|
83
|
+
const uri = String(payload.uri || "").trim();
|
|
84
|
+
if (uri) used.add(uri);
|
|
85
|
+
}
|
|
86
|
+
for (const id of db.allNodeIds?.() || []) {
|
|
87
|
+
const payload = payloadOf(db, id);
|
|
88
|
+
if (!isBusinessType(payload.type) || payload.status === "archived") continue;
|
|
89
|
+
if (String(payload.uri || "").trim()) continue;
|
|
90
|
+
let uri = mintUri(payload);
|
|
91
|
+
let n = 0;
|
|
92
|
+
while (used.has(uri)) {
|
|
93
|
+
n += 1;
|
|
94
|
+
uri = `${mintUri(payload)}-${n}`;
|
|
95
|
+
}
|
|
96
|
+
used.add(uri);
|
|
97
|
+
db.updatePayload(Number(id), { ...payload, uri });
|
|
98
|
+
changed += 1;
|
|
99
|
+
}
|
|
100
|
+
if (changed) db.flush();
|
|
101
|
+
return changed;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function serializeGraph(db) {
|
|
105
|
+
const nodes = [];
|
|
106
|
+
const uriOf = new Map();
|
|
107
|
+
for (const id of db.allNodeIds?.() || []) {
|
|
108
|
+
const payload = payloadOf(db, id);
|
|
109
|
+
if (!isBusinessType(payload.type) || payload.status === "archived") continue;
|
|
110
|
+
const uri = String(payload.uri || "").trim();
|
|
111
|
+
if (!uri) continue;
|
|
112
|
+
uriOf.set(Number(id), uri);
|
|
113
|
+
nodes.push(nodeRecord(payload));
|
|
114
|
+
}
|
|
115
|
+
nodes.sort((a, b) => String(a.uri).localeCompare(String(b.uri)));
|
|
116
|
+
const edges = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
const want = new Set(BUSINESS_EDGES);
|
|
119
|
+
for (const id of db.allNodeIds?.() || []) {
|
|
120
|
+
const fromUri = uriOf.get(Number(id));
|
|
121
|
+
if (!fromUri) continue;
|
|
122
|
+
const node = db.get(Number(id));
|
|
123
|
+
for (const e of node?.edges || []) {
|
|
124
|
+
const label = e.label || "";
|
|
125
|
+
if (!want.has(label)) continue;
|
|
126
|
+
const toUri = uriOf.get(edgeTarget(e));
|
|
127
|
+
if (!toUri || toUri === fromUri) continue;
|
|
128
|
+
const key = `${fromUri}\0${toUri}\0${label}`;
|
|
129
|
+
if (seen.has(key)) continue;
|
|
130
|
+
seen.add(key);
|
|
131
|
+
edges.push({ op: "edge", from: fromUri, to: toUri, type: label });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
edges.sort((a, b) => {
|
|
135
|
+
const x = String(a.from).localeCompare(String(b.from));
|
|
136
|
+
if (x) return x;
|
|
137
|
+
const y = String(a.to).localeCompare(String(b.to));
|
|
138
|
+
if (y) return y;
|
|
139
|
+
return String(a.type).localeCompare(String(b.type));
|
|
140
|
+
});
|
|
141
|
+
const header = { v: SIDECAR_VERSION, format: SIDECAR_FORMAT };
|
|
142
|
+
return [header, ...nodes, ...edges].map((row) => JSON.stringify(row)).join("\n") + "\n";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function parseSidecar(text) {
|
|
146
|
+
const lines = String(text || "").split(/\r?\n/);
|
|
147
|
+
const nodes = [];
|
|
148
|
+
const edges = [];
|
|
149
|
+
let header = null;
|
|
150
|
+
for (const raw of lines) {
|
|
151
|
+
const line = raw.trim();
|
|
152
|
+
if (!line) continue;
|
|
153
|
+
let row;
|
|
154
|
+
try {
|
|
155
|
+
row = JSON.parse(line);
|
|
156
|
+
} catch {
|
|
157
|
+
return { ok: false, message: "invalid jsonl" };
|
|
158
|
+
}
|
|
159
|
+
if (!row || typeof row !== "object") return { ok: false, message: "invalid jsonl" };
|
|
160
|
+
if (row.format === SIDECAR_FORMAT || (row.v && !row.op && !row.uri)) {
|
|
161
|
+
if (header) return { ok: false, message: "duplicate header" };
|
|
162
|
+
header = row;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const op = row.op || (row.uri && row.type ? "node" : row.from && row.to ? "edge" : "");
|
|
166
|
+
if (op === "node") {
|
|
167
|
+
if (!isBusinessType(row.type) || !String(row.uri || "").trim()) {
|
|
168
|
+
return { ok: false, message: "invalid node" };
|
|
169
|
+
}
|
|
170
|
+
nodes.push(row);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (op === "edge") {
|
|
174
|
+
const label = row.type || row.label || "";
|
|
175
|
+
if (!BUSINESS_EDGES.includes(label) || !row.from || !row.to) {
|
|
176
|
+
return { ok: false, message: "invalid edge" };
|
|
177
|
+
}
|
|
178
|
+
edges.push({ from: row.from, to: row.to, type: label });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
return { ok: false, message: "unknown jsonl row" };
|
|
182
|
+
}
|
|
183
|
+
if (!header || header.format !== SIDECAR_FORMAT) {
|
|
184
|
+
return { ok: false, message: "missing sidecar header" };
|
|
185
|
+
}
|
|
186
|
+
return { ok: true, header, nodes, edges };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function readSidecarFile(cwd) {
|
|
190
|
+
const file = sidecarPath(cwd);
|
|
191
|
+
if (!existsSync(file)) return null;
|
|
192
|
+
try {
|
|
193
|
+
return readFileSync(file, "utf8");
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function atomicWrite(file, text) {
|
|
200
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
201
|
+
const tmp = `${file}.tmp`;
|
|
202
|
+
writeFileSync(tmp, text, "utf8");
|
|
203
|
+
try {
|
|
204
|
+
renameSync(tmp, file);
|
|
205
|
+
} catch {
|
|
206
|
+
try {
|
|
207
|
+
unlinkSync(file);
|
|
208
|
+
} catch {
|
|
209
|
+
// dest may not exist
|
|
210
|
+
}
|
|
211
|
+
renameSync(tmp, file);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function rememberHash(cwd, hash) {
|
|
216
|
+
const key = resolve(cwd);
|
|
217
|
+
memoryHash.set(key, hash);
|
|
218
|
+
try {
|
|
219
|
+
writeSidecarHash(key, hash);
|
|
220
|
+
} catch {
|
|
221
|
+
// home settings write is best-effort
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function knownHash(cwd) {
|
|
226
|
+
const key = resolve(cwd);
|
|
227
|
+
return memoryHash.get(key) || sidecarHashOf(key) || "";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function unlinkIfExists(path) {
|
|
231
|
+
try {
|
|
232
|
+
unlinkSync(path);
|
|
233
|
+
return true;
|
|
234
|
+
} catch (err) {
|
|
235
|
+
if (err && err.code === "ENOENT") return false;
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function clearSidecarState(cwd) {
|
|
241
|
+
const key = resolve(cwd || "");
|
|
242
|
+
const prev = timers.get(key);
|
|
243
|
+
if (prev) {
|
|
244
|
+
clearTimeout(prev);
|
|
245
|
+
timers.delete(key);
|
|
246
|
+
}
|
|
247
|
+
memoryHash.delete(key);
|
|
248
|
+
try {
|
|
249
|
+
writeSidecarHash(key, "");
|
|
250
|
+
} catch {
|
|
251
|
+
// home settings write is best-effort
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function deleteSidecarAt(cwd) {
|
|
256
|
+
const key = resolve(cwd || "");
|
|
257
|
+
if (!key) return { path: "", deleted: false };
|
|
258
|
+
clearSidecarState(key);
|
|
259
|
+
const file = sidecarPath(key);
|
|
260
|
+
const deletedFile = unlinkIfExists(file);
|
|
261
|
+
const deletedTmp = unlinkIfExists(`${file}.tmp`);
|
|
262
|
+
return { path: file, deleted: deletedFile || deletedTmp };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function deleteKnownSidecars(extraCwds = []) {
|
|
266
|
+
const seen = new Set();
|
|
267
|
+
const files = [];
|
|
268
|
+
const add = (cwd) => {
|
|
269
|
+
const key = String(cwd || "").trim();
|
|
270
|
+
if (!key) return;
|
|
271
|
+
const resolved = resolve(key);
|
|
272
|
+
if (seen.has(resolved)) return;
|
|
273
|
+
seen.add(resolved);
|
|
274
|
+
const result = deleteSidecarAt(resolved);
|
|
275
|
+
if (result.deleted) files.push(result.path);
|
|
276
|
+
};
|
|
277
|
+
try {
|
|
278
|
+
for (const cwd of listedWorkspaces()) add(cwd);
|
|
279
|
+
} catch {
|
|
280
|
+
// listing is best-effort
|
|
281
|
+
}
|
|
282
|
+
for (const cwd of extraCwds) add(cwd);
|
|
283
|
+
return files;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function writeSidecarNow(cwd, db, { force = false } = {}) {
|
|
287
|
+
if (!cwd || !db) return null;
|
|
288
|
+
if (!gitSidecarEnabledOf(readUiSettings())) return null;
|
|
289
|
+
ensureBusinessUris(db);
|
|
290
|
+
const text = serializeGraph(db);
|
|
291
|
+
const hash = hashText(text);
|
|
292
|
+
const file = sidecarPath(cwd);
|
|
293
|
+
if (!force && hash === knownHash(cwd) && existsSync(file)) {
|
|
294
|
+
return { path: file, hash, wrote: false };
|
|
295
|
+
}
|
|
296
|
+
atomicWrite(file, text);
|
|
297
|
+
rememberHash(cwd, hash);
|
|
298
|
+
return { path: file, hash, wrote: true };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function scheduleSidecarWrite(cwd, db) {
|
|
302
|
+
if (!cwd || !db) return;
|
|
303
|
+
if (!gitSidecarEnabledOf(readUiSettings())) return;
|
|
304
|
+
const key = resolve(cwd);
|
|
305
|
+
const prev = timers.get(key);
|
|
306
|
+
if (prev) clearTimeout(prev);
|
|
307
|
+
const timer = setTimeout(() => {
|
|
308
|
+
timers.delete(key);
|
|
309
|
+
try {
|
|
310
|
+
writeSidecarNow(key, db);
|
|
311
|
+
} catch {
|
|
312
|
+
// disk errors are non-fatal
|
|
313
|
+
}
|
|
314
|
+
}, SIDECAR_DEBOUNCE_MS);
|
|
315
|
+
timers.set(key, timer);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function flushSidecarNow(cwd, db) {
|
|
319
|
+
const key = resolve(cwd || "");
|
|
320
|
+
const prev = timers.get(key);
|
|
321
|
+
if (prev) {
|
|
322
|
+
clearTimeout(prev);
|
|
323
|
+
timers.delete(key);
|
|
324
|
+
}
|
|
325
|
+
if (!cwd || !db) return null;
|
|
326
|
+
return writeSidecarNow(key, db);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function applySidecar(db, parsed) {
|
|
330
|
+
const store = await import("./store.js");
|
|
331
|
+
const uriSet = new Set();
|
|
332
|
+
store.runWithoutSidecar(() => {
|
|
333
|
+
const idByUri = new Map();
|
|
334
|
+
for (const row of parsed.nodes) {
|
|
335
|
+
const uri = String(row.uri || "").trim();
|
|
336
|
+
uriSet.add(uri);
|
|
337
|
+
const payload = {
|
|
338
|
+
type: row.type,
|
|
339
|
+
name: row.name || "",
|
|
340
|
+
text: row.text || "",
|
|
341
|
+
uri,
|
|
342
|
+
status: row.status && row.status !== "archived" ? row.status : "active",
|
|
343
|
+
aliases: Array.isArray(row.aliases) ? row.aliases : [],
|
|
344
|
+
until: row.until || "",
|
|
345
|
+
untilAt: row.untilAt || "",
|
|
346
|
+
fail: row.fail || "",
|
|
347
|
+
fix: row.fix || "",
|
|
348
|
+
createdAt: row.createdAt || "",
|
|
349
|
+
updatedAt: row.updatedAt || "",
|
|
350
|
+
};
|
|
351
|
+
if (!payload.until) delete payload.until;
|
|
352
|
+
if (!payload.untilAt) delete payload.untilAt;
|
|
353
|
+
if (!payload.fail) delete payload.fail;
|
|
354
|
+
if (!payload.fix) delete payload.fix;
|
|
355
|
+
let dest = store.findByUri(db, uri);
|
|
356
|
+
if (dest == null) dest = store.findMergeTarget(db, payload);
|
|
357
|
+
if (dest != null) {
|
|
358
|
+
store.mergeInto(db, dest, payload);
|
|
359
|
+
idByUri.set(uri, dest);
|
|
360
|
+
} else {
|
|
361
|
+
idByUri.set(uri, store.insertNode(db, payload));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const wantEdges = new Set();
|
|
365
|
+
for (const e of parsed.edges) {
|
|
366
|
+
const from = idByUri.get(e.from) ?? store.findByUri(db, e.from);
|
|
367
|
+
const to = idByUri.get(e.to) ?? store.findByUri(db, e.to);
|
|
368
|
+
if (from == null || to == null || from === to) continue;
|
|
369
|
+
store.ensureLink(db, from, to, e.type);
|
|
370
|
+
wantEdges.add(`${e.from}\0${e.to}\0${e.type}`);
|
|
371
|
+
}
|
|
372
|
+
const uriById = new Map();
|
|
373
|
+
for (const id of db.allNodeIds?.() || []) {
|
|
374
|
+
const payload = payloadOf(db, id);
|
|
375
|
+
if (!isBusinessType(payload.type)) continue;
|
|
376
|
+
const uri = String(payload.uri || "").trim();
|
|
377
|
+
if (uri) uriById.set(Number(id), uri);
|
|
378
|
+
}
|
|
379
|
+
for (const id of db.allNodeIds?.() || []) {
|
|
380
|
+
const fromUri = uriById.get(Number(id));
|
|
381
|
+
if (!fromUri) continue;
|
|
382
|
+
const node = db.get(Number(id));
|
|
383
|
+
for (const e of node?.edges || []) {
|
|
384
|
+
const label = e.label || "";
|
|
385
|
+
if (!BUSINESS_EDGES.includes(label)) continue;
|
|
386
|
+
const toUri = uriById.get(edgeTarget(e));
|
|
387
|
+
if (!toUri) continue;
|
|
388
|
+
if (!wantEdges.has(`${fromUri}\0${toUri}\0${label}`)) {
|
|
389
|
+
store.dropLink(db, id, edgeTarget(e), label);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
for (const id of db.allNodeIds?.() || []) {
|
|
394
|
+
const payload = payloadOf(db, id);
|
|
395
|
+
if (!isBusinessType(payload.type) || payload.status === "archived") continue;
|
|
396
|
+
const uri = String(payload.uri || "").trim();
|
|
397
|
+
if (!uri || !uriSet.has(uri)) store.archiveNode(db, id);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Import when the sidecar changed outside this process (clone / git pull).
|
|
404
|
+
* Write is skipped when the switch is off; import still runs.
|
|
405
|
+
*/
|
|
406
|
+
export async function syncSidecarOnOpen(cwd, db) {
|
|
407
|
+
if (!cwd || !db) return { action: "skip" };
|
|
408
|
+
const key = resolve(cwd);
|
|
409
|
+
const text = readSidecarFile(key);
|
|
410
|
+
if (text == null) {
|
|
411
|
+
if (!gitSidecarEnabledOf(readUiSettings())) return { action: "skip" };
|
|
412
|
+
const wrote = writeSidecarNow(key, db);
|
|
413
|
+
return { action: "bootstrap-write", hash: wrote?.hash || "" };
|
|
414
|
+
}
|
|
415
|
+
const fileHash = hashText(text);
|
|
416
|
+
if (fileHash === knownHash(key)) return { action: "skip" };
|
|
417
|
+
const parsed = parseSidecar(text);
|
|
418
|
+
if (!parsed.ok) return { action: "skip-invalid", message: parsed.message };
|
|
419
|
+
await applySidecar(db, parsed);
|
|
420
|
+
rememberHash(key, fileHash);
|
|
421
|
+
return { action: "import", hash: fileHash, nodes: parsed.nodes.length, edges: parsed.edges.length };
|
|
422
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -3,6 +3,13 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { rememberWorkspace } from "./settings.js";
|
|
6
|
+
import {
|
|
7
|
+
deleteKnownSidecars,
|
|
8
|
+
flushSidecarNow,
|
|
9
|
+
scheduleSidecarWrite,
|
|
10
|
+
syncSidecarOnOpen,
|
|
11
|
+
writeSidecarNow,
|
|
12
|
+
} from "./sidecar.js";
|
|
6
13
|
import { DIM, EDGE_LABELS, BUSINESS_EDGES, NODE_TYPES, indexTextFor, nowIso, weightOf, zeroVector } from "./schema.js";
|
|
7
14
|
import { clipToTokenBudget, estimateTokens } from "./tokens.js";
|
|
8
15
|
import { isStalePayload, parseUntilFromText, queryMentionsUntil, resolveUntilAt } from "./until.js";
|
|
@@ -32,6 +39,31 @@ function loadEngine() {
|
|
|
32
39
|
|
|
33
40
|
const dbs = new Map();
|
|
34
41
|
let activeCwd = null;
|
|
42
|
+
let sidecarPaused = false;
|
|
43
|
+
|
|
44
|
+
export function cwdForDb(db) {
|
|
45
|
+
for (const [cwd, inst] of dbs) {
|
|
46
|
+
if (inst === db) return cwd;
|
|
47
|
+
}
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function runWithoutSidecar(fn) {
|
|
52
|
+
const prev = sidecarPaused;
|
|
53
|
+
sidecarPaused = true;
|
|
54
|
+
try {
|
|
55
|
+
return fn();
|
|
56
|
+
} finally {
|
|
57
|
+
sidecarPaused = prev;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function persist(db) {
|
|
62
|
+
db.flush();
|
|
63
|
+
if (sidecarPaused) return;
|
|
64
|
+
const cwd = cwdForDb(db);
|
|
65
|
+
if (cwd) scheduleSidecarWrite(cwd, db);
|
|
66
|
+
}
|
|
35
67
|
|
|
36
68
|
export function dbPathFor(cwd) {
|
|
37
69
|
return join(resolve(cwd), ".dsh", "trivium.tdb");
|
|
@@ -54,6 +86,19 @@ export function listOpenWorkspaces() {
|
|
|
54
86
|
}));
|
|
55
87
|
}
|
|
56
88
|
|
|
89
|
+
/** Replace jsonl from the current .tdb. Overwrite in place (same path) so git diffs lines. */
|
|
90
|
+
export function generateSidecarFromDb(cwd, db) {
|
|
91
|
+
const wrote = writeSidecarNow(cwd, db, { force: true });
|
|
92
|
+
if (!wrote) return { ok: false, message: "git sidecar off" };
|
|
93
|
+
return { ok: true, action: "generate", cwd, path: wrote.path, wrote: wrote.wrote };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Delete known workspace trivium.jsonl files. .tdb stays. */
|
|
97
|
+
export function deleteGitSidecars() {
|
|
98
|
+
const files = deleteKnownSidecars([...dbs.keys(), activeCwd].filter(Boolean));
|
|
99
|
+
return { ok: true, action: "delete", files };
|
|
100
|
+
}
|
|
101
|
+
|
|
57
102
|
export function nodeCountOf(db) {
|
|
58
103
|
try {
|
|
59
104
|
if (typeof db.nodeCount === "function") return db.nodeCount();
|
|
@@ -84,11 +129,21 @@ export async function openWorkspaceDb(cwd) {
|
|
|
84
129
|
} catch {
|
|
85
130
|
// listing is best-effort; opening the store still succeeds
|
|
86
131
|
}
|
|
132
|
+
try {
|
|
133
|
+
await syncSidecarOnOpen(key, db);
|
|
134
|
+
} catch {
|
|
135
|
+
// sidecar is best-effort; the .tdb still opens
|
|
136
|
+
}
|
|
87
137
|
return db;
|
|
88
138
|
}
|
|
89
139
|
|
|
90
140
|
export function closeAll() {
|
|
91
|
-
for (const db of dbs
|
|
141
|
+
for (const [cwd, db] of dbs) {
|
|
142
|
+
try {
|
|
143
|
+
flushSidecarNow(cwd, db);
|
|
144
|
+
} catch {
|
|
145
|
+
// ignore
|
|
146
|
+
}
|
|
92
147
|
try {
|
|
93
148
|
db.flush();
|
|
94
149
|
db.close();
|
|
@@ -263,7 +318,7 @@ export function mergeInto(db, id, incoming) {
|
|
|
263
318
|
});
|
|
264
319
|
db.updatePayload(Number(id), next);
|
|
265
320
|
indexNode(db, Number(id), next);
|
|
266
|
-
db
|
|
321
|
+
persist(db);
|
|
267
322
|
queueEmbed(db, Number(id), next);
|
|
268
323
|
return Number(id);
|
|
269
324
|
}
|
|
@@ -339,7 +394,7 @@ export function archiveNode(db, id) {
|
|
|
339
394
|
};
|
|
340
395
|
db.updatePayload(Number(id), payload);
|
|
341
396
|
rebuildTextIndex(db);
|
|
342
|
-
db
|
|
397
|
+
persist(db);
|
|
343
398
|
return true;
|
|
344
399
|
}
|
|
345
400
|
|
|
@@ -348,7 +403,7 @@ export function deleteNode(db, id) {
|
|
|
348
403
|
if (!db.contains?.(n) && !db.get(n)) return false;
|
|
349
404
|
db.delete(n);
|
|
350
405
|
rebuildTextIndex(db);
|
|
351
|
-
db
|
|
406
|
+
persist(db);
|
|
352
407
|
return true;
|
|
353
408
|
}
|
|
354
409
|
|
|
@@ -390,7 +445,7 @@ export function updateNode(db, id, patch = {}) {
|
|
|
390
445
|
}
|
|
391
446
|
db.updatePayload(nid, body);
|
|
392
447
|
indexNode(db, nid, body);
|
|
393
|
-
db
|
|
448
|
+
persist(db);
|
|
394
449
|
queueEmbed(db, nid, body);
|
|
395
450
|
return body;
|
|
396
451
|
}
|
|
@@ -541,7 +596,7 @@ export function ensureLink(db, from, to, label) {
|
|
|
541
596
|
if (!Number.isFinite(src) || !Number.isFinite(dst) || src === dst) return false;
|
|
542
597
|
if (hasEdge(db, src, dst, label)) return false;
|
|
543
598
|
db.link(src, dst, label, weightOf(label));
|
|
544
|
-
db
|
|
599
|
+
persist(db);
|
|
545
600
|
return true;
|
|
546
601
|
}
|
|
547
602
|
|
|
@@ -554,7 +609,7 @@ export function dropLink(db, from, to, label) {
|
|
|
554
609
|
try {
|
|
555
610
|
if (label) db.unlink(src, dst, label);
|
|
556
611
|
else db.unlink(src, dst);
|
|
557
|
-
db
|
|
612
|
+
persist(db);
|
|
558
613
|
return true;
|
|
559
614
|
} catch {
|
|
560
615
|
return false;
|
|
@@ -597,7 +652,7 @@ export function insertNode(db, payload, { linkToWorkspace = true } = {}) {
|
|
|
597
652
|
const root = workspaceId(db);
|
|
598
653
|
if (root != null) ensureLink(db, id, root, EDGE_LABELS.inWorkspace);
|
|
599
654
|
}
|
|
600
|
-
db
|
|
655
|
+
persist(db);
|
|
601
656
|
queueEmbed(db, id, body);
|
|
602
657
|
return id;
|
|
603
658
|
}
|