dsh-engram 0.4.0 → 0.5.1

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/api.js CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { uuid, shortId } from "./util.js";
14
14
  import { collectToolCounts, collectUsageStats } from "./usage.js";
15
- import { SETTINGS_NS } from "./settings.js";
15
+ import { SETTINGS_NS, WIRE_SETTINGS_NS, LEGACY_SETTINGS_NS } from "./settings.js";
16
16
 
17
17
  /** Route family prefix (kept in sync with the client half). */
18
18
  export const API_PREFIX = "/api/dsh-engram";
@@ -160,8 +160,9 @@ export function makeEngramRoutes(service) {
160
160
  writeJson(res, 200, {
161
161
  ...summary,
162
162
  indexes,
163
- captures: service.captureStats ?? { total: 0, git: 0, file: 0, error: 0 },
163
+ captures: service.captureStats ?? { total: 0, git: 0, file: 0, error: 0, preference: 0 },
164
164
  gc: service.gcStats ?? { lastRun: 0, archivedMemories: 0, archivedTasks: 0, removedLinks: 0 },
165
+ activation: service.packTelemetry ? service.packTelemetry() : { boundaryPrune: null, recall: null },
165
166
  compaction: service.compactionStatus ?? { host: "unknown", web: { action: "unknown", presets: [] } },
166
167
  config: service.config ?? {},
167
168
  anonymous: {
@@ -512,6 +513,10 @@ export function makeEngramRoutes(service) {
512
513
  recallHitsPerQuery: queries > 0 ? +(hitsTotal / queries).toFixed(3) : null,
513
514
  detailFollowRate: queries > 0 ? +(detailFollows / queries).toFixed(4) : null,
514
515
  },
516
+ // SoL-Pi activation telemetry: the durable BoundaryPrune / ResultPack
517
+ // activation record (fires actually landed on this profile) + the
518
+ // [RECALL] injection-funnel leg (blocks actually injected into sessions).
519
+ activation: service.packTelemetry ? service.packTelemetry() : { boundaryPrune: null, recall: null },
515
520
  });
516
521
  };
517
522
 
@@ -561,8 +566,13 @@ export function makeEngramRoutes(service) {
561
566
  // Normalize both onto the HTTP contract {namespaces:[...]} the client uses.
562
567
  const payload = settings.describe();
563
568
  const namespaces = Array.isArray(payload) ? payload : (payload?.namespaces ?? []);
564
- const view = namespaces.find((n) => n && n.ns === SETTINGS_NS);
565
- writeJson(res, 200, view ? { namespaces: [view] } : { namespaces: [] });
569
+ // The seam carries the ACTIVE namespace — entry id `engram` on DSH
570
+ // ≥0.1.7 / 0.2.x, legacy `dsh-engram` on ≤0.1.5. The view is re-labelled
571
+ // onto the stable WIRE_SETTINGS_NS the client card filters on, so the
572
+ // shipped client works unchanged across generations.
573
+ const activeNs = settings.ns ?? SETTINGS_NS;
574
+ const view = namespaces.find((n) => n && (n.ns === activeNs || n.ns === LEGACY_SETTINGS_NS));
575
+ writeJson(res, 200, view ? { namespaces: [{ ...view, ns: WIRE_SETTINGS_NS }] } : { namespaces: [] });
566
576
  } catch (error) {
567
577
  writeError(res, 500, String(error?.message ?? error));
568
578
  }
@@ -586,10 +596,13 @@ export function makeEngramRoutes(service) {
586
596
  return writeError(res, 400, "body must provide ops (array of {op,path[,value]}) or patch (object)");
587
597
  }
588
598
  try {
599
+ // Drive the seam's ACTIVE namespace (entry id `engram` on DSH ≥0.1.7 /
600
+ // 0.2.x, legacy `dsh-engram` on ≤0.1.5), never the wire label.
601
+ const activeNs = settings.ns ?? SETTINGS_NS;
589
602
  if (Array.isArray(ops)) {
590
- await settings.mutate(SETTINGS_NS, ops, expectedRevision);
603
+ await settings.mutate(activeNs, ops, expectedRevision);
591
604
  } else {
592
- await settings.update(SETTINGS_NS, patch, expectedRevision);
605
+ await settings.update(activeNs, patch, expectedRevision);
593
606
  }
594
607
  writeJson(res, 200, { ok: true });
595
608
  } catch (error) {
@@ -40,6 +40,7 @@ import fs from "node:fs";
40
40
  import os from "node:os";
41
41
  import path from "node:path";
42
42
 
43
+ import { sessionLogFile } from "./usage.js";
43
44
  import { workspaceKey as wk } from "./util.js";
44
45
 
45
46
  /** Fixed fallback for the repayment gate when no history exists yet. */
@@ -93,7 +94,7 @@ export function workspaceHistory(root = path.join(os.homedir(), ".dsh", "session
93
94
  let sessions = 0;
94
95
  const bdir = path.join(root, bucket);
95
96
  for (const sid of fs.readdirSync(bdir)) {
96
- const file = path.join(bdir, sid, "session.jsonl.zstd");
97
+ const file = sessionLogFile(path.join(bdir, sid));
97
98
  try {
98
99
  const st = fs.statSync(file);
99
100
  if (st.size > 0) sessions += 1;
@@ -128,7 +129,7 @@ export function expectedSessionRequests(requestsSoFar, history, workspace, floor
128
129
  }
129
130
 
130
131
  /** Install the boundary-prune listener. Returns a disposer. */
131
- export function installBoundaryPrune(ctx, config, log) {
132
+ export function installBoundaryPrune(ctx, config, log, hooks = {}, opts = {}) {
132
133
  const enabled = config?.boundaryPrune === true;
133
134
  if (!enabled) return () => {};
134
135
  const pruner = ctx.get?.("toolResultPruner");
@@ -139,7 +140,44 @@ export function installBoundaryPrune(ctx, config, log) {
139
140
  const minRemaining = Number.isFinite(config?.boundaryPruneMinRemaining)
140
141
  ? config.boundaryPruneMinRemaining
141
142
  : DEFAULT_MIN_REMAINING;
142
- const history = workspaceHistory();
143
+ // History is injectable for tests; production reads the real session corpus.
144
+ const history = opts?.history ?? workspaceHistory();
145
+
146
+ // Optional threshold override (ResultPack L0, T2048 landing): the host pruner
147
+ // ships a fixed threshold (8192) that the plugin cannot change in place. When
148
+ // `boundaryPruneThresholdChars` is set, we instantiate our own pruner at that
149
+ // threshold — same deterministic head/middle/tail engine, lower budget — via
150
+ // the exported `ToolResultPruner` prototype (bypassing the cordis Service
151
+ // constructor, which needs a full context; the instance only uses its own
152
+ // `config` + `ctx.tokenMeter`). Falls back to the host pruner when the package
153
+ // or the token meter is unavailable. `boundaryPruneHeadChars` /
154
+ // `boundaryPruneTailChars` tune the retained edges (defaults stay at the
155
+ // host's 4096/1024).
156
+ let activePruner = pruner;
157
+ const thresholdOverride = Number.isFinite(config?.boundaryPruneThresholdChars)
158
+ ? Math.trunc(config.boundaryPruneThresholdChars)
159
+ : null;
160
+ if (thresholdOverride !== null && thresholdOverride > 0) {
161
+ activePruner = makeThresholdPruner(pruner, {
162
+ thresholdChars: thresholdOverride,
163
+ headChars: Number.isFinite(config?.boundaryPruneHeadChars) && config.boundaryPruneHeadChars > 0
164
+ ? Math.trunc(config.boundaryPruneHeadChars)
165
+ : 4096,
166
+ tailChars: Number.isFinite(config?.boundaryPruneTailChars) && config.boundaryPruneTailChars > 0
167
+ ? Math.trunc(config.boundaryPruneTailChars)
168
+ : 1024,
169
+ });
170
+ if (activePruner === null) {
171
+ log?.warn?.(
172
+ `engram boundary-prune: threshold override ${thresholdOverride} unavailable (pruner package/tokenMeter) — using host pruner ${pruner.config?.thresholdChars ?? "default"}`,
173
+ );
174
+ activePruner = pruner;
175
+ } else {
176
+ log?.info?.(
177
+ `engram boundary-prune: using threshold ${activePruner.config?.thresholdChars} (head ${activePruner.config?.headChars} / tail ${activePruner.config?.tailChars})`,
178
+ );
179
+ }
180
+ }
143
181
 
144
182
  /** Per-session boundary state (todo high-water mark, request count, total prunes). */
145
183
  const stateBySession = new Map();
@@ -150,12 +188,32 @@ export function installBoundaryPrune(ctx, config, log) {
150
188
  if (typeof sid !== "string" || sid.length === 0) return;
151
189
  let state = stateBySession.get(sid);
152
190
  if (!state) {
153
- state = { todoCompleted: 0, requests: 0, prunes: 0, prunedResults: 0 };
191
+ state = { todoCompleted: 0, requests: 0, prunes: 0, prunedResults: 0, lastFireRequest: void 0 };
154
192
  stateBySession.set(sid, state);
155
193
  }
156
194
  const type = event?.type;
157
195
  if (type === "assistant/message") {
158
196
  state.requests += 1;
197
+ // Request-count boundary (SoL-Pi "step idle", default OFF): sessions
198
+ // with no todo progress / no goal never fire the event boundaries (L0
199
+ // corpus: ~62% of sessions), leaving their over-threshold replay
200
+ // exposure permanently untouched. When `boundaryPruneIdleCadence` is
201
+ // set, the counter itself is a boundary source: fire at most once per
202
+ // cadence requests, still under the repayment gate (so cadence ≠
203
+ // aggressive — the gate keeps per-session reuse healthy). The implied
204
+ // boundary kind is `step` so telemetry can attribute it.
205
+ const cadence = config?.boundaryPruneIdleCadence;
206
+ if (Number.isFinite(cadence) && cadence > 0 && state.requests % Math.trunc(cadence) === 0) {
207
+ const sinceLastPrune = state.lastFireRequest === void 0 ? state.requests : state.requests - state.lastFireRequest;
208
+ if (sinceLastPrune >= Math.trunc(cadence)) {
209
+ const workspace = wk(session?.header?.cwd);
210
+ const expectedTotal = expectedSessionRequests(state.requests, history, workspace, minRemaining);
211
+ if (state.requests + minRemaining <= expectedTotal) {
212
+ // reuse the same fire path via a synthetic boundary
213
+ return fireBoundary(session, { kind: "step" }, state, expectedTotal);
214
+ }
215
+ }
216
+ }
159
217
  return;
160
218
  }
161
219
  const boundary = isBoundary(event, state);
@@ -168,21 +226,43 @@ export function installBoundaryPrune(ctx, config, log) {
168
226
  const workspace = wk(session?.header?.cwd);
169
227
  const expectedTotal = expectedSessionRequests(state.requests, history, workspace, minRemaining);
170
228
  if (state.requests + minRemaining > expectedTotal) return;
171
-
172
- const result = pruner.pruneSession(session);
173
- const prunedNow = result?.pruned?.length ?? 0;
174
- if (prunedNow > 0) {
175
- state.prunes += 1;
176
- state.prunedResults += prunedNow;
177
- log?.info?.(
178
- `engram boundary-prune (${boundary.kind} @${state.requests} reqs): pruned ${prunedNow} results, saved ${result.charsRemoved} chars`,
179
- );
180
- }
229
+ return fireBoundary(session, boundary, state, expectedTotal);
181
230
  } catch (error) {
182
231
  // fully contained — a prune bug must never disturb the session pipeline
183
232
  log?.warn?.(`engram boundary-prune threw: ${String(error)}`);
184
233
  }
185
234
  };
235
+
236
+ /** Shared fire path for every boundary source (event + step-cadence). */
237
+ const fireBoundary = (session, boundary, state, expectedTotal) => {
238
+ const result = activePruner.pruneSession(session);
239
+ const prunedNow = result?.pruned?.length ?? 0;
240
+ if (prunedNow > 0) {
241
+ state.prunes += 1;
242
+ state.prunedResults += prunedNow;
243
+ state.lastFireRequest = state.requests;
244
+ log?.info?.(
245
+ `engram boundary-prune (${boundary.kind} @${state.requests} reqs): pruned ${prunedNow} results, saved ${result.charsRemoved} chars`,
246
+ );
247
+ // L2 hook (ResultPack): archiving + handle tail is wired by index.js
248
+ // when `resultPack` is enabled. Contained there — never throws here.
249
+ // Attach the live pruner budgets so the archiver extracts the exact
250
+ // removed span (archives must match what the surface now displays).
251
+ try {
252
+ const budgets = {
253
+ headChars: activePruner.config?.headChars ?? 4096,
254
+ tailChars: activePruner.config?.tailChars ?? 1024,
255
+ };
256
+ for (const item of result?.pruned ?? []) {
257
+ if (item && typeof item === "object") Object.assign(item, budgets);
258
+ }
259
+ hooks?.onPruned?.(session, result, boundary);
260
+ } catch (error) {
261
+ log?.warn?.(`engram boundary-prune: L2 hook threw: ${String(error)}`);
262
+ }
263
+ }
264
+ void expectedTotal;
265
+ };
186
266
  const disposeListener = ctx.on("session/event", onEvent);
187
267
  const disposeSink = ctx.on("session/disposed", (session) => {
188
268
  stateBySession.delete(session?.id);
@@ -192,3 +272,24 @@ export function installBoundaryPrune(ctx, config, log) {
192
272
  disposeSink?.();
193
273
  };
194
274
  }
275
+
276
+ /**
277
+ * Build a pruner at an override threshold by prototyping off the injected
278
+ * host service instance.
279
+ *
280
+ * The host `toolResultPruner` ships a fixed budget (8192/4096/1024) that
281
+ * cannot be mutated in place. DSH's pruner is a plain prototype-owning class
282
+ * whose methods only read `this.config` and `this.ctx.tokenMeter`, so we
283
+ * create a child object inheriting the host instance (methods + ctx shared,
284
+ * config overridden on the child). Zero LLM, deterministic, contained — the
285
+ * host instance and its config stay untouched.
286
+ */
287
+ function makeThresholdPruner(pruner, cfg) {
288
+ try {
289
+ const clone = Object.create(pruner);
290
+ clone.config = cfg;
291
+ return clone;
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
package/lib/client.js CHANGED
@@ -153,6 +153,28 @@ var EngramApi = class {
153
153
  })
154
154
  );
155
155
  }
156
+ /**
157
+ * Data contract (issue #3) — dump the selected workspace, or every
158
+ * workspace when omitted, as a `dsh-engram/export` v1 payload. The same
159
+ * format `POST /import` restores, so the dump is a portable backup.
160
+ */
161
+ async exportData(workspace) {
162
+ return readJson(await fetch(`${API_PREFIX}/export${query({ workspace })}`));
163
+ }
164
+ /**
165
+ * Data contract (issue #3) — restore a dump. `merge` adds missing rows
166
+ * (idempotent), `replace` wipes the target workspace first (the host then
167
+ * requires `confirm: "restore"`). `dryRun` plans without writing.
168
+ */
169
+ async importData(body) {
170
+ return readJson(
171
+ await fetch(`${API_PREFIX}/import`, {
172
+ method: "POST",
173
+ headers: { "content-type": "application/json" },
174
+ body: JSON.stringify(body)
175
+ })
176
+ );
177
+ }
156
178
  /** Settings card transport — read the `dsh-engram` namespace through our
157
179
  * own HTTP API instead of the host connection/remote RPC. */
158
180
  async getSettings() {
@@ -2209,6 +2231,7 @@ function EngramTelemetry({ api, workspace }) {
2209
2231
  var import_react6 = require("react");
2210
2232
  var import_jsx_runtime6 = require("react/jsx-runtime");
2211
2233
  var KIND_COLORS = {
2234
+ preference: "#b45309",
2212
2235
  decision: "#2563eb",
2213
2236
  error: "#dc2626",
2214
2237
  procedure: "#7c3aed",
@@ -2218,6 +2241,7 @@ var KIND_COLORS = {
2218
2241
  task: "#4f46e5"
2219
2242
  };
2220
2243
  var KIND_LABEL = {
2244
+ preference: "\u504F\u597D",
2221
2245
  decision: "\u51B3\u5B9A",
2222
2246
  error: "\u9519\u8BEF",
2223
2247
  procedure: "\u6D41\u7A0B",
@@ -2252,7 +2276,7 @@ function gapsOf(t) {
2252
2276
  if (!t.memoryRefs || t.memoryRefs.length === 0) gaps.push("memory_ref");
2253
2277
  return gaps;
2254
2278
  }
2255
- function EngramDetail({ target, api, tasks, nodes, links, onClose, onNavigateMemory, onChanged }) {
2279
+ function EngramDetail({ target, api, tasks, nodes, links, memories = [], onClose, onNavigateMemory, onChanged }) {
2256
2280
  const { dark, vars } = useEngramTheme();
2257
2281
  const [gArt, setGArt] = (0, import_react6.useState)("");
2258
2282
  const [gEval, setGEval] = (0, import_react6.useState)("");
@@ -2272,6 +2296,15 @@ function EngramDetail({ target, api, tasks, nodes, links, onClose, onNavigateMem
2272
2296
  if (target.kind !== "node" || !node) return [];
2273
2297
  return links.filter((l) => l.source === node.id || l.target === node.id).map((l) => ({ l, outgoing: l.source === node.id }));
2274
2298
  }, [links, node, target.kind]);
2299
+ const memoryLinks = (0, import_react6.useMemo)(() => {
2300
+ if (target.kind !== "memory") return null;
2301
+ const m = target.memory;
2302
+ return {
2303
+ m,
2304
+ replacedBy: memories.filter((x) => x.status === "active" && x.supersedes === m.id),
2305
+ contradictedBy: memories.filter((x) => x.status === "active" && x.contradicts === m.id)
2306
+ };
2307
+ }, [target, memories]);
2275
2308
  const hb2 = {
2276
2309
  root: {
2277
2310
  ...vars,
@@ -2302,6 +2335,8 @@ function EngramDetail({ target, api, tasks, nodes, links, onClose, onNavigateMem
2302
2335
  badge: { fontSize: 10.5, fontWeight: 700, padding: "2px 8px", borderRadius: 999, color: "#fff" },
2303
2336
  tag: { fontSize: 11, padding: "1px 8px", borderRadius: 999, background: "var(--dsh-color-hover-bg, #f3f4f6)", color: "var(--dsh-color-muted, #6b7280)", border: "1px solid var(--dsh-color-border, #e5e7eb)" },
2304
2337
  chip: { fontSize: 11, padding: "2px 9px", borderRadius: 999, background: "rgba(37,99,235,.1)", color: "#2563eb", border: "1px solid rgba(37,99,235,.25)", cursor: "pointer" },
2338
+ chipSup: { fontSize: 11, padding: "2px 9px", borderRadius: 999, background: "rgba(124,58,237,.1)", color: "#7c3aed", border: "1px solid rgba(124,58,237,.25)", cursor: "pointer" },
2339
+ chipDanger: { fontSize: 11, padding: "2px 9px", borderRadius: 999, background: "rgba(220,38,38,.1)", color: "#dc2626", border: "1px solid rgba(220,38,38,.25)", cursor: "pointer" },
2305
2340
  input: { fontSize: 12, padding: "5px 8px", border: "1px solid var(--dsh-color-border, #d1d5db)", borderRadius: 8, background: "var(--dsh-color-surface, #ffffff)", color: "var(--dsh-color-muted-strong, #374151)", width: "100%", boxSizing: "border-box" },
2306
2341
  btn: { fontSize: 11.5, fontWeight: 600, padding: "5px 10px", borderRadius: 8, cursor: "pointer", border: "1px solid var(--dsh-color-border, #d1d5db)", background: "transparent", color: "var(--dsh-color-muted-strong, #374151)" },
2307
2342
  btnPrimary: { fontSize: 11.5, fontWeight: 700, padding: "5px 12px", borderRadius: 8, cursor: "pointer", border: "none", background: "#2563eb", color: "#fff" },
@@ -2440,6 +2475,29 @@ function EngramDetail({ target, api, tasks, nodes, links, onClose, onNavigateMem
2440
2475
  t
2441
2476
  ] }, t)) })
2442
2477
  ] }),
2478
+ memoryLinks && (memoryLinks.m.supersedes || memoryLinks.m.contradicts || memoryLinks.replacedBy.length > 0 || memoryLinks.contradictedBy.length > 0) && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { style: hb2.section, children: [
2479
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { style: hb2.k, children: "\u66FF\u6362 / \u51B2\u7A81" }),
2480
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { style: { display: "flex", gap: 5, flexWrap: "wrap", marginTop: 3 }, children: [
2481
+ memoryLinks.m.supersedes && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { style: hb2.chipSup, title: `\u672C\u6761\u66FF\u4EE3 ${memoryLinks.m.supersedes}\uFF08\u65E7\u884C\u4FDD\u7559\u3001\u53EC\u56DE\u964D\u7EA7\uFF09`, onClick: () => onNavigateMemory(memoryLinks.m.supersedes), children: [
2482
+ "\u66FF\u4EE3 #",
2483
+ memoryLinks.m.supersedes.slice(0, 8)
2484
+ ] }),
2485
+ memoryLinks.m.contradicts && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { style: hb2.chipDanger, title: `\u672C\u6761\u4E0E ${memoryLinks.m.contradicts} \u51B2\u7A81\uFF08\u4E24\u6761\u90FD\u4FDD\u7559\uFF09`, onClick: () => onNavigateMemory(memoryLinks.m.contradicts), children: [
2486
+ "\u51B2\u7A81 #",
2487
+ memoryLinks.m.contradicts.slice(0, 8)
2488
+ ] }),
2489
+ memoryLinks.replacedBy.map((x) => /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { style: hb2.chipSup, title: `\u5DF2\u88AB ${x.text.slice(0, 40)}\u2026 \u66FF\u4EE3`, onClick: () => onNavigateMemory(x.id), children: [
2490
+ "\u2190 \u88AB #",
2491
+ x.id.slice(0, 8),
2492
+ " \u66FF\u4EE3"
2493
+ ] }, x.id)),
2494
+ memoryLinks.contradictedBy.map((x) => /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { style: hb2.chipDanger, title: `${x.text.slice(0, 40)}\u2026 \u4E0E\u672C\u6761\u51B2\u7A81`, onClick: () => onNavigateMemory(x.id), children: [
2495
+ "\u21C4 #",
2496
+ x.id.slice(0, 8),
2497
+ " \u51B2\u7A81"
2498
+ ] }, x.id))
2499
+ ] })
2500
+ ] }),
2443
2501
  /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { style: hb2.section, children: [
2444
2502
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { style: hb2.k, children: "\u5143\u6570\u636E" }),
2445
2503
  /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { style: { display: "flex", flexDirection: "column", gap: 2, marginTop: 3, fontSize: 11.5, fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", color: "var(--dsh-color-muted, #6b7280)" }, children: [
@@ -2467,6 +2525,18 @@ function EngramDetail({ target, api, tasks, nodes, links, onClose, onNavigateMem
2467
2525
  " \xB7 updated ",
2468
2526
  fmtDate(target.memory.updatedAt)
2469
2527
  ] }),
2528
+ target.memory.source && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { children: [
2529
+ "source ",
2530
+ target.memory.source
2531
+ ] }),
2532
+ target.memory.conditions && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { children: [
2533
+ "conditions ",
2534
+ target.memory.conditions
2535
+ ] }),
2536
+ target.memory.filePath && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { children: [
2537
+ "file ",
2538
+ target.memory.filePath
2539
+ ] }),
2470
2540
  /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { children: [
2471
2541
  "TTL ",
2472
2542
  daysLeft(target.memory.expiresAt)
@@ -2567,6 +2637,7 @@ function EngramDetail({ target, api, tasks, nodes, links, onClose, onNavigateMem
2567
2637
  // client/src/EngramSection.tsx
2568
2638
  var import_jsx_runtime7 = require("react/jsx-runtime");
2569
2639
  var KIND_COLORS2 = {
2640
+ preference: "#b45309",
2570
2641
  decision: "#2563eb",
2571
2642
  error: "#dc2626",
2572
2643
  procedure: "#7c3aed",
@@ -2576,6 +2647,7 @@ var KIND_COLORS2 = {
2576
2647
  task: "#4f46e5"
2577
2648
  };
2578
2649
  var KIND_LABEL2 = {
2650
+ preference: "\u504F\u597D",
2579
2651
  decision: "\u51B3\u5B9A",
2580
2652
  error: "\u9519\u8BEF",
2581
2653
  procedure: "\u6D41\u7A0B",
@@ -2787,6 +2859,14 @@ function EngramSection({ api, t }) {
2787
2859
  const [closeEval, setCloseEval] = (0, import_react7.useState)("");
2788
2860
  const [closeRefs, setCloseRefs] = (0, import_react7.useState)("");
2789
2861
  const [closeBusy, setCloseBusy] = (0, import_react7.useState)(false);
2862
+ const [importDump, setImportDump] = (0, import_react7.useState)(null);
2863
+ const [importName, setImportName] = (0, import_react7.useState)("");
2864
+ const [importMode, setImportMode] = (0, import_react7.useState)("merge");
2865
+ const [importDryRun, setImportDryRun] = (0, import_react7.useState)(true);
2866
+ const [importReplaceOk, setImportReplaceOk] = (0, import_react7.useState)(false);
2867
+ const [importBusy, setImportBusy] = (0, import_react7.useState)(false);
2868
+ const [importReport, setImportReport] = (0, import_react7.useState)(null);
2869
+ const [dataError, setDataError] = (0, import_react7.useState)(null);
2790
2870
  const refresh = (0, import_react7.useCallback)(async () => {
2791
2871
  setBusy(true);
2792
2872
  setError(null);
@@ -2843,6 +2923,24 @@ function EngramSection({ api, t }) {
2843
2923
  for (const t2 of tasks) map.set(t2.id, t2.name);
2844
2924
  return (id) => map.get(id) ?? id;
2845
2925
  }, [nodes, tasks]);
2926
+ const supersedeIndex = (0, import_react7.useMemo)(() => {
2927
+ const replacedBy = /* @__PURE__ */ new Map();
2928
+ const contradictedBy = /* @__PURE__ */ new Map();
2929
+ for (const m of memories) {
2930
+ if (m.status !== "active") continue;
2931
+ if (m.supersedes) {
2932
+ const list = replacedBy.get(m.supersedes) ?? [];
2933
+ list.push(m);
2934
+ replacedBy.set(m.supersedes, list);
2935
+ }
2936
+ if (m.contradicts) {
2937
+ const list = contradictedBy.get(m.contradicts) ?? [];
2938
+ list.push(m);
2939
+ contradictedBy.set(m.contradicts, list);
2940
+ }
2941
+ }
2942
+ return { replacedBy, contradictedBy };
2943
+ }, [memories]);
2846
2944
  (0, import_react7.useEffect)(() => {
2847
2945
  if (!newTaskWs && workspaces.length > 0 && !workspace) setNewTaskWs(workspaces[0]);
2848
2946
  }, [newTaskWs, workspaces, workspace]);
@@ -2876,6 +2974,60 @@ function EngramSection({ api, t }) {
2876
2974
  setGcRunning(false);
2877
2975
  }
2878
2976
  };
2977
+ const downloadExport = async () => {
2978
+ setDataError(null);
2979
+ try {
2980
+ const dump = await api.exportData(workspace || void 0);
2981
+ const blob = new Blob([JSON.stringify(dump, null, 2)], { type: "application/json" });
2982
+ const url = URL.createObjectURL(blob);
2983
+ const a = document.createElement("a");
2984
+ const safe = (workspace || "all").replace(/[^a-zA-Z0-9._-]+/g, "_");
2985
+ a.href = url;
2986
+ a.download = `dsh-engram-export-${safe}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.json`;
2987
+ a.click();
2988
+ URL.revokeObjectURL(url);
2989
+ } catch (e) {
2990
+ setDataError(e instanceof Error ? e.message : String(e));
2991
+ }
2992
+ };
2993
+ const pickImportFile = async (file) => {
2994
+ setDataError(null);
2995
+ setImportReport(null);
2996
+ setImportDump(null);
2997
+ setImportName("");
2998
+ if (!file) return;
2999
+ try {
3000
+ const text = await file.text();
3001
+ setImportDump(JSON.parse(text));
3002
+ setImportName(file.name);
3003
+ } catch (e) {
3004
+ setDataError(`\u65E0\u6CD5\u89E3\u6790 ${file.name}\uFF1A${e instanceof Error ? e.message : String(e)}`);
3005
+ }
3006
+ };
3007
+ const runImport = async () => {
3008
+ if (importDump === null) return;
3009
+ if (importMode === "replace" && !importReplaceOk) {
3010
+ setDataError("\u66FF\u6362\u6A21\u5F0F\u4F1A\u5148\u6E05\u7A7A\u76EE\u6807\u5DE5\u4F5C\u533A \u2014 \u8BF7\u5148\u52FE\u9009\u786E\u8BA4");
3011
+ return;
3012
+ }
3013
+ setImportBusy(true);
3014
+ setDataError(null);
3015
+ try {
3016
+ const report = await api.importData({
3017
+ payload: importDump,
3018
+ mode: importMode,
3019
+ dryRun: importDryRun,
3020
+ workspace: workspace || void 0,
3021
+ confirm: importMode === "replace" ? "restore" : void 0
3022
+ });
3023
+ setImportReport(report);
3024
+ if (!report.dryRun) await refresh();
3025
+ } catch (e) {
3026
+ setDataError(e instanceof Error ? e.message : String(e));
3027
+ } finally {
3028
+ setImportBusy(false);
3029
+ }
3030
+ };
2879
3031
  const groupedRows = workspace === "" ? groupByWorkspace(memories) : [[workspace, memories]];
2880
3032
  const taskGroups = workspace === "" ? groupByWorkspace(tasks) : [[workspace, tasks]];
2881
3033
  const linkGroups = workspace === "" ? groupByWorkspace(links) : [[workspace, links]];
@@ -2995,6 +3147,7 @@ function EngramSection({ api, t }) {
2995
3147
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(StatCard, { num: String(wsCounts ? wsCounts.nodes ?? 0 : overview.totals.nodes ?? 0), label: wsCounts ? "\u8282\u70B9" : "\u8282\u70B9 (\u5168\u5C40)" }),
2996
3148
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(StatCard, { num: String(workspaces.length), label: "\u5DE5\u4F5C\u533A" }),
2997
3149
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(StatCard, { num: String(overview.captures.total), label: "\u81EA\u52A8\u6355\u83B7" }),
3150
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(StatCard, { num: String(overview.captures.preference ?? 0), label: "\u504F\u597D\u6355\u83B7" }),
2998
3151
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
2999
3152
  StatCard,
3000
3153
  {
@@ -3064,6 +3217,90 @@ function EngramSection({ api, t }) {
3064
3217
  ] }, l.id))
3065
3218
  ] })
3066
3219
  ] }),
3220
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: s2.subPanel, children: [
3221
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }, children: [
3222
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: { fontWeight: 700, fontSize: 13 }, children: "\u6570\u636E\u5907\u4EFD\uFF08export / import\uFF09" }),
3223
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("button", { style: s2.btn, title: "\u4E0B\u8F7D\u5F53\u524D\u5DE5\u4F5C\u533A\uFF08\u6216\u5168\u90E8\uFF09\u7684 dsh-engram/export v1 \u8F6C\u50A8", onClick: () => void downloadExport(), children: "\u5BFC\u51FA JSON" }),
3224
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("label", { style: { ...s2.btn, display: "inline-flex", alignItems: "center", gap: 6, cursor: "pointer" }, children: [
3225
+ "\u9009\u62E9\u5BFC\u5165\u6587\u4EF6\u2026",
3226
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
3227
+ "input",
3228
+ {
3229
+ type: "file",
3230
+ accept: "application/json,.json",
3231
+ style: { display: "none" },
3232
+ onChange: (e) => {
3233
+ void pickImportFile(e.target.files?.[0] ?? null);
3234
+ e.currentTarget.value = "";
3235
+ }
3236
+ }
3237
+ )
3238
+ ] }),
3239
+ importName && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: s2.mono, children: importName })
3240
+ ] }),
3241
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: { fontSize: 11.5, color: "var(--dsh-color-muted, #6b7280)", marginTop: 4 }, children: [
3242
+ "\u5BFC\u51FA\u5373\u53EF\u79FB\u690D\u5907\u4EFD\uFF08\u8BB0\u5FC6/\u4EFB\u52A1/\u5173\u7CFB/\u8282\u70B9\uFF0C\u542B\u5F52\u6863\uFF09\uFF1B",
3243
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("b", { children: "merge" }),
3244
+ " \u53EA\u8865\u7F3A\u5931\uFF08\u5E42\u7B49\uFF09\uFF0C",
3245
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("b", { children: "replace" }),
3246
+ " \u5148\u6E05\u7A7A\u76EE\u6807\u5DE5\u4F5C\u533A\u518D\u6062\u590D\u3002\u8BB0\u5FC6\u6B63\u6587\u4E0E\u6240\u6709\u5199\u5165\u8DEF\u5F84\u4E00\u6837\u8FC7\u8131\u654F\u3002"
3247
+ ] }),
3248
+ importDump !== null && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: { marginTop: 8, display: "flex", flexDirection: "column", gap: 6 }, children: [
3249
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: { display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap", fontSize: 12.5 }, children: [
3250
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("label", { style: { display: "inline-flex", gap: 4, alignItems: "center" }, children: [
3251
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("input", { type: "checkbox", checked: importDryRun, onChange: (e) => setImportDryRun(e.target.checked) }),
3252
+ "\u4EC5\u9884\u89C8\uFF08dry run\uFF09"
3253
+ ] }),
3254
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
3255
+ "select",
3256
+ {
3257
+ style: s2.input,
3258
+ value: importMode,
3259
+ onChange: (e) => {
3260
+ const mode = e.target.value;
3261
+ setImportMode(mode);
3262
+ if (mode !== "replace") setImportReplaceOk(false);
3263
+ },
3264
+ children: [
3265
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("option", { value: "merge", children: "\u5408\u5E76\uFF08merge\uFF0C\u5E42\u7B49\uFF09" }),
3266
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("option", { value: "replace", children: "\u66FF\u6362\uFF08replace\uFF0C\u5148\u6E05\u7A7A\u5DE5\u4F5C\u533A\uFF09" })
3267
+ ]
3268
+ }
3269
+ ),
3270
+ importMode === "replace" && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("label", { style: { display: "inline-flex", gap: 4, alignItems: "center", color: "#b45309" }, children: [
3271
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("input", { type: "checkbox", checked: importReplaceOk, onChange: (e) => setImportReplaceOk(e.target.checked) }),
3272
+ "\u6211\u786E\u8BA4\u66FF\u6362",
3273
+ workspace ? `\uFF08${workspace}\uFF09` : "\uFF08\u9700\u5148\u9009\u5DE5\u4F5C\u533A\uFF09"
3274
+ ] }),
3275
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
3276
+ "button",
3277
+ {
3278
+ style: s2.btnPrimary,
3279
+ disabled: importBusy || importMode === "replace" && (!importReplaceOk || !workspace),
3280
+ onClick: () => void runImport(),
3281
+ children: importBusy ? "\u2026" : importDryRun ? "\u9884\u89C8\u5BFC\u5165" : "\u6267\u884C\u5BFC\u5165"
3282
+ }
3283
+ )
3284
+ ] }),
3285
+ importReport && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: { fontSize: 12.5 }, children: [
3286
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { children: [
3287
+ importReport.dryRun ? "dry-run \u9884\u89C8\uFF1A" : "\u5DF2\u5BFC\u5165\uFF1A",
3288
+ " ",
3289
+ Object.entries(importReport.written).map(([k, v]) => `${k} ${v}`).join(" \xB7 ") || "\u2014",
3290
+ importReport.skipped.length > 0 ? ` \xB7 \u8DF3\u8FC7 ${importReport.skipped.length}` : ""
3291
+ ] }),
3292
+ importReport.skipped.slice(0, 5).map((x, i) => /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: s2.mono, children: [
3293
+ "- ",
3294
+ x.table,
3295
+ " ",
3296
+ x.id ? x.id.slice(0, 8) : "?",
3297
+ ": ",
3298
+ x.reason
3299
+ ] }, `${x.table}-${x.id ?? i}`))
3300
+ ] }),
3301
+ dataError && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { style: { fontSize: 12, color: "#dc2626", fontWeight: 600 }, children: dataError })
3302
+ ] })
3303
+ ] }),
3067
3304
  /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: s2.row, children: [
3068
3305
  /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("select", { style: s2.input, value: kind, onChange: (e) => setKind(e.target.value), children: [
3069
3306
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("option", { value: "", children: "\u5168\u90E8\u7C7B\u578B" }),
@@ -3137,7 +3374,8 @@ function EngramSection({ api, t }) {
3137
3374
  ] }) }, `h-${r.ws}`) : /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("tr", { children: [
3138
3375
  /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("td", { style: { ...s2.td, whiteSpace: "nowrap" }, children: [
3139
3376
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: { ...s2.badge, background: KIND_COLORS2[r.m.kind] ?? "#6b7280" }, children: KIND_LABEL2[r.m.kind] ?? r.m.kind }),
3140
- r.m.status === "archived" && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: { ...s2.tag, color: "#b45309", background: "#fef3c7" }, children: "archived" })
3377
+ r.m.status === "archived" && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: { ...s2.tag, color: "#b45309", background: "#fef3c7" }, children: "archived" }),
3378
+ supersedeIndex.replacedBy.has(r.m.id) && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: { ...s2.tag, color: "#b45309", background: "#fef3c7" }, title: `\u5DF2\u88AB ${(supersedeIndex.replacedBy.get(r.m.id) ?? []).map((x) => `#${x.id.slice(0, 8)}`).join(", ")} \u66FF\u4EE3\uFF08\u65E7\u884C\u4FDD\u7559\u3001\u53EC\u56DE\u964D\u7EA7\uFF09`, children: "\u5DF2\u66FF\u6362" })
3141
3379
  ] }),
3142
3380
  /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("td", { style: { ...s2.td, minWidth: 0 }, children: [
3143
3381
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { title: r.m.text, style: expandedRows.has(r.m.id) ? s2.expanded : s2.clamp3, children: r.m.text }),
@@ -3154,7 +3392,11 @@ function EngramSection({ api, t }) {
3154
3392
  " \xB7 ",
3155
3393
  daysLeft2(r.m.expiresAt)
3156
3394
  ] }),
3157
- /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("button", { style: s2.linkBtn, onClick: () => toggleExpand(r.m.id), children: expandedRows.has(r.m.id) ? "\u6536\u8D77" : "\u5C55\u5F00\u5168\u6587" })
3395
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("button", { style: s2.linkBtn, onClick: () => toggleExpand(r.m.id), children: expandedRows.has(r.m.id) ? "\u6536\u8D77" : "\u5C55\u5F00\u5168\u6587" }),
3396
+ r.m.supersedes && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("button", { style: s2.linkBtn, title: `\u6253\u5F00\u88AB\u66FF\u4EE3\u7684\u65E7\u8BB0\u5FC6 ${r.m.supersedes}`, onClick: () => openMemory(r.m.supersedes), children: [
3397
+ "\u66FF\u4EE3 #",
3398
+ r.m.supersedes.slice(0, 8)
3399
+ ] })
3158
3400
  ] }),
3159
3401
  r.m.tags.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { style: { marginTop: 3, display: "flex", flexWrap: "wrap", gap: 4 }, children: r.m.tags.map((tag) => /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: s2.tag, children: tag }, tag)) })
3160
3402
  ] }),
@@ -3354,6 +3596,7 @@ ${r.m.text.slice(0, 60)}`)) void act(() => api.remove(r.m.id, r.m.workspace));
3354
3596
  tasks,
3355
3597
  nodes,
3356
3598
  links,
3599
+ memories,
3357
3600
  onClose: () => setDetail(null),
3358
3601
  onNavigateMemory: openMemory,
3359
3602
  onChanged: () => void refresh()
@@ -3369,6 +3612,10 @@ var import_react8 = require("react");
3369
3612
  var import_jsx_runtime8 = require("react/jsx-runtime");
3370
3613
  var COMMON_FIELDS = [
3371
3614
  { key: "autoCapture", label: "\u81EA\u52A8\u6355\u83B7", hint: "\u96F6 LLM \u4ECE\u5DE5\u5177\u7ED3\u679C\u63D0\u53D6\u8BB0\u5FC6\uFF08git/\u5173\u952E\u6587\u4EF6/\u9519\u8BEF\uFF09", kind: "bool" },
3615
+ { key: "autoCapturePreferences", label: "\u504F\u597D\u6355\u83B7", hint: "\u7528\u6237\u7EA0\u6B63/\u504F\u597D\u5B66\u4E60\uFF1A\u68C0\u6D4B\u6D88\u606F\u91CC\u7684\u300C\u4E0D\u8981\u7528X/\u6539\u7528Y/prefer/never use\u300D\u53E5\u5F0F\uFF0C\u81EA\u52A8\u6C89\u6DC0\u4E3A preference \u8BB0\u5FC6\u5E76\u7F6E\u9876 [ENGRAM]\uFF08correx/PAHF \u5F0F\uFF0C\u96F6 LLM\uFF09", kind: "bool" },
3616
+ { key: "boundaryPrune", label: "\u8FB9\u754C\u526A\u679D", hint: "ResultPack L1\uFF1Atodo \u5B8C\u6210/goal \u7EC8\u6001\u8FB9\u754C\u4E3B\u52A8\u526A\u6389\u8D85\u9608\u503C\u65E7\u5DE5\u5177\u7ED3\u679C\uFF08DSH \u786E\u5B9A\u6027 surface-replace\u3001\u507F\u8FD8\u95E8\u3001\u96F6 LLM\uFF1B\u9ED8\u8BA4\u5173\uFF09", kind: "bool" },
3617
+ { key: "boundaryPruneThresholdChars", label: "\u526A\u679D\u9608\u503C(\u5B57\u7B26)", hint: "\u8986\u76D6\u5BBF\u4E3B 8192\uFF1A\u8BBE 2048 \u53EF\u66F4\u65E9\u5F00\u526A\uFF0C\u5E76\u914D L2 \u5F52\u6863\u627E\u56DE\u88AB\u526A\u4E2D\u6BB5\uFF1B\u7559\u7A7A=\u5BBF\u4E3B\u9ED8\u8BA4\u3002\u5EFA\u8BAE\u540C\u65F6\u628A\u4FDD\u7559\u8FB9\u7F18\u8C03\u5C0F\uFF08\u5982 1024/256\uFF09\u4EE5\u83B7\u5F97\u6700\u5927\u8282\u7701", kind: "num", min: 256, max: 16384, step: 256 },
3618
+ { key: "resultPack", label: "\u7ED3\u679C\u5F52\u6863\u53EC\u56DE", hint: "ResultPack L2\uFF1A\u526A\u6389\u7684\u4E2D\u6BB5\u8131\u654F\u5F52\u6863\uFF0C\u8282\u70B9\u5C3E\u90E8\u9644 handle\uFF0C\u6A21\u578B\u53EF\u7528 result_recall(callId) \u6309\u9875\u53EC\u56DE\uFF08\u5B8C\u6574 ObservationPack\uFF1B\u9700\u4E0E\u8FB9\u754C\u526A\u679D\u4E00\u8D77\u5F00\uFF09", kind: "bool" },
3372
3619
  { key: "autoRecallOnStart", label: "\u4F1A\u8BDD\u542F\u52A8\u81EA\u52A8\u53EC\u56DE", hint: "\u65B0\u4F1A\u8BDD\u6309\u9996\u6761\u6D88\u606F\u81EA\u52A8\u628A\u6700\u76F8\u5173\u8BB0\u5FC6\u6CE8\u5165 [RECALL] \u5757\uFF08\u65E0\u9700\u6A21\u578B\u60F3\u8D77\u8C03 engram_recall\uFF09\uFF1B\u5173\u6389=\u56DE\u5230\u7EAF\u62C9\u53D6", kind: "bool" },
3373
3620
  { key: "maxRecallPerSession", label: "\u6BCF\u4F1A\u8BDD\u53BB\u91CD\u4E0A\u9650", hint: "recall \u6BCF\u4F1A\u8BDD\u6700\u591A\u4FDD\u7559 N \u6761\uFF08\u8DE8\u4F1A\u8BDD\u53BB\u91CD\uFF09", kind: "num", min: 1, max: 10 },
3374
3621
  { key: "expireDays", label: "TTL\uFF08\u5929\uFF09", hint: "0 = \u4E0D\u8FC7\u671F", kind: "num", min: 0, max: 3650 },
@@ -3381,6 +3628,9 @@ var ADVANCED_GROUPS = [
3381
3628
  description: "\u5176\u4F59\u6355\u83B7/\u641C\u7D22\u65CB\u94AE",
3382
3629
  fields: [
3383
3630
  { key: "autoCapturePerSession", label: "\u6BCF\u4F1A\u8BDD\u6355\u83B7\u4E0A\u9650", hint: "\u5355\u4F1A\u8BDD\u81EA\u52A8\u6355\u83B7\u6761\u6570\u4E0A\u9650", kind: "num", min: 0, max: 1e3 },
3631
+ { key: "boundaryPruneHeadChars", label: "\u526A\u679D\u4FDD\u7559\u5934\u90E8(\u5B57\u7B26)", hint: "\u9608\u503C\u8986\u76D6\u751F\u6548\u65F6\u7ED3\u679C\u5F00\u5934\u4FDD\u7559\u957F\u5EA6\uFF08\u9ED8\u8BA4 4096\uFF09\uFF1B\u8C03\u5C0F\uFF08\u5982 1024\uFF09\u914D\u5408 T2048 \u66F4\u7701", kind: "num", min: 0, max: 16384, step: 128 },
3632
+ { key: "boundaryPruneTailChars", label: "\u526A\u679D\u4FDD\u7559\u5C3E\u90E8(\u5B57\u7B26)", hint: "\u9608\u503C\u8986\u76D6\u751F\u6548\u65F6\u7ED3\u679C\u7ED3\u5C3E\u4FDD\u7559\u957F\u5EA6\uFF08\u9ED8\u8BA4 1024\uFF09\uFF1B\u8C03\u5C0F\uFF08\u5982 256\uFF09\u914D\u5408 T2048 \u66F4\u7701", kind: "num", min: 0, max: 16384, step: 128 },
3633
+ { key: "boundaryPruneIdleCadence", label: "\u7A7A\u95F2\u6B65\u6570\u8FB9\u754C", hint: "\u6700\u591A\u6BCF N \u8BF7\u6C42\u89E6\u53D1\u4E00\u6B21\u526A\u679D\uFF0C\u8986\u76D6\u65E0 todo/goal \u7684\u4F1A\u8BDD\uFF08\u7EA6 62% \u4F1A\u8BDD\uFF09\uFF1B\u4ECD\u53D7\u507F\u8FD8\u95E8\u7EA6\u675F\uFF1B\u7559\u7A7A=\u4EC5\u4E8B\u4EF6\u8FB9\u754C", kind: "num", min: 10, max: 1e3, step: 10 },
3384
3634
  { key: "autoRecallLimit", label: "\u81EA\u52A8\u53EC\u56DE\u6761\u6570", hint: "[RECALL] \u5757\u6700\u591A\u6CE8\u5165\u7684\u8BB0\u5FC6\u6761\u6570\uFF08\u5C11\u800C\u51C6\uFF09", kind: "num", min: 1, max: 8 },
3385
3635
  { key: "sessionSearch", label: "\u4F1A\u8BDD\u5386\u53F2\u641C\u7D22", hint: "engram_recall \u652F\u6301\u8DE8\u4F1A\u8BDD FTS \u515C\u5E95", kind: "bool" },
3386
3636
  { key: "recallScope", label: "\u53EC\u56DE\u8303\u56F4", hint: "workspace=\u4E25\u683C\u5F53\u524D\u5DE5\u4F5C\u533A\u9694\u79BB\uFF08\u9ED8\u8BA4\uFF0Ctoken \u7EAA\u5F8B\uFF09\uFF1Bglobal=\u672C\u5DE5\u4F5C\u533A\u547D\u4E2D\u5F31/\u7A7A\u65F6\u4ECE\u5176\u5B83\u5DE5\u4F5C\u533A\u6C60\u53EC\u56DE\u5E76\u6807 [W:<workspace>] \u6765\u6E90\uFF08\u4F1A\u6253\u7834\u9694\u79BB\uFF09", kind: "select", options: ["workspace", "global"] }