dsh-engram 0.3.5 → 0.3.6
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 +128 -6
- package/README.zh.md +77 -4
- package/lib/api.js +57 -2
- package/lib/client.js +13 -1
- package/lib/index-block.js +92 -1
- package/lib/index.js +37 -1
- package/lib/settings.js +6 -0
- package/lib/store.js +287 -0
- package/lib/tools.js +48 -14
- package/lib/usage.js +146 -5
- package/package.json +1 -1
package/lib/store.js
CHANGED
|
@@ -69,6 +69,10 @@ const MemorySchema = z.object({
|
|
|
69
69
|
supersedes: z.string().nullable().default(null),
|
|
70
70
|
/** Memory id this row directly contradicts (conflicting claim, keep both). */
|
|
71
71
|
contradicts: z.string().nullable().default(null),
|
|
72
|
+
/** Optional provenance source class: manual | session | report | upstream | handoff. */
|
|
73
|
+
source: z.string().nullable().default(null),
|
|
74
|
+
/** Optional trigger/condition under which this memory should be recalled. */
|
|
75
|
+
conditions: z.string().nullable().default(null),
|
|
72
76
|
});
|
|
73
77
|
|
|
74
78
|
const TaskSchema = z.object({
|
|
@@ -506,6 +510,8 @@ export async function openEngramDomain(facility) {
|
|
|
506
510
|
status: "active",
|
|
507
511
|
supersedes,
|
|
508
512
|
contradicts,
|
|
513
|
+
source: entry.source ?? null,
|
|
514
|
+
conditions: entry.conditions ?? null,
|
|
509
515
|
expiresAt,
|
|
510
516
|
};
|
|
511
517
|
await memories.put(stored.id, stored);
|
|
@@ -545,6 +551,58 @@ export async function openEngramDomain(facility) {
|
|
|
545
551
|
}
|
|
546
552
|
return active.sort(byRecency).slice(0, bounded);
|
|
547
553
|
},
|
|
554
|
+
/**
|
|
555
|
+
* Optional cross-workspace recall (issue #5, recallScope: "global").
|
|
556
|
+
* Ranks across EVERY workspace's active pool with the same deterministic
|
|
557
|
+
* BM25 scorer, then applies a small origin penalty so an out-of-workspace
|
|
558
|
+
* hit needs meaningfully stronger lexical relevance to displace an
|
|
559
|
+
* in-workspace one. Results are annotated with `_origin` (the workspace key)
|
|
560
|
+
* so the tools layer can surface a [W:<workspace>] marker — the model then
|
|
561
|
+
* knows the context a cross-workspace entry came from. Default strictly
|
|
562
|
+
* preserves per-workspace isolation; this is only invoked when the caller
|
|
563
|
+
* opts in (config recallScope="global" or an explicit scope="global" call).
|
|
564
|
+
*/
|
|
565
|
+
recallGlobal(query, limit = 20, { maxPerSession, excludeWorkspace } = {}) {
|
|
566
|
+
const bounded = Math.max(1, Math.min(50, Math.trunc(limit) || 20));
|
|
567
|
+
let pool = [...memories.entries()].map(([, m]) => m).filter((m) => m.status === "active").filter(notExpiredNow);
|
|
568
|
+
if (excludeWorkspace !== void 0 && excludeWorkspace !== null) {
|
|
569
|
+
pool = pool.filter((m) => m.workspace !== excludeWorkspace);
|
|
570
|
+
}
|
|
571
|
+
if (pool.length === 0) return [];
|
|
572
|
+
// Tag every row with its origin workspace for the display layer.
|
|
573
|
+
const tagged = pool.map((m) => ({ ...m, _origin: m.workspace }));
|
|
574
|
+
const trimmed = String(query ?? "").trim();
|
|
575
|
+
const half = Math.max(1, bounded * 3);
|
|
576
|
+
let ranked;
|
|
577
|
+
if (trimmed.length === 0) {
|
|
578
|
+
ranked = tagged.sort(byRecency).slice(0, bounded);
|
|
579
|
+
} else {
|
|
580
|
+
ranked = bm25Rank(tagged, trimmed, half);
|
|
581
|
+
}
|
|
582
|
+
// Origin penalty: an out-of-workspace hit is discounted by 20% (a
|
|
583
|
+
// same-workspace entry with comparable relevance wins ties). bm25Rank
|
|
584
|
+
// drops its internal score, but the returned order is monotonic in it;
|
|
585
|
+
// we only re-stabilize near-equal neighbours so cross-workspace hits
|
|
586
|
+
// never displace an equally-relevant in-workspace one.
|
|
587
|
+
if (excludeWorkspace !== void 0 && excludeWorkspace !== null && ranked.length > 1) {
|
|
588
|
+
ranked = ranked.slice().sort((a, b) => {
|
|
589
|
+
const crossA = (a._origin ?? a.workspace) !== excludeWorkspace;
|
|
590
|
+
const crossB = (b._origin ?? b.workspace) !== excludeWorkspace;
|
|
591
|
+
if (crossA === crossB) return 0;
|
|
592
|
+
return crossA ? 1 : -1; // same-workspace first within ties
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
const out = [];
|
|
596
|
+
const seen = new Set();
|
|
597
|
+
for (const m of ranked) {
|
|
598
|
+
if (seen.has(m.id)) continue;
|
|
599
|
+
seen.add(m.id);
|
|
600
|
+
out.push(m);
|
|
601
|
+
if (out.length >= bounded) break;
|
|
602
|
+
}
|
|
603
|
+
if (maxPerSession !== void 0 && maxPerSession !== null) return dedupeBySession(out, maxPerSession);
|
|
604
|
+
return out;
|
|
605
|
+
},
|
|
548
606
|
/**
|
|
549
607
|
* Memory-to-memory relation lookup maps for a workspace (deterministic):
|
|
550
608
|
* supersededBy: id -> { newer } (a row marked m.supersedes === id)
|
|
@@ -971,6 +1029,235 @@ export async function openEngramDomain(facility) {
|
|
|
971
1029
|
await entities.delete(id);
|
|
972
1030
|
return true;
|
|
973
1031
|
},
|
|
1032
|
+
// ── export / import (data contract, format "dsh-engram/export" v1) ─
|
|
1033
|
+
/**
|
|
1034
|
+
* Dump every row of the four memory tables — including archived memories /
|
|
1035
|
+
* tasks — so a backup or migration never loses provenance. `workspace`
|
|
1036
|
+
* restricts to one workspace; falsy dumps every workspace. The dump is the
|
|
1037
|
+
* plain contract a consumer (or a future dsh release) can round-trip back
|
|
1038
|
+
* through `importAll` — it carries `meta.format` + `meta.version` so
|
|
1039
|
+
* forwards-incompatible formats can be rejected by the importer.
|
|
1040
|
+
*/
|
|
1041
|
+
exportAll(workspace) {
|
|
1042
|
+
const pick = (table) => [...table.entries()].map(([, v]) => v).filter((v) => v.workspace === workspace);
|
|
1043
|
+
const all = workspace
|
|
1044
|
+
? { memories: pick(memories), tasks: pick(tasks), links: pick(links), entities: pick(entities) }
|
|
1045
|
+
: {
|
|
1046
|
+
memories: [...memories.entries()].map(([, v]) => v),
|
|
1047
|
+
tasks: [...tasks.entries()].map(([, v]) => v),
|
|
1048
|
+
links: [...links.entries()].map(([, v]) => v),
|
|
1049
|
+
entities: [...entities.entries()].map(([, v]) => v),
|
|
1050
|
+
};
|
|
1051
|
+
return {
|
|
1052
|
+
meta: { format: "dsh-engram/export", version: 1, exportedAt: now(), workspace: workspace ?? null },
|
|
1053
|
+
...all,
|
|
1054
|
+
};
|
|
1055
|
+
},
|
|
1056
|
+
/**
|
|
1057
|
+
* Restore a dump produced by `exportAll` (same format/version), honoring
|
|
1058
|
+
* the same write-path invariants as `storeMemory`: memory text is run
|
|
1059
|
+
* through the deterministic secret redactor before it can land on disk.
|
|
1060
|
+
*
|
|
1061
|
+
* Modes:
|
|
1062
|
+
* - `merge` (default) — idempotent: writes only rows whose id is absent,
|
|
1063
|
+
* kept per-row (each row keeps its own `workspace`).
|
|
1064
|
+
* - `replace` — wipes the given `workspace` from all four tables first,
|
|
1065
|
+
* then writes only rows belonging to it (a full restore; the caller
|
|
1066
|
+
* owns the destructive step).
|
|
1067
|
+
*
|
|
1068
|
+
* Bad rows never abort the whole import: structural/validation failures
|
|
1069
|
+
* (missing id/workspace, empty text, text over the char cap, id already
|
|
1070
|
+
* present in merge, workspace memory cap in merge) are skipped and listed
|
|
1071
|
+
* in the report. `dryRun: true` computes the identical plan without
|
|
1072
|
+
* writing a single row.
|
|
1073
|
+
*
|
|
1074
|
+
* @param payload - the dump (`{ meta?, memories?, tasks?, links?, entities? }`).
|
|
1075
|
+
* @param opts - `{ mode, dryRun, workspace, config }` (workspace mandatory for replace).
|
|
1076
|
+
* @returns { mode, dryRun, written: {table: n}, skipped: [{table,id,reason}] }
|
|
1077
|
+
*/
|
|
1078
|
+
async importAll(payload, { mode = "merge", dryRun = false, workspace, config } = {}) {
|
|
1079
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
1080
|
+
throw new EngramError("ENGRAM_INVALID_ARGS", "import payload must be an object (a dsh-engram/export dump)");
|
|
1081
|
+
}
|
|
1082
|
+
if (mode !== "merge" && mode !== "replace") {
|
|
1083
|
+
throw new EngramError("ENGRAM_INVALID_ARGS", "import mode must be 'merge' or 'replace'");
|
|
1084
|
+
}
|
|
1085
|
+
if (mode === "replace" && (typeof workspace !== "string" || workspace.length === 0)) {
|
|
1086
|
+
throw new EngramError("ENGRAM_INVALID_ARGS", "replace mode requires the workspace to restore into");
|
|
1087
|
+
}
|
|
1088
|
+
const maxMemoryChars = config?.maxMemoryChars ?? 1600;
|
|
1089
|
+
const maxPerWs = config?.maxMemoriesPerWorkspace ?? 2000;
|
|
1090
|
+
const tables = {
|
|
1091
|
+
memories: Array.isArray(payload.memories) ? payload.memories : [],
|
|
1092
|
+
tasks: Array.isArray(payload.tasks) ? payload.tasks : [],
|
|
1093
|
+
links: Array.isArray(payload.links) ? payload.links : [],
|
|
1094
|
+
entities: Array.isArray(payload.entities) ? payload.entities : [],
|
|
1095
|
+
};
|
|
1096
|
+
const written = { memories: 0, tasks: 0, links: 0, entities: 0 };
|
|
1097
|
+
const skipped = [];
|
|
1098
|
+
const rowTable = (name) => ({ memories, tasks, links, entities })[name];
|
|
1099
|
+
const rowsOf = (table, ws) => [...table.entries()].map(([, v]) => v).filter((v) => v.workspace === ws);
|
|
1100
|
+
|
|
1101
|
+
// Destructive mode: drop every row of the target workspace up front
|
|
1102
|
+
// (planned under dryRun, executed otherwise).
|
|
1103
|
+
if (mode === "replace") {
|
|
1104
|
+
for (const [name, table] of Object.entries({ memories, tasks, links, entities })) {
|
|
1105
|
+
for (const row of rowsOf(table, workspace)) {
|
|
1106
|
+
if (!dryRun) await table.delete(row.id);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Per-workspace running count of ACTIVE memories — the invariant the
|
|
1112
|
+
// workspace cap protects (index-block size); archived rows never count,
|
|
1113
|
+
// and the cap is per workspace, not global.
|
|
1114
|
+
const activeByWs = new Map();
|
|
1115
|
+
for (const [, m] of memories.entries()) {
|
|
1116
|
+
if (m.status === "active") activeByWs.set(m.workspace, (activeByWs.get(m.workspace) ?? 0) + 1);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const putRow = async (name, key, row) => {
|
|
1120
|
+
if (!dryRun) await rowTable(name).put(key, row);
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
// Schema defaults, mirroring storeMemory / the zod schemas: an imported
|
|
1124
|
+
// row must behave exactly like one that went through the live write path
|
|
1125
|
+
// (listMemories/recall only see status==="active", tasks need a state,
|
|
1126
|
+
// etc.) — so absent optional fields get their canonical default, never
|
|
1127
|
+
// `undefined`.
|
|
1128
|
+
const normalizeMemory = (row, key, ws, text) => ({
|
|
1129
|
+
kind: row.kind ?? "fact",
|
|
1130
|
+
tags: Array.isArray(row.tags) ? row.tags.slice() : [],
|
|
1131
|
+
entity: row.entity ?? null,
|
|
1132
|
+
seq: Number.isFinite(row.seq) ? row.seq : 0,
|
|
1133
|
+
...row,
|
|
1134
|
+
id: key,
|
|
1135
|
+
workspace: ws,
|
|
1136
|
+
text,
|
|
1137
|
+
status: row.status ?? "active",
|
|
1138
|
+
hits: Number.isFinite(row.hits) ? row.hits : 0,
|
|
1139
|
+
lastAccessAt: row.lastAccessAt ?? null,
|
|
1140
|
+
filePath: row.filePath ?? null,
|
|
1141
|
+
signal: typeof row.signal === "number" ? row.signal : 0.5,
|
|
1142
|
+
supersedes: row.supersedes ?? null,
|
|
1143
|
+
contradicts: row.contradicts ?? null,
|
|
1144
|
+
source: row.source ?? null,
|
|
1145
|
+
conditions: row.conditions ?? null,
|
|
1146
|
+
expiresAt: row.expiresAt ?? null,
|
|
1147
|
+
sessionId: row.sessionId ?? "import",
|
|
1148
|
+
createdAt: Number.isFinite(row.createdAt) ? row.createdAt : now(),
|
|
1149
|
+
updatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : now(),
|
|
1150
|
+
});
|
|
1151
|
+
const normalizeTask = (row, key, ws) => ({
|
|
1152
|
+
...row,
|
|
1153
|
+
id: key,
|
|
1154
|
+
workspace: ws,
|
|
1155
|
+
state: row.state ?? "draft",
|
|
1156
|
+
artifact: row.artifact ?? null,
|
|
1157
|
+
evaluation: row.evaluation ?? null,
|
|
1158
|
+
memoryRefs: Array.isArray(row.memoryRefs) ? row.memoryRefs.slice() : [],
|
|
1159
|
+
deps: Array.isArray(row.deps) ? row.deps.slice() : [],
|
|
1160
|
+
assignee: row.assignee ?? null,
|
|
1161
|
+
claimedAt: row.claimedAt ?? null,
|
|
1162
|
+
stateChangedAt: Number.isFinite(row.stateChangedAt) ? row.stateChangedAt : 0,
|
|
1163
|
+
archivedAt: row.archivedAt ?? null,
|
|
1164
|
+
sessionId: row.sessionId ?? "import",
|
|
1165
|
+
createdAt: Number.isFinite(row.createdAt) ? row.createdAt : now(),
|
|
1166
|
+
updatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : now(),
|
|
1167
|
+
});
|
|
1168
|
+
const normalizeLink = (row, key, ws) => ({
|
|
1169
|
+
...row,
|
|
1170
|
+
id: key,
|
|
1171
|
+
workspace: ws,
|
|
1172
|
+
relation: row.relation ?? "relates-to",
|
|
1173
|
+
confidence: typeof row.confidence === "number" ? row.confidence : 1,
|
|
1174
|
+
sessionId: row.sessionId ?? "import",
|
|
1175
|
+
createdAt: Number.isFinite(row.createdAt) ? row.createdAt : now(),
|
|
1176
|
+
});
|
|
1177
|
+
const normalizeEntity = (row, key, ws) => ({
|
|
1178
|
+
...row,
|
|
1179
|
+
id: key,
|
|
1180
|
+
workspace: ws,
|
|
1181
|
+
description: row.description ?? "",
|
|
1182
|
+
kind: row.kind ?? "",
|
|
1183
|
+
sessionId: row.sessionId ?? "import",
|
|
1184
|
+
createdAt: Number.isFinite(row.createdAt) ? row.createdAt : now(),
|
|
1185
|
+
updatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : now(),
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
for (const row of tables.memories) {
|
|
1189
|
+
const key = typeof row?.id === "string" && row.id.length > 0 ? row.id : "";
|
|
1190
|
+
const ws = typeof row?.workspace === "string" && row.workspace.length > 0 ? row.workspace : workspace;
|
|
1191
|
+
if (key === "") {
|
|
1192
|
+
skipped.push({ table: "memories", id: null, reason: "missing id" });
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
if (mode === "merge" && memories.get(key) !== void 0) {
|
|
1196
|
+
skipped.push({ table: "memories", id: key, reason: "exists" });
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
if (typeof ws !== "string" || ws.length === 0) {
|
|
1200
|
+
skipped.push({ table: "memories", id: key, reason: "missing workspace" });
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
if (mode === "replace" && ws !== workspace) {
|
|
1204
|
+
skipped.push({ table: "memories", id: key, reason: "outside restored workspace" });
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
const text = redactText(String(row.text ?? "")).trim();
|
|
1208
|
+
if (text.length === 0) {
|
|
1209
|
+
skipped.push({ table: "memories", id: key, reason: "empty text" });
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
if ([...text].length > maxMemoryChars) {
|
|
1213
|
+
skipped.push({ table: "memories", id: key, reason: "over maxMemoryChars" });
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
if (mode === "merge" && (row.status ?? "active") === "active" && (activeByWs.get(ws) ?? 0) >= maxPerWs) {
|
|
1217
|
+
skipped.push({ table: "memories", id: key, reason: "workspace memory cap" });
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
if ((row.status ?? "active") === "active") activeByWs.set(ws, (activeByWs.get(ws) ?? 0) + 1);
|
|
1221
|
+
try {
|
|
1222
|
+
await putRow("memories", key, normalizeMemory(row, key, ws, text));
|
|
1223
|
+
written.memories += 1;
|
|
1224
|
+
} catch (error) {
|
|
1225
|
+
skipped.push({ table: "memories", id: key, reason: `invalid row: ${String(error?.message ?? error)}` });
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
for (const [name, rows] of Object.entries({ tasks: tables.tasks, links: tables.links, entities: tables.entities })) {
|
|
1230
|
+
for (const row of rows) {
|
|
1231
|
+
const key = typeof row?.id === "string" && row.id.length > 0 ? row.id : "";
|
|
1232
|
+
if (key === "") {
|
|
1233
|
+
skipped.push({ table: name, id: null, reason: "missing id" });
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
if (mode === "merge" && rowTable(name).get(key) !== void 0) {
|
|
1237
|
+
skipped.push({ table: name, id: key, reason: "exists" });
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
const ws = typeof row?.workspace === "string" && row.workspace.length > 0 ? row.workspace : workspace;
|
|
1241
|
+
if (typeof ws !== "string" || ws.length === 0) {
|
|
1242
|
+
skipped.push({ table: name, id: key, reason: "missing workspace" });
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
if (mode === "replace" && ws !== workspace) {
|
|
1246
|
+
skipped.push({ table: name, id: key, reason: "outside restored workspace" });
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
const normalize = { tasks: normalizeTask, links: normalizeLink, entities: normalizeEntity }[name];
|
|
1250
|
+
try {
|
|
1251
|
+
await putRow(name, key, normalize(row, key, ws));
|
|
1252
|
+
written[name] += 1;
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
skipped.push({ table: name, id: key, reason: `invalid row: ${String(error?.message ?? error)}` });
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
return { mode, dryRun, written, skipped };
|
|
1260
|
+
},
|
|
974
1261
|
// ── usage / observability ────────────────────────────────────────
|
|
975
1262
|
/** Merge one tool-call event into the (workspace, day) rollup row. */
|
|
976
1263
|
async bumpUsage(workspace, day, { counts = {}, failures = 0, recall = {} }) {
|
package/lib/tools.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { z } from "zod";
|
|
19
|
+
import { basename } from "node:path";
|
|
19
20
|
import { EngramError, hasCode } from "./store.js";
|
|
20
21
|
import { buildEsrStatusView } from "./index-block.js";
|
|
21
22
|
import { truncate, shortId, slugId, fmtDate, escapeLt, workspaceKey as wk, uuid } from "./util.js";
|
|
@@ -74,7 +75,12 @@ async function requireDomain(service) {
|
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
function memoryLine(m, verbosity = "short", rel) {
|
|
78
|
+
function memoryLine(m, verbosity = "short", rel, homeWs) {
|
|
79
|
+
// origin marker for cross-workspace hits: [W:<ws>] tells the model the memory
|
|
80
|
+
// came from a different workspace pool (issue #5).
|
|
81
|
+
const origin = homeWs !== void 0 && homeWs !== null && (m._origin ?? m.workspace) !== homeWs
|
|
82
|
+
? ` [W:${basename(m._origin ?? m.workspace) || String(m._origin ?? m.workspace)}]`
|
|
83
|
+
: "";
|
|
78
84
|
const tags = m.entity !== null ? ` · entity:${m.entity}` : "";
|
|
79
85
|
const relTail = rel
|
|
80
86
|
? `${rel.supersededBy.has(m.id) ? ` · superseded by ${rel.supersededBy.get(m.id).id.slice(0, 8)}` : ""}${rel.contradictedBy.has(m.id) ? ` · contradicted by ${rel.contradictedBy.get(m.id).map((c) => c.id.slice(0, 8)).join(",")}` : ""}`
|
|
@@ -83,11 +89,13 @@ function memoryLine(m, verbosity = "short", rel) {
|
|
|
83
89
|
const access = m.lastAccessAt == null ? "never-accessed" : `lastAccess ${fmtDate(m.lastAccessAt)}`;
|
|
84
90
|
const pathLine = m.filePath ? ` · path ${m.filePath}` : "";
|
|
85
91
|
const relLine = relTail.length > 0 ? relTail.trim() : "";
|
|
86
|
-
|
|
92
|
+
const sourceLine = m.source ? ` · source ${m.source}` : "";
|
|
93
|
+
const condLine = m.conditions ? ` · conditions ${m.conditions}` : "";
|
|
94
|
+
return [`# ${m.id} [${m.kind}]${origin}`, escapeLt(m.text), `tags: ${(m.tags ?? []).join(", ")}${tags}`, `signal ${m.signal} · hits ${m.hits ?? 0} · ${fmtDate(m.createdAt)} · session ${m.sessionId}#${m.seq} · ${access}${pathLine}${relLine ? ` · ${relLine}` : ""}${sourceLine}${condLine}`, ""].join("\n");
|
|
87
95
|
}
|
|
88
96
|
const date = fmtDate(m.createdAt ?? m.updatedAt);
|
|
89
97
|
const path = m.filePath ? ` · ${truncate(m.filePath, 48)}` : "";
|
|
90
|
-
return `- ${m.id.slice(0, 8)} [${m.kind}] ${date} ${escapeLt(truncate(m.text, 200))}${tags}${m.hits > 0 ? ` · ×${m.hits}` : ""}${path}${relTail}`;
|
|
98
|
+
return `- ${m.id.slice(0, 8)} [${m.kind}]${origin} ${date} ${escapeLt(truncate(m.text, 200))}${tags}${m.hits > 0 ? ` · ×${m.hits}` : ""}${path}${relTail}`;
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
/** Stable kind display order for grouped recall output (matches MEMORY_KINDS). */
|
|
@@ -100,7 +108,7 @@ const MEMORY_KIND_ORDER = ["decision", "error", "procedure", "fact", "insight",
|
|
|
100
108
|
* Within a group the original (relevance) order is preserved; unknown kinds
|
|
101
109
|
* (future-proof) trail the fixed order. Pure, deterministic.
|
|
102
110
|
*/
|
|
103
|
-
function groupKindLines(items, rel) {
|
|
111
|
+
function groupKindLines(items, rel, homeWs) {
|
|
104
112
|
const buckets = new Map();
|
|
105
113
|
for (const m of items) {
|
|
106
114
|
const kind = typeof m.kind === "string" && m.kind.length > 0 ? m.kind : "unknown";
|
|
@@ -110,7 +118,7 @@ function groupKindLines(items, rel) {
|
|
|
110
118
|
const lines = [];
|
|
111
119
|
const emit = (kind, group) => {
|
|
112
120
|
lines.push(`## ${kind} (${group.length})`);
|
|
113
|
-
for (const m of group) lines.push(memoryLine(m, "short", rel));
|
|
121
|
+
for (const m of group) lines.push(memoryLine(m, "short", rel, homeWs));
|
|
114
122
|
};
|
|
115
123
|
for (const kind of MEMORY_KIND_ORDER) if (buckets.has(kind)) emit(kind, buckets.get(kind));
|
|
116
124
|
for (const [kind, group] of buckets) if (!MEMORY_KIND_ORDER.includes(kind)) emit(kind, group);
|
|
@@ -136,12 +144,14 @@ export function registerTools(ctx, service) {
|
|
|
136
144
|
file_path: { type: "string", description: "Optional absolute path this memory anchors to (e.g. the failing file)" },
|
|
137
145
|
supersedes: { type: "string", description: "Optional memory id this new statement replaces (old row stays, demoted in recall)" },
|
|
138
146
|
contradicts: { type: "string", description: "Optional memory id this new statement directly contradicts (both stay, flagged)" },
|
|
147
|
+
source: { type: "string", description: "Optional provenance source class: manual | session | report | upstream | handoff" },
|
|
148
|
+
conditions: { type: "string", description: "Optional trigger/condition under which this memory should be recalled" },
|
|
139
149
|
},
|
|
140
150
|
required: ["text"],
|
|
141
151
|
},
|
|
142
152
|
output: TEXT_OUTPUT,
|
|
143
153
|
execute: async (raw, exec) => {
|
|
144
|
-
const args = parse(z.object({ text: z.string().min(1).max(2000), kind: z.enum(KINDS).optional(), tags: z.array(z.string()).optional(), entity: z.string().optional(), file_path: z.string().optional(), supersedes: z.string().optional(), contradicts: z.string().optional() }), raw, "engram_store");
|
|
154
|
+
const args = parse(z.object({ text: z.string().min(1).max(2000), kind: z.enum(KINDS).optional(), tags: z.array(z.string()).optional(), entity: z.string().optional(), file_path: z.string().optional(), supersedes: z.string().optional(), contradicts: z.string().optional(), source: z.string().optional(), conditions: z.string().optional() }), raw, "engram_store");
|
|
145
155
|
const caller = callerOf(exec);
|
|
146
156
|
const domain = await requireDomain(service);
|
|
147
157
|
await domain.expireSweep(caller.cwd);
|
|
@@ -155,6 +165,8 @@ export function registerTools(ctx, service) {
|
|
|
155
165
|
filePath: args.file_path ?? null,
|
|
156
166
|
supersedes: args.supersedes ?? null,
|
|
157
167
|
contradicts: args.contradicts ?? null,
|
|
168
|
+
source: args.source ?? null,
|
|
169
|
+
conditions: args.conditions ?? null,
|
|
158
170
|
sessionId: caller.sessionId,
|
|
159
171
|
seq: caller.seq,
|
|
160
172
|
signal: 0.6,
|
|
@@ -180,12 +192,13 @@ export function registerTools(ctx, service) {
|
|
|
180
192
|
max_per_session: { type: "integer", minimum: 1, maximum: 10, description: "Max memories kept per sessionId (default 3; sessions share the cap)" },
|
|
181
193
|
group_by_kind: { type: "boolean", description: "Group recall hits by kind into sections (default true); false = one flat list" },
|
|
182
194
|
search_sessions: { type: "boolean", description: "Also FTS search past sessions (requires the session-query index)" },
|
|
195
|
+
scope: { type: "string", enum: ["workspace", "global", "auto"], description: "Recall scope override: workspace=strict current-workspace only (default); global=include other workspaces' pools when the local pool is weak/empty (marks [W:<ws>] origin); auto=follow the recallScope config (recommended)" },
|
|
183
196
|
},
|
|
184
197
|
},
|
|
185
198
|
output: TEXT_OUTPUT,
|
|
186
199
|
timeoutMs: 30000,
|
|
187
200
|
execute: async (raw, exec) => {
|
|
188
|
-
const args = parse(z.object({ query: z.string().optional(), entity: z.string().optional(), limit: z.number().int().min(1).max(50).optional(), max_per_session: z.number().int().min(1).max(10).optional(), group_by_kind: z.boolean().optional(), search_sessions: z.boolean().optional() }), raw, "engram_recall");
|
|
201
|
+
const args = parse(z.object({ query: z.string().optional(), entity: z.string().optional(), limit: z.number().int().min(1).max(50).optional(), max_per_session: z.number().int().min(1).max(10).optional(), group_by_kind: z.boolean().optional(), search_sessions: z.boolean().optional(), scope: z.enum(["workspace", "global", "auto"]).optional() }), raw, "engram_recall");
|
|
189
202
|
const caller = callerOf(exec);
|
|
190
203
|
const domain = await requireDomain(service);
|
|
191
204
|
let lines = [];
|
|
@@ -194,6 +207,11 @@ export function registerTools(ctx, service) {
|
|
|
194
207
|
// reuses DSH's own cross-session index (ctx.sessionQuery) instead of
|
|
195
208
|
// a self-built index — this keeps proactive coverage without new infra.
|
|
196
209
|
let zeroHits = false;
|
|
210
|
+
// recallScope resolution: explicit scope wins; else the config knob;
|
|
211
|
+
// default "workspace" preserves the strict per-workspace isolation.
|
|
212
|
+
const cfgScope = service.config.recallScope ?? "workspace";
|
|
213
|
+
const effectiveScope = args.scope === void 0 ? cfgScope : args.scope === "auto" ? cfgScope : args.scope;
|
|
214
|
+
const globalEnabled = effectiveScope === "global";
|
|
197
215
|
if (args.entity !== void 0 && args.entity.trim().length > 0) {
|
|
198
216
|
items = domain.timeline(caller.cwd, args.entity, args.limit ?? 30);
|
|
199
217
|
if (items.length === 0) zeroHits = true;
|
|
@@ -202,11 +220,25 @@ export function registerTools(ctx, service) {
|
|
|
202
220
|
await Promise.all(items.map((m) => domain.touchMemory(caller.cwd, m.id).catch(() => null)));
|
|
203
221
|
} else if (args.query !== void 0) {
|
|
204
222
|
const maxPerSession = args.max_per_session ?? service.config.maxRecallPerSession ?? 3;
|
|
205
|
-
|
|
223
|
+
const limit = args.limit ?? 12;
|
|
224
|
+
items = domain.recall(caller.cwd, args.query, limit, { maxPerSession });
|
|
225
|
+
let cross = false;
|
|
226
|
+
// Opt-in global fallback (issue #5): only when the local pool is
|
|
227
|
+
// weak/no-hit AND the user enabled recallScope=global or passed
|
|
228
|
+
// scope=global. Cross-workspace hits carry a [W:<ws>] origin marker.
|
|
229
|
+
if (globalEnabled && items.length === 0) {
|
|
230
|
+
const extra = domain.recallGlobal(args.query, limit, { maxPerSession, excludeWorkspace: caller.cwd });
|
|
231
|
+
if (extra.length > 0) {
|
|
232
|
+
items = extra;
|
|
233
|
+
cross = true;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
206
236
|
if (items.length === 0) zeroHits = true;
|
|
207
|
-
|
|
237
|
+
const scopeTag = cross ? " · cross-workspace" : "";
|
|
238
|
+
lines.push(zeroHits ? `no active memories match ${JSON.stringify(args.query)} in this workspace` + (globalEnabled ? " (global fallback also empty)" : "") : `# recall: ${JSON.stringify(args.query)} (${items.length}) · ≤${maxPerSession}/session${scopeTag}`);
|
|
208
239
|
const rel = domain.memoryRelations(caller.cwd);
|
|
209
|
-
|
|
240
|
+
const homeWs = caller.cwd;
|
|
241
|
+
lines.push(...(args.group_by_kind === false ? items.map((m) => memoryLine(m, "short", rel, homeWs)) : groupKindLines(items, rel, homeWs)));
|
|
210
242
|
await Promise.all(items.map((m) => domain.touchMemory(caller.cwd, m.id).catch(() => null)));
|
|
211
243
|
} else {
|
|
212
244
|
items = domain.listMemories(caller.cwd, args.limit ?? 12);
|
|
@@ -228,7 +260,7 @@ export function registerTools(ctx, service) {
|
|
|
228
260
|
const wantSessions = args.search_sessions === true || (zeroHits && service.config.sessionSearch !== false);
|
|
229
261
|
if (wantSessions) {
|
|
230
262
|
const q = args.query ?? args.entity ?? "";
|
|
231
|
-
const extra = await searchSessions(service, caller, q, exec.signal);
|
|
263
|
+
const extra = await searchSessions(service, caller, q, exec.signal, globalEnabled);
|
|
232
264
|
if (extra.length > 0) {
|
|
233
265
|
lines.push("", "# past sessions (FTS fallback)", ...extra);
|
|
234
266
|
} else if (hasCJK(q)) {
|
|
@@ -750,15 +782,17 @@ function collectNeighborhood(domain, workspace, items) {
|
|
|
750
782
|
return lines;
|
|
751
783
|
}
|
|
752
784
|
|
|
753
|
-
/** FTS over past sessions via ctx.sessionQuery (mounted by the web profile).
|
|
754
|
-
|
|
785
|
+
/** FTS over past sessions via ctx.sessionQuery (mounted by the web profile).
|
|
786
|
+
* `global` (recallScope=global) drops the cwd filter so past-session hits from
|
|
787
|
+
* other workspaces are reachable too — matching the cross-workspace scope. */
|
|
788
|
+
async function searchSessions(service, caller, query, signal, global) {
|
|
755
789
|
const sq = service.sessionQuery;
|
|
756
790
|
if (sq === void 0 || query.trim().length === 0) return [];
|
|
757
791
|
try {
|
|
758
792
|
const { items = [] } = await sq.searchSessions(
|
|
759
793
|
{
|
|
760
794
|
query: query.trim(),
|
|
761
|
-
sessionFilters: [{ kind: "cwd", values: [caller.cwd] }],
|
|
795
|
+
sessionFilters: global ? [] : [{ kind: "cwd", values: [caller.cwd] }],
|
|
762
796
|
limit: 5,
|
|
763
797
|
},
|
|
764
798
|
signal !== void 0 ? { signal } : undefined,
|
package/lib/usage.js
CHANGED
|
@@ -11,13 +11,19 @@
|
|
|
11
11
|
* with a short TTL cache. No tool call pays a write anymore (pure GUI-side
|
|
12
12
|
* observability), and the log stream is the single source of truth.
|
|
13
13
|
*
|
|
14
|
-
* Zero
|
|
14
|
+
* Zero external dependency: Node >=22.19 provides the zstd codec in
|
|
15
|
+
* `node:zlib`. Session logs are concatenated zstd frames (each append is an
|
|
16
|
+
* independent frame), so we scan frame boundaries and reuse one decoder
|
|
17
|
+
* handle across all frames instead of relying on a system `zstd` CLI (which
|
|
18
|
+
* Windows hosts commonly lack) or the one-shot `zstdDecompressSync`, which
|
|
19
|
+
* only decodes the FIRST frame of a multi-frame container.
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
|
-
import { execFileSync } from "node:child_process";
|
|
18
22
|
import fs from "node:fs";
|
|
19
23
|
import os from "node:os";
|
|
20
24
|
import path from "node:path";
|
|
25
|
+
import { constants as bufferConstants } from "node:buffer";
|
|
26
|
+
import { createZstdDecompress, zstdDecompressSync } from "node:zlib";
|
|
21
27
|
|
|
22
28
|
import { workspaceKey as wk } from "./util.js";
|
|
23
29
|
|
|
@@ -28,8 +34,6 @@ export function dayKey(now = Date.now()) {
|
|
|
28
34
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
|
-
/** A big session log far exceeds Node's 1MB default exec buffer. */
|
|
32
|
-
const MAX_BYTES = 300 * 1024 * 1024;
|
|
33
37
|
const CACHE_TTL_MS = 60_000;
|
|
34
38
|
const DAY_MS = 86_400_000;
|
|
35
39
|
|
|
@@ -40,9 +44,146 @@ export function sessionsRoot() {
|
|
|
40
44
|
return path.join(os.homedir(), ".dsh", "sessions");
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
/** Byte range of one structurally complete zstd frame in a concatenated stream. */
|
|
48
|
+
function frameRanges(buffer) {
|
|
49
|
+
const frames = [];
|
|
50
|
+
let offset = 0;
|
|
51
|
+
while (offset < buffer.length) {
|
|
52
|
+
const start = offset;
|
|
53
|
+
if (buffer.length - offset < 4) break;
|
|
54
|
+
if (buffer.readUInt32LE(offset) !== 0xfd2fb528) break; // ZSTD_MAGIC
|
|
55
|
+
offset += 4;
|
|
56
|
+
if (offset === buffer.length) break;
|
|
57
|
+
const descriptor = buffer.readUInt8(offset);
|
|
58
|
+
offset += 1;
|
|
59
|
+
if ((descriptor & 0x18) !== 0) break; // reserved frame-header bit
|
|
60
|
+
const contentSizeFlag = descriptor >>> 6;
|
|
61
|
+
const singleSegment = (descriptor & 0x20) !== 0;
|
|
62
|
+
const checksum = (descriptor & 0x04) !== 0;
|
|
63
|
+
const dictionaryFlag = descriptor & 0x03;
|
|
64
|
+
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
|
|
65
|
+
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag;
|
|
66
|
+
const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
|
|
67
|
+
if (buffer.length - offset < remainingHeaderBytes) break;
|
|
68
|
+
offset += remainingHeaderBytes;
|
|
69
|
+
for (;;) {
|
|
70
|
+
if (buffer.length - offset < 3) return frames;
|
|
71
|
+
const blockHeader = buffer.readUIntLE(offset, 3);
|
|
72
|
+
offset += 3;
|
|
73
|
+
const lastBlock = (blockHeader & 1) !== 0;
|
|
74
|
+
const blockType = (blockHeader >>> 1) & 0x03;
|
|
75
|
+
const blockSize = blockHeader >>> 3;
|
|
76
|
+
if (blockType === 0x03) return frames; // reserved block type
|
|
77
|
+
const payloadBytes = blockType === 0x01 ? 1 : blockSize;
|
|
78
|
+
if (buffer.length - offset < payloadBytes) return frames;
|
|
79
|
+
offset += payloadBytes;
|
|
80
|
+
if (lastBlock) break;
|
|
81
|
+
}
|
|
82
|
+
if (checksum) {
|
|
83
|
+
if (buffer.length - offset < 4) return frames;
|
|
84
|
+
offset += 4;
|
|
85
|
+
}
|
|
86
|
+
frames.push({ start, end: offset });
|
|
87
|
+
}
|
|
88
|
+
return frames;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Node-private synchronous multi-frame zstd decoder. Node exposes synchronous
|
|
93
|
+
* decoding only as a one-shot API that stops at the first frame of a
|
|
94
|
+
* concatenated container (session logs append a fresh frame per batch).
|
|
95
|
+
* Reusing one stream's `_handle.writeSync` across frames is the same
|
|
96
|
+
* optimization the harness's own `NodePrivateZstdFrameDecoder` uses; when the
|
|
97
|
+
* running Node release lacks that private shape we fall back to per-frame
|
|
98
|
+
* `zstdDecompressSync` (correct, slower — fine for the occasional /stats poll).
|
|
99
|
+
*/
|
|
100
|
+
function createFrameDecoder() {
|
|
101
|
+
const stream = createZstdDecompress({ chunkSize: 1024 * 1024 });
|
|
102
|
+
const handle = stream._handle;
|
|
103
|
+
const errorKey = Reflect.ownKeys(stream).find(
|
|
104
|
+
(key) => typeof key === "symbol" && key.description === "kError",
|
|
105
|
+
);
|
|
106
|
+
if (
|
|
107
|
+
typeof handle !== "object" || handle === null ||
|
|
108
|
+
typeof handle?.writeSync !== "function" ||
|
|
109
|
+
!(stream._writeState instanceof Uint32Array) ||
|
|
110
|
+
stream._writeState.length < 2 ||
|
|
111
|
+
typeof stream._defaultFlushFlag !== "number" ||
|
|
112
|
+
errorKey === void 0 ||
|
|
113
|
+
stream[errorKey] !== null
|
|
114
|
+
) {
|
|
115
|
+
stream.close();
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const output = Buffer.allocUnsafe(1024 * 1024);
|
|
119
|
+
let decoderError;
|
|
120
|
+
stream.on("error", (error) => { decoderError ??= error; });
|
|
121
|
+
return {
|
|
122
|
+
decode(frame) {
|
|
123
|
+
if (decoderError !== void 0) throw decoderError;
|
|
124
|
+
let inputOffset = 0;
|
|
125
|
+
let inputRemaining = frame.length;
|
|
126
|
+
let outputBytes = 0;
|
|
127
|
+
const fullChunks = [];
|
|
128
|
+
for (;;) {
|
|
129
|
+
handle.writeSync(
|
|
130
|
+
stream._defaultFlushFlag,
|
|
131
|
+
frame,
|
|
132
|
+
inputOffset,
|
|
133
|
+
inputRemaining,
|
|
134
|
+
output,
|
|
135
|
+
0,
|
|
136
|
+
output.length,
|
|
137
|
+
);
|
|
138
|
+
if (decoderError !== void 0) throw decoderError;
|
|
139
|
+
const internalError = stream[errorKey];
|
|
140
|
+
if (internalError !== null) throw internalError;
|
|
141
|
+
const outputAfter = stream._writeState[0];
|
|
142
|
+
const inputAfter = stream._writeState[1];
|
|
143
|
+
const consumed = inputRemaining - inputAfter;
|
|
144
|
+
const produced = output.length - outputAfter;
|
|
145
|
+
if (produced > 0) {
|
|
146
|
+
outputBytes += produced;
|
|
147
|
+
if (outputBytes > bufferConstants.MAX_LENGTH) throw new Error("frame output too large");
|
|
148
|
+
}
|
|
149
|
+
if (outputAfter !== 0) {
|
|
150
|
+
if (inputAfter !== 0) throw new Error("decoder left trailing input");
|
|
151
|
+
const finalChunk = output.subarray(0, produced);
|
|
152
|
+
if (fullChunks.length === 0) return Buffer.from(finalChunk);
|
|
153
|
+
if (produced > 0) fullChunks.push(Buffer.from(finalChunk));
|
|
154
|
+
const onlyChunk = fullChunks[0];
|
|
155
|
+
return fullChunks.length === 1 ? onlyChunk : Buffer.concat(fullChunks, outputBytes);
|
|
156
|
+
}
|
|
157
|
+
fullChunks.push(Buffer.from(output));
|
|
158
|
+
inputOffset += consumed;
|
|
159
|
+
inputRemaining = inputAfter;
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
close() {
|
|
163
|
+
stream.close();
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
43
168
|
function decodeZstd(file) {
|
|
44
169
|
try {
|
|
45
|
-
|
|
170
|
+
const buffer = fs.readFileSync(file);
|
|
171
|
+
const frames = frameRanges(buffer);
|
|
172
|
+
if (frames.length === 0) return "";
|
|
173
|
+
const decoder = createFrameDecoder();
|
|
174
|
+
if (decoder !== null) {
|
|
175
|
+
try {
|
|
176
|
+
let out = "";
|
|
177
|
+
for (const frame of frames) out += decoder.decode(buffer.subarray(frame.start, frame.end)).toString("utf8");
|
|
178
|
+
return out;
|
|
179
|
+
} finally {
|
|
180
|
+
decoder.close();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Public fallback: one zstdDecompressSync per frame (correct, slower).
|
|
184
|
+
let out = "";
|
|
185
|
+
for (const frame of frames) out += zstdDecompressSync(buffer.subarray(frame.start, frame.end)).toString("utf8");
|
|
186
|
+
return out;
|
|
46
187
|
} catch {
|
|
47
188
|
return "";
|
|
48
189
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-engram",
|
|
3
3
|
"description": "Minimalist long-term memory for DeepSeek Harness, distilling symbolic-index + pi-esr ideas: zero-LLM auto-capture, a symbolic [ENGRAM] index with progressive disclosure, and an ESR-lite evidence-closure protocol (esr_task / esr_node / esr_close / esr_link). Real agent-behaviour telemetry (usage rollup + /stats) and a deterministic offline recall benchmark (npm run eval). Hot path is model-free; storage on ctx.storageDomain; web memory viewer, observability panel and config card through DSH's native settings slots.",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.6",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|