dsh-engram 0.3.5 → 0.3.7

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
@@ -5,12 +5,14 @@
5
5
  * wears the loopback-only trust fence (copied from the SSH family): these
6
6
  * routes expose the on-disk memory store, so a LAN-exposed dsh web must not
7
7
  * serve them. All reads go straight at the opened storage-domain handle
8
- * (synchronous after the first open); the two mutations (archive / delete)
9
- * go through the same store methods the agent tools use.
8
+ * (synchronous after the first open); the mutations (archive / delete, and
9
+ * the settings card's PUT /settings) go through the same store methods the
10
+ * agent tools use (settings writes through the `service.settings` seam).
10
11
  */
11
12
 
12
13
  import { uuid, shortId } from "./util.js";
13
14
  import { collectToolCounts, collectUsageStats } from "./usage.js";
15
+ import { SETTINGS_NS } from "./settings.js";
14
16
 
15
17
  /** Route family prefix (kept in sync with the client half). */
16
18
  export const API_PREFIX = "/api/dsh-engram";
@@ -18,6 +20,9 @@ export const API_PREFIX = "/api/dsh-engram";
18
20
  /** Cap on JSON request bodies (archives/deletes are tiny). */
19
21
  const MAX_JSON_BODY_BYTES = 32 * 1024;
20
22
 
23
+ /** Import dumps carry whole workspaces of memories/tasks — allow up to 32 MiB. */
24
+ const MAX_IMPORT_BODY_BYTES = 32 * 1024 * 1024;
25
+
21
26
  /** Loopback literal check plus browser same-origin markers. */
22
27
  function isLoopbackRequest(request, trustedHosts = []) {
23
28
  const address = request.socket?.remoteAddress;
@@ -67,13 +72,13 @@ function readQuery(req) {
67
72
  return url.searchParams;
68
73
  }
69
74
 
70
- function readJsonBody(req) {
75
+ function readJsonBody(req, maxBytes = MAX_JSON_BODY_BYTES) {
71
76
  return new Promise((resolve) => {
72
77
  let size = 0;
73
78
  const chunks = [];
74
79
  req.on("data", (chunk) => {
75
80
  size += chunk.length;
76
- if (size > MAX_JSON_BODY_BYTES) {
81
+ if (size > maxBytes) {
77
82
  resolve({ error: "body too large" });
78
83
  req.destroy();
79
84
  return;
@@ -300,6 +305,56 @@ export function makeEngramRoutes(service) {
300
305
  }
301
306
  };
302
307
 
308
+ /**
309
+ * Data contract — dump every row (memories/tasks/links/entities, archived
310
+ * included) of one workspace (`?workspace=`) or of every workspace, in the
311
+ * machine-readable `dsh-engram/export` v1 format that `POST /import`
312
+ * round-trips. Export is the stable, format-versioned exit door: the memory
313
+ * corpus never depends on engram's internal tables, and a thin adapter
314
+ * (another harness, MCP, a plain backup script) only needs this shape.
315
+ */
316
+ const exportData = async (req, res) => {
317
+ const domain = await withDomain(res);
318
+ if (domain === void 0) return;
319
+ const workspace = readQuery(req).get("workspace") || void 0;
320
+ try {
321
+ writeJson(res, 200, domain.exportAll(workspace));
322
+ } catch (error) {
323
+ writeError(res, 500, String(error?.message ?? error));
324
+ }
325
+ };
326
+
327
+ /** Data contract — restore a `dsh-engram/export` dump (see store.importAll:
328
+ * merge = idempotent add-missing, replace = wipe workspace then restore;
329
+ * `dryRun` plans without writing; `confirm: "restore"` is required for the
330
+ * destructive replace mode). Memory text passes the same secret redactor as
331
+ * every other write path. */
332
+ const importData = async (req, res) => {
333
+ const domain = await withDomain(res);
334
+ if (domain === void 0) return;
335
+ const body = await readJsonBody(req, MAX_IMPORT_BODY_BYTES);
336
+ if (body.error) return writeError(res, 400, body.error);
337
+ const { payload, mode, dryRun, workspace, confirm } = body.value ?? {};
338
+ if (mode !== "merge" && mode !== "replace") {
339
+ return writeError(res, 400, "mode must be 'merge' or 'replace'");
340
+ }
341
+ if (mode === "replace" && confirm !== "restore") {
342
+ return writeError(res, 400, "replace mode is destructive — pass confirm: \"restore\" to proceed");
343
+ }
344
+ try {
345
+ const report = await domain.importAll(payload, {
346
+ mode: mode ?? "merge",
347
+ dryRun: dryRun === true,
348
+ workspace: typeof workspace === "string" && workspace.length > 0 ? workspace : void 0,
349
+ config: service.config ?? {},
350
+ });
351
+ writeJson(res, 200, report);
352
+ } catch (error) {
353
+ const status = error instanceof Error && error.code === "ENGRAM_INVALID_ARGS" ? 400 : 500;
354
+ writeError(res, status, String(error?.message ?? error));
355
+ }
356
+ };
357
+
303
358
  const guard = (req, res, method, handler) => {
304
359
  if ((req.method ?? "GET").toUpperCase() !== method) {
305
360
  writeError(res, 405, "method not allowed");
@@ -488,6 +543,71 @@ export function makeEngramRoutes(service) {
488
543
  writeJson(res, 200, { items: list });
489
544
  };
490
545
 
546
+ /** Settings card transport — read the `dsh-engram` namespace through the
547
+ * host settings service (see settings.js for the normalized `service.settings`
548
+ * seam). Same-origin fetch, like every other route in this family; this is
549
+ * what lets the web config card be DSH-generation-agnostic (no
550
+ * connection/remote RPC), at the cost of the same loopback/trustedHosts
551
+ * fence the memory viewer already wears. */
552
+ const getSettings = (_req, res) => {
553
+ const settings = service.settings;
554
+ if (!settings) {
555
+ writeError(res, 503, "settings service not available");
556
+ return;
557
+ }
558
+ try {
559
+ // dsh-settings `describe()` returns a BARE array of namespace descriptors
560
+ // (in registration order); older adapters may wrap it as {namespaces:[...]}.
561
+ // Normalize both onto the HTTP contract {namespaces:[...]} the client uses.
562
+ const payload = settings.describe();
563
+ 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: [] });
566
+ } catch (error) {
567
+ writeError(res, 500, String(error?.message ?? error));
568
+ }
569
+ };
570
+
571
+ /** Settings card transport — mutate the `dsh-engram` namespace. Accepts
572
+ * either path ops (`ops: [{op:"set"|"unset", path:[...], value?}]`, the
573
+ * exact shape the card used through the RPC) or a plain partial patch
574
+ * (`patch: object`). The provider validates and persists through the same
575
+ * document the GUI card always wrote to. */
576
+ const putSettings = async (req, res) => {
577
+ const settings = service.settings;
578
+ if (!settings) {
579
+ writeError(res, 503, "settings service not available");
580
+ return;
581
+ }
582
+ const body = await readJsonBody(req);
583
+ if (body.error) return writeError(res, 400, body.error);
584
+ const { ops, patch, expectedRevision } = body.value ?? {};
585
+ if (!Array.isArray(ops) && !(patch !== null && typeof patch === "object")) {
586
+ return writeError(res, 400, "body must provide ops (array of {op,path[,value]}) or patch (object)");
587
+ }
588
+ try {
589
+ if (Array.isArray(ops)) {
590
+ await settings.mutate(SETTINGS_NS, ops, expectedRevision);
591
+ } else {
592
+ await settings.update(SETTINGS_NS, patch, expectedRevision);
593
+ }
594
+ writeJson(res, 200, { ok: true });
595
+ } catch (error) {
596
+ if (error?.code === "SETTINGS_CONFLICT" || error?.name === "SettingsConflictError") {
597
+ writeJson(res, 409, {
598
+ error: {
599
+ code: "SETTINGS_CONFLICT",
600
+ message: String(error?.message ?? "settings moved since read"),
601
+ expected: error?.expected,
602
+ actual: error?.actual,
603
+ },
604
+ });
605
+ return;
606
+ }
607
+ writeError(res, 400, String(error?.message ?? error));
608
+ }
609
+ };
610
+
491
611
  const make = (method, path, handler) => ({
492
612
  kind: "exact",
493
613
  path: `${API_PREFIX}${path}`,
@@ -532,9 +652,12 @@ export function makeEngramRoutes(service) {
532
652
  make("GET", "/model", model),
533
653
  make("GET", "/preview", preview),
534
654
  make("GET", "/config", readConfig),
655
+ makeUnion("/settings", { GET: getSettings, PUT: putSettings }),
535
656
  make("POST", "/memories/archive", archive),
536
657
  make("POST", "/memories/delete", remove),
537
658
  make("POST", "/tasks/close", closeTask),
538
659
  make("POST", "/gc", gcRun),
660
+ make("GET", "/export", exportData),
661
+ make("POST", "/import", importData),
539
662
  ];
540
663
  }
package/lib/client.js CHANGED
@@ -153,6 +153,34 @@ var EngramApi = class {
153
153
  })
154
154
  );
155
155
  }
156
+ /** Settings card transport — read the `dsh-engram` namespace through our
157
+ * own HTTP API instead of the host connection/remote RPC. */
158
+ async getSettings() {
159
+ return readJson(await fetch(`${API_PREFIX}/settings`));
160
+ }
161
+ /** Settings card transport — persist one or more path ops into the
162
+ * `dsh-engram` namespace. Non-2xx surfaces the server's message. */
163
+ async updateSettings(ops, expectedRevision) {
164
+ const response = await fetch(`${API_PREFIX}/settings`, {
165
+ method: "PUT",
166
+ headers: { "content-type": "application/json" },
167
+ body: JSON.stringify({
168
+ ops,
169
+ ...expectedRevision !== void 0 ? { expectedRevision } : {}
170
+ })
171
+ });
172
+ let body = null;
173
+ try {
174
+ body = await response.json();
175
+ } catch {
176
+ }
177
+ if (!response.ok) {
178
+ const err = body;
179
+ const message = typeof err?.error === "string" ? err.error : typeof err?.error === "object" && err.error !== null && typeof err.error.message === "string" ? err.error.message : `HTTP ${response.status}`;
180
+ throw new EngramApiError(message);
181
+ }
182
+ return body ?? { ok: true };
183
+ }
156
184
  };
157
185
 
158
186
  // client/src/EngramTaskDock.tsx
@@ -3341,6 +3369,7 @@ var import_react8 = require("react");
3341
3369
  var import_jsx_runtime8 = require("react/jsx-runtime");
3342
3370
  var COMMON_FIELDS = [
3343
3371
  { 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" },
3372
+ { 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" },
3344
3373
  { 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 },
3345
3374
  { key: "expireDays", label: "TTL\uFF08\u5929\uFF09", hint: "0 = \u4E0D\u8FC7\u671F", kind: "num", min: 0, max: 3650 },
3346
3375
  { key: "indexMaxChars", label: "\u7D22\u5F15\u5B57\u7B26\u4E0A\u9650", hint: "[ENGRAM] \u5757 token \u9884\u7B97", kind: "num", min: 0, max: 4e3 }
@@ -3352,7 +3381,9 @@ var ADVANCED_GROUPS = [
3352
3381
  description: "\u5176\u4F59\u6355\u83B7/\u641C\u7D22\u65CB\u94AE",
3353
3382
  fields: [
3354
3383
  { 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 },
3355
- { key: "sessionSearch", label: "\u4F1A\u8BDD\u5386\u53F2\u641C\u7D22", hint: "engram_recall \u652F\u6301\u8DE8\u4F1A\u8BDD FTS \u515C\u5E95", kind: "bool" }
3384
+ { 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
+ { key: "sessionSearch", label: "\u4F1A\u8BDD\u5386\u53F2\u641C\u7D22", hint: "engram_recall \u652F\u6301\u8DE8\u4F1A\u8BDD FTS \u515C\u5E95", kind: "bool" },
3386
+ { 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"] }
3356
3387
  ]
3357
3388
  },
3358
3389
  {
@@ -3651,6 +3682,15 @@ function EngramConfigCard({ scope }) {
3651
3682
  disabled: !writable,
3652
3683
  onChange: (e) => setField(field.key, e.target.checked)
3653
3684
  }
3685
+ ) : field.kind === "select" ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
3686
+ "select",
3687
+ {
3688
+ style: { ...s3.input, width: field.width ?? 160 },
3689
+ value: raw === void 0 ? "" : String(raw),
3690
+ disabled: !writable,
3691
+ onChange: (e) => setField(field.key, e.target.value),
3692
+ children: (field.options ?? []).map((opt) => /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("option", { value: opt, children: opt }, opt))
3693
+ }
3654
3694
  ) : field.kind === "text" ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
3655
3695
  "input",
3656
3696
  {
@@ -3812,14 +3852,7 @@ var EngramScopeImpl = class {
3812
3852
  this.snapshot = LOADING;
3813
3853
  this.notify();
3814
3854
  try {
3815
- const response = await this.api.settings.describe({});
3816
- const result = response?.result;
3817
- if (!result || result.ok !== true) {
3818
- const err = result && "error" in result ? result.error : { code: "settings.describe", message: "unreachable" };
3819
- this.fail(`settings.describe failed: ${String(err?.code ?? err)}: ${String(err?.message ?? "")}`);
3820
- return;
3821
- }
3822
- const payload = result.value;
3855
+ const payload = await this.api.getSettings();
3823
3856
  const view = payload.namespaces?.find((n) => n.ns === this.ns);
3824
3857
  if (!view) {
3825
3858
  this.fail(`the '${this.ns}' settings namespace is not served by this host.`);
@@ -3833,27 +3866,16 @@ var EngramScopeImpl = class {
3833
3866
  base: view.base ?? view.value,
3834
3867
  user: view.user,
3835
3868
  revision: view.revision ?? 0,
3836
- writable: this.opts.writable !== false && payload.writable !== false && applied
3869
+ writable: this.opts.writable !== false && applied
3837
3870
  };
3838
3871
  this.notify();
3839
3872
  } catch (error) {
3840
- this.fail(`settings.describe threw: ${String(error instanceof Error ? error.message : error)}`);
3873
+ this.fail(`settings fetch failed: ${String(error instanceof Error ? error.message : error)}`);
3841
3874
  }
3842
3875
  }
3843
3876
  async mutate(op) {
3844
3877
  const revision = this.snapshot.revision;
3845
- const response = await this.api.settings.mutate({
3846
- ns: this.ns,
3847
- ops: [op],
3848
- ...revision ? { expectedRevision: revision } : {}
3849
- });
3850
- const result = response?.result;
3851
- if (!result || result.ok !== true) {
3852
- const err = result && "error" in result ? result.error : { code: "settings.mutate", message: "unreachable" };
3853
- throw new Error(`settings.mutate failed: ${String(err?.code ?? err)}: ${String(err?.message ?? "")}`);
3854
- }
3855
- const view = result.value;
3856
- if (view) this.snapshot = { ...this.snapshot, revision: view.revision ?? revision };
3878
+ await this.api.updateSettings([op], revision > 0 ? revision : void 0);
3857
3879
  }
3858
3880
  async set(key, value) {
3859
3881
  await this.mutate({ op: "set", path: [String(key)], value });
@@ -4912,7 +4934,7 @@ var en = {
4912
4934
  refresh: "Refresh",
4913
4935
  error: "Load failed"
4914
4936
  };
4915
- var inject = ["slots", "locale", "connection", "sessions", "workspaces"];
4937
+ var inject = ["slots", "locale", "sessions", "workspaces"];
4916
4938
  function apply(ctx) {
4917
4939
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-engram: dictionaries");
4918
4940
  const api = new EngramApi();
@@ -4953,8 +4975,7 @@ function apply(ctx) {
4953
4975
  } catch (error) {
4954
4976
  console.warn("[dsh-engram] settings.section registration failed:", error);
4955
4977
  }
4956
- const connection = ctx.get("connection");
4957
- const scope = new EngramScopeImpl(connection.api, "dsh-engram");
4978
+ const scope = new EngramScopeImpl(api, "dsh-engram");
4958
4979
  const cardInjected = () => ({ scope });
4959
4980
  try {
4960
4981
  ctx.slots.inject(
@@ -115,7 +115,7 @@ export function renderIndex(store, workspace, cwd, config) {
115
115
  if (entityNames.length > 0) lines.push(`nodes: ${truncate(entityNames.join(", "), 72)}`);
116
116
  for (const m of memories.slice(0, config.indexMaxLines)) lines.push(memoryLine(m, config));
117
117
  for (const t of tasks.slice(0, 4)) lines.push(taskStateLine(t));
118
- lines.push("drill: engram_recall <query> | engram_detail <id> | esr_task (multi-step work → create now) | esr_node (recurring domain object → create now) | esr_link (relate nodes/tasks)");
118
+ lines.push("drill: use [RECALL] below · engram_detail <id> (full record) · engram_recall <query> (more) · esr_task/esr_node/esr_link (work)");
119
119
  return enforceCharCap(lines, config.indexMaxChars);
120
120
  }
121
121
 
@@ -133,6 +133,97 @@ function enforceCharCap(lines, maxChars) {
133
133
  return joined;
134
134
  }
135
135
 
136
+ /** Text of the session's first user message (the opening instruction of the
137
+ * conversation). Walk `session.events` for the first `user/message` event and
138
+ * join its `data.content` text blocks; fall back to the first text-bearing
139
+ * event ("goal"/reminder start) guardedly. Returns "" when there is none.
140
+ * Pure, deterministic — the auto-recall (B) keys its query off this. */
141
+ export function firstUserMessageText(session) {
142
+ if (session === null || typeof session !== "object") return "";
143
+ const events = Array.isArray(session.events) ? session.events : [];
144
+ if (events.length === 0) return "";
145
+ const textOf = (data) => {
146
+ if (data === null || typeof data !== "object") return "";
147
+ const blocks = Array.isArray(data.content) ? data.content : [];
148
+ if (blocks.length > 0) {
149
+ return blocks
150
+ .map((b) => (b !== null && typeof b === "object" && typeof b.text === "string" ? b.text : ""))
151
+ .join(" ")
152
+ .trim();
153
+ }
154
+ return typeof data.text === "string" ? data.text.trim() : "";
155
+ };
156
+ for (const event of events) {
157
+ if (event === null || typeof event !== "object") continue;
158
+ if (event.type !== "user/message") continue;
159
+ const text = textOf(event.data);
160
+ if (text.length > 0) return text;
161
+ }
162
+ // Fallback: no user/message yet — first event carrying text (goal-driven,
163
+ // headless or reminder-seeded starts) still gives the recall a key.
164
+ for (const event of events) {
165
+ if (event === null || typeof event !== "object" || event.type === "user/message") continue;
166
+ const text = textOf(event.data);
167
+ if (text.length > 0) return text;
168
+ }
169
+ return "";
170
+ }
171
+
172
+ /**
173
+ * Host-side session-start auto-recall (方案 B): at the first assembly of a new
174
+ * session, deterministically key a bounded BM25 recall off the session's first
175
+ * user message and inject the top hits as a `[RECALL]` block — so relevant
176
+ * memory reaches the model even when it never calls `engram_recall` on its own
177
+ * (the observed real-world gap). Pure rule, zero LLM, no `hits` bumps (a pure
178
+ * read). Falls back to the newest high-signal memories when the query is empty
179
+ * and `allowRecencyFallback` is on. Returns "" when nothing useful to show.
180
+ */
181
+ export function renderStartRecall(store, workspace, config = {}, opts = {}) {
182
+ if (store === null || typeof store?.recall !== "function") return "";
183
+ const limit = Math.max(1, Math.min(8, Number.isFinite(config.autoRecallLimit) ? Math.trunc(config.autoRecallLimit) : 3));
184
+ const maxChars = Number.isFinite(config.autoRecallMaxChars) && config.autoRecallMaxChars > 0 ? Math.trunc(config.autoRecallMaxChars) : 700;
185
+ const rawQuery = String(opts.query ?? "").trim().slice(0, 300);
186
+ const query = rawQuery.length > 0 ? rawQuery : (opts.allowRecencyFallback ?? true) ? null : "";
187
+ const items = query === null
188
+ ? (store.listMemories?.(workspace, limit) ?? []).slice(0, limit)
189
+ : store.recall(workspace, query, limit, { maxPerSession: limit });
190
+ const hits = Array.isArray(items) ? items.filter((m) => m !== null && m !== void 0) : [];
191
+ // "Few and precise" (agentmemory 候选①): never let a superseded stale row —
192
+ // which `recall` demotes to the tail of an unbounded list — occupy one of the
193
+ // ≤ N auto-injected slots. Same policy as the [ENGRAM] index. The stale row
194
+ // stays re-fetchable via engram_detail, just not auto-pushed.
195
+ const { supersededBy } = store.memoryRelations?.(workspace) ?? { supersededBy: new Map() };
196
+ const fresh = hits.filter((m) => !supersededBy.has(m.id));
197
+ if (fresh.length === 0) return "";
198
+
199
+ const lines = [];
200
+ const source = query === null ? "newest memory" : "recall";
201
+ lines.push(`[RECALL] ${source} · ${fresh.length} hit(s)${query ? ` · first msg: ${escapeLt(truncate(query, 40))}` : " · no user text yet"}`);
202
+ for (const m of fresh) {
203
+ const px = prefixFor(m.kind);
204
+ const mark = isProvenProcedure(m, config) ? `${px}✓` : px;
205
+ const date = fmtDate(m.createdAt ?? m.updatedAt);
206
+ const text = truncate(String(m.text).replace(/\s*\n+\s*/g, " ").trim(), 140);
207
+ lines.push(`- [${mark}] ${date} ${escapeLt(text)} #${shortId(m.id)}${(m.hits ?? 0) > 0 ? ` ×${m.hits}` : ""}`);
208
+ }
209
+ lines.push("context: use above · engram_detail <id> · engram_recall <query>");
210
+ return enforceStartRecallCap(lines, maxChars);
211
+ }
212
+
213
+ /** Keep the [RECALL] header + context tail, drop hits until under budget;
214
+ * hard-truncate with an ellipsis when even the shell exceeds the cap. */
215
+ function enforceStartRecallCap(lines, maxChars) {
216
+ let joined = lines.join("\n");
217
+ while (joined.length > maxChars && lines.length > 2) {
218
+ lines.splice(lines.length - 2, 1);
219
+ joined = lines.join("\n");
220
+ }
221
+ if (joined.length > maxChars) {
222
+ joined = `${joined.slice(0, Math.max(1, maxChars - 1))}…`;
223
+ }
224
+ return joined;
225
+ }
226
+
136
227
  /**
137
228
  * One-line escalation reminder from an arbitrary balance view
138
229
  * ({ memCalls, esrCalls, days }). Surfaces the workspace real recent
package/lib/index.js CHANGED
@@ -23,7 +23,7 @@ import { openEngramDomain } from "./store.js";
23
23
  import { registerTools } from "./tools.js";
24
24
  import { makeCaptureHandler } from "./capture.js";
25
25
  import { installGoalCapture } from "./goal-capture.js";
26
- import { renderIndex, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
26
+ import { renderIndex, renderStartRecall, firstUserMessageText, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
27
27
  import { makeTriggerRecorder } from "./trigger.js";
28
28
  import { pendingTodosFromSession, sinkPendingTodos } from "./todo-sink.js";
29
29
  import { installEngramSettings } from "./settings.js";
@@ -48,9 +48,25 @@ const DEFAULTS = {
48
48
  autoCaptureGlobalCap: 500,
49
49
  // recall
50
50
  sessionSearch: true,
51
+ // opt-in cross-workspace recall ("workspace" = current default, strict
52
+ // per-workspace isolation; "global" = when the local pool has weak/no hits,
53
+ // also rank entries from other workspaces' pools with a recency/origin-aware
54
+ // penalty and surface a [W:<workspace>] origin marker). Token-discipline
55
+ // default is preserved; this is strictly an enable knob.
56
+ recallScope: "workspace",
51
57
  // session-diversified recall: max memories per session before cross-session
52
58
  // evidence takes over (agentmemory 候选①; "few and precise")
53
59
  maxRecallPerSession: 3,
60
+ // Host-side session-start auto-recall (方案 B): at the FIRST assembly of a
61
+ // new session, key a bounded BM25 recall off the session's first user message
62
+ // and inject the top hits as a [RECALL] block — relevant memory reaches the
63
+ // model even when it never calls engram_recall on its own (the observed gap:
64
+ // pull-only recall left everything to model initiative and almost never
65
+ // fired). Pure rule, zero LLM, per-session frozen with the [ENGRAM] block.
66
+ // "few and precise" — agentmemory 候选①'s lesson from the coding leaderboard.
67
+ autoRecallOnStart: true,
68
+ autoRecallLimit: 3,
69
+ autoRecallMaxChars: 700,
54
70
  // [ENGRAM] index budget (token discipline)
55
71
  indexMaxLines: 12,
56
72
  indexMaxChars: 700,
@@ -242,7 +258,7 @@ export function apply(ctx, config) {
242
258
 
243
259
  // User settings (web GUI card): merges stored values onto the live config.
244
260
  // Non-blocking — when the settings service is absent this never runs.
245
- installEngramSettings(ctx, resolved, resolved);
261
+ installEngramSettings(ctx, resolved, resolved, service);
246
262
 
247
263
  // Web API for the GUI: deferred on the webServer service, so headless
248
264
  // profiles keep running the plugin without it. `register` returns the
@@ -282,12 +298,32 @@ export function apply(ctx, config) {
282
298
  return "";
283
299
  }
284
300
  let block = "";
301
+ let pendingRecall = false;
285
302
  try {
286
303
  const workspace = resolvePath(cwd);
287
304
  block = renderIndex(opened, workspace, cwd, resolved);
305
+ // Host-side session-start auto-recall (方案 B): append the [RECALL]
306
+ // block keyed on the session's FIRST user message. The cache only freezes
307
+ // once there is a query to key on — if the user text isn't in the events
308
+ // yet (exotic pre-message assemblies), return the index alone and let the
309
+ // next assembly retry, so a new conversation never permanently misses its
310
+ // recalled memory.
311
+ if (resolved.autoRecallOnStart !== false) {
312
+ const query = firstUserMessageText(session);
313
+ if (query.length === 0) {
314
+ pendingRecall = true;
315
+ } else {
316
+ const recall = renderStartRecall(opened, workspace, resolved, { query, allowRecencyFallback: false });
317
+ block = recall.length > 0 ? `${block}\n${recall}`.trim() : block;
318
+ }
319
+ }
288
320
  } catch (error) {
289
321
  log?.warn?.(`engram index render failed: ${String(error)}`);
290
322
  }
323
+ // Never cache a render that skipped auto-recall for lack of an opening
324
+ // message — the next assembly will have it and can still materialize the
325
+ // [RECALL] block.
326
+ if (pendingRecall) return block;
291
327
  sessionCache.set(session, block);
292
328
  return block;
293
329
  };
package/lib/settings.js CHANGED
@@ -12,16 +12,20 @@
12
12
  */
13
13
 
14
14
  import z from "@deepseek-ai/schemastery";
15
- import { settingsNamespace, installSettingsSection } from "@deepseek-ai/dsh-settings";
16
15
 
17
16
  /** Namespace join key — must match the client card's `key`. */
18
17
  export const SETTINGS_NS_NAME = "dsh-engram";
19
- export const SETTINGS_NS = settingsNamespace(SETTINGS_NS_NAME);
18
+ // dsh-settings 0.1.5 exposes the namespace as a validated string at the
19
+ // provider boundary; the legacy branding helper was removed.
20
+ export const SETTINGS_NS = SETTINGS_NS_NAME;
20
21
 
21
22
  /** Fields the GUI exposes (a curated subset of the plugin DEFAULTS). */
22
23
  export const SETTINGS_KEYS = [
23
24
  "autoCapture",
24
25
  "sessionSearch",
26
+ "recallScope",
27
+ "autoRecallOnStart",
28
+ "autoRecallLimit",
25
29
  "maxRecallPerSession",
26
30
  "autoCapturePerSession",
27
31
  "indexMaxLines",
@@ -55,6 +59,9 @@ export function makeSettingsSchema(defaults) {
55
59
  return z.object({
56
60
  autoCapture: z.boolean().default(defaults.autoCapture),
57
61
  sessionSearch: z.boolean().default(defaults.sessionSearch),
62
+ recallScope: z.union(["workspace", "global"]).default(defaults.recallScope ?? "workspace").description("召回范围:workspace=严格按当前工作区隔离(默认、token 纪律);global=本工作区命中弱/空时,从其它工作区池按 recency/origin 加权召回并标 [W:<workspace>] 来源。开启会打破跨工作区隔离,请按需选用"),
63
+ autoRecallOnStart: z.boolean().default(defaults.autoRecallOnStart).description("新会话开始时,宿主按首条消息对工作区做确定性召回,把命中记忆直接注入 [RECALL] 块(无需模型自己想起调 engram_recall;关掉=回到纯拉取)"),
64
+ autoRecallLimit: z.natural().min(1).max(8).default(defaults.autoRecallLimit).description("[RECALL] 块最多注入的记忆条数(agentmemory 候选①:少而准)"),
58
65
  maxRecallPerSession: z.natural().min(1).max(10).default(defaults.maxRecallPerSession),
59
66
  autoCapturePerSession: z.natural().max(1000).default(defaults.autoCapturePerSession),
60
67
  indexMaxLines: z.natural().max(50).default(defaults.indexMaxLines),
@@ -80,30 +87,49 @@ export function makeSettingsSchema(defaults) {
80
87
  /**
81
88
  * Wire the settings section into `ctx` and keep `live` (the plugin's mutable
82
89
  * config object) merged with what the user stored. Called from `apply()`.
90
+ *
91
+ * When a `service` object is supplied, a normalized `service.settings` seam
92
+ * ({ describe, mutate, update }) is exposed for the web API — this is the
93
+ * single host-side adapter point: both the current and the legacy DSH
94
+ * settings APIs reduce onto the same three calls, so `/api/dsh-engram/settings`
95
+ * never needs to know which generation it is talking to.
83
96
  */
84
- export function installEngramSettings(ctx, defaults, live) {
97
+ export function installEngramSettings(ctx, defaults, live, service) {
85
98
  const base = settingsBaseFrom(defaults);
86
99
  const schema = makeSettingsSchema(defaults);
87
100
  let source = () => base;
88
- installSettingsSection(ctx, SETTINGS_NS, schema, base, {
89
- setSource(current) {
90
- source = current;
91
- },
92
- onChange() {
93
- let value;
94
- try {
95
- value = source();
96
- } catch {
97
- return;
98
- }
99
- if (value === null || typeof value !== "object") return;
100
- for (const key of SETTINGS_KEYS) {
101
- if (value[key] !== void 0 && live[key] !== value[key]) live[key] = value[key];
102
- }
103
- },
104
- validate(value) {
105
- // The schema already constrains every exposed field; refuse nothing here.
106
- void value;
107
- },
101
+ ctx.inject(["settings"], (settingsCtx) => {
102
+ // dsh-settings 0.1.5 moved the optional-consumer wiring onto the provider
103
+ // instance. Keeping the registration on the scoped context preserves the
104
+ // provider attach/detach lifecycle while avoiding a removed helper export.
105
+ const provider = settingsCtx.settings;
106
+ if (service) {
107
+ service.settings = {
108
+ describe: () => provider.describe({ redactSecrets: true }),
109
+ mutate: (ns, ops, expectedRevision) => provider.mutate(ns, ops, expectedRevision),
110
+ update: (ns, patch, expectedRevision) => provider.update(ns, patch, expectedRevision),
111
+ };
112
+ }
113
+ provider.installSection(ctx, SETTINGS_NS, schema, base, {
114
+ setSource(current) {
115
+ source = current;
116
+ },
117
+ onChange() {
118
+ let value;
119
+ try {
120
+ value = source();
121
+ } catch {
122
+ return;
123
+ }
124
+ if (value === null || typeof value !== "object") return;
125
+ for (const key of SETTINGS_KEYS) {
126
+ if (value[key] !== void 0 && live[key] !== value[key]) live[key] = value[key];
127
+ }
128
+ },
129
+ validate(value) {
130
+ // The schema already constrains every exposed field; refuse nothing here.
131
+ void value;
132
+ },
133
+ });
108
134
  });
109
135
  }