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/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
- return [`# ${m.id} [${m.kind}]`, 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}` : ""}`, ""].join("\n");
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
- items = domain.recall(caller.cwd, args.query, args.limit ?? 12, { maxPerSession });
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
- lines.push(zeroHits ? `no active memories match ${JSON.stringify(args.query)} in this workspace` : `# recall: ${JSON.stringify(args.query)} (${items.length}) · ≤${maxPerSession}/session`);
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
- lines.push(...(args.group_by_kind === false ? items.map((m) => memoryLine(m, "short", rel)) : groupKindLines(items, rel)));
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
- async function searchSessions(service, caller, query, signal) {
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-dependency: the host already carries the `zstd` CLI.
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
- return execFileSync("zstd", ["-dc", file], { maxBuffer: MAX_BYTES }).toString("utf8");
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.5",
4
+ "version": "0.3.6",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",