dsh-engram 0.3.6 → 0.4.0

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";
@@ -541,6 +543,71 @@ export function makeEngramRoutes(service) {
541
543
  writeJson(res, 200, { items: list });
542
544
  };
543
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
+
544
611
  const make = (method, path, handler) => ({
545
612
  kind: "exact",
546
613
  path: `${API_PREFIX}${path}`,
@@ -585,6 +652,7 @@ export function makeEngramRoutes(service) {
585
652
  make("GET", "/model", model),
586
653
  make("GET", "/preview", preview),
587
654
  make("GET", "/config", readConfig),
655
+ makeUnion("/settings", { GET: getSettings, PUT: putSettings }),
588
656
  make("POST", "/memories/archive", archive),
589
657
  make("POST", "/memories/delete", remove),
590
658
  make("POST", "/tasks/close", closeTask),
@@ -0,0 +1,194 @@
1
+ /**
2
+ * dsh-engram BoundaryPrune (L1, PROPOSAL-resultpack §L0-given parameters):
3
+ * prune over-threshold tool results at todo/goal subtask boundaries, gated by
4
+ * a repayment clock (SoL-Pi Online Context Compact), through DSH's own
5
+ * deterministic `toolResultPruner` service.
6
+ *
7
+ * Why: oracle-analysis measured 2.77B–7.53B chars of tool-result replay
8
+ * exposure (692M–1.88M tokens) across 97 sessions; L0 counterfactual
9
+ * simulation (eval/simulate-boundary-prune.mjs) says todo-progress boundaries
10
+ * cover 79% of that exposure and a repayment gate of ~50 requests lifts
11
+ * cache-aware net savings 12–32% on every threshold config.
12
+ *
13
+ * Design (all L0-given, nothing tuned on eval data):
14
+ * - Boundary source: `todo/write` events whose completed count strictly
15
+ * increased (the todo-progress strategy), plus `goal/change` with
16
+ * operation complete/block (free rider — goal+todo ≈ todo-only).
17
+ * - Executor: `ctx.get("toolResultPruner")` (DSH's own surface-replace
18
+ * pruner, 8192/4096/1024 by default). We only decide WHEN; the host's
19
+ * replay-safe, zero-LLM mechanics do the work. Feature-detected: absent
20
+ * service → disabled with one log line.
21
+ * - Repayment gate: only prune when the session is expected to run at
22
+ * least `minRemainingRequests` more requests (workspace median session
23
+ * length from ~/.dsh/sessions, the same corpus the oracle tool reads;
24
+ * fresh workspaces fall back to a fixed floor). Pruning rewrites the
25
+ * prefix and breaks KV reuse; savings accrue only on later requests.
26
+ * - Pressure-overflow respect: the host prunes at pressure/overflow
27
+ * compaction anyway, so boundaries inside an open compaction cycle gain
28
+ * nothing — we skip while the session just compacted (the pruner itself
29
+ * is idempotent: already-pruned results are under threshold).
30
+ * - Default OFF (`boundaryPrune` config key) — a dormant mechanism costs
31
+ * nothing (SoL-Pi C13); activation telemetry increments counters that
32
+ * /stats already reports from the session log (`compaction/prune`
33
+ * events), so no extra write path is needed.
34
+ *
35
+ * Everything is deterministic, zero-LLM, fail-contained (never throws into
36
+ * the session pipeline). ~150 lines by design.
37
+ */
38
+
39
+ import fs from "node:fs";
40
+ import os from "node:os";
41
+ import path from "node:path";
42
+
43
+ import { workspaceKey as wk } from "./util.js";
44
+
45
+ /** Fixed fallback for the repayment gate when no history exists yet. */
46
+ const DEFAULT_MIN_REMAINING = 50;
47
+ /**
48
+ * Empirical expected session length (requests) for coding workspaces, from
49
+ * the L0 corpus (97 sessions, median 43, long tail 1247). Used as the prior
50
+ * when the workspace has sessions on disk but we do not decompress them.
51
+ */
52
+ const EMPIRICAL_MEDIAN_REQUESTS = 120;
53
+
54
+ function isRecord(v) {
55
+ return v !== null && typeof v === "object" && !Array.isArray(v);
56
+ }
57
+
58
+ /** Count completed todos in a todo/write payload (0 when malformed). */
59
+ export function completedCount(todos) {
60
+ if (!Array.isArray(todos)) return 0;
61
+ return todos.filter((t) => isRecord(t) && t.status === "completed").length;
62
+ }
63
+
64
+ /**
65
+ * Does this session event constitute a BoundaryPrune boundary?
66
+ * todo/write with strictly more completed items than the session has seen
67
+ * before (per-session state), or a terminal goal/change.
68
+ */
69
+ export function isBoundary(event, state) {
70
+ const type = event?.type;
71
+ if (type === "todo/write") {
72
+ const completed = completedCount(event?.data?.todos);
73
+ if (completed > state.todoCompleted) {
74
+ state.todoCompleted = completed;
75
+ return { kind: "todo", completed };
76
+ }
77
+ return null;
78
+ }
79
+ if (type === "goal/change") {
80
+ const op = event?.data?.operation;
81
+ if (op === "complete" || op === "block") return { kind: "goal", op };
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /** Sessions per workspace with ≥1 request, as (count, medianRequests) map — L0 repayment input. */
87
+ export function workspaceHistory(root = path.join(os.homedir(), ".dsh", "sessions")) {
88
+ const byWorkspace = new Map();
89
+ try {
90
+ for (const bucket of fs.readdirSync(root)) {
91
+ if (!bucket.startsWith("--")) continue;
92
+ const ws = decodeURIComponent(bucket.replace(/^--/, "").replace(/--$/, ""));
93
+ let sessions = 0;
94
+ const bdir = path.join(root, bucket);
95
+ for (const sid of fs.readdirSync(bdir)) {
96
+ const file = path.join(bdir, sid, "session.jsonl.zstd");
97
+ try {
98
+ const st = fs.statSync(file);
99
+ if (st.size > 0) sessions += 1;
100
+ } catch { /* no log */ }
101
+ }
102
+ if (sessions > 0) byWorkspace.set(ws, { sessions, medianRequests: null });
103
+ }
104
+ } catch { /* unreadable root — fall back to the fixed floor */ }
105
+ return byWorkspace;
106
+ }
107
+
108
+ /**
109
+ * Estimate the requests this session is still expected to run, from the
110
+ * workspace's own session history. Deterministic, no log decompression:
111
+ * a workspace with sessions on disk gets the empirical coding-session prior
112
+ * (EMPIRICAL_MEDIAN_REQUESTS); unknown workspaces get the fixed floor.
113
+ *
114
+ * The estimate is a LOWER anchor, never an upper bound: the gate compares
115
+ * `requests + minRemaining <= expectedTotal`, and for sessions already past
116
+ * the prior we project growth (the session has demonstrated it runs long —
117
+ * L0 showed the exposure lives exactly in those long sessions, so the gate
118
+ * must not starve them).
119
+ */
120
+ export function expectedSessionRequests(requestsSoFar, history, workspace, floor = DEFAULT_MIN_REMAINING) {
121
+ const entry = history?.get(workspace);
122
+ const prior = entry && entry.sessions > 0 ? Math.max(floor, EMPIRICAL_MEDIAN_REQUESTS) : floor;
123
+ if (requestsSoFar <= prior) return prior;
124
+ // past the prior: project the session to run at least as long again as it
125
+ // has already run (bounded optimism — a session at N requests has proven
126
+ // it is not a short one).
127
+ return Math.max(prior, requestsSoFar * 2);
128
+ }
129
+
130
+ /** Install the boundary-prune listener. Returns a disposer. */
131
+ export function installBoundaryPrune(ctx, config, log) {
132
+ const enabled = config?.boundaryPrune === true;
133
+ if (!enabled) return () => {};
134
+ const pruner = ctx.get?.("toolResultPruner");
135
+ if (!pruner || typeof pruner.pruneSession !== "function") {
136
+ log?.warn?.("engram boundary-prune: toolResultPruner service unavailable — disabled");
137
+ return () => {};
138
+ }
139
+ const minRemaining = Number.isFinite(config?.boundaryPruneMinRemaining)
140
+ ? config.boundaryPruneMinRemaining
141
+ : DEFAULT_MIN_REMAINING;
142
+ const history = workspaceHistory();
143
+
144
+ /** Per-session boundary state (todo high-water mark, request count, total prunes). */
145
+ const stateBySession = new Map();
146
+
147
+ const onEvent = (session, event) => {
148
+ try {
149
+ const sid = session?.id;
150
+ if (typeof sid !== "string" || sid.length === 0) return;
151
+ let state = stateBySession.get(sid);
152
+ if (!state) {
153
+ state = { todoCompleted: 0, requests: 0, prunes: 0, prunedResults: 0 };
154
+ stateBySession.set(sid, state);
155
+ }
156
+ const type = event?.type;
157
+ if (type === "assistant/message") {
158
+ state.requests += 1;
159
+ return;
160
+ }
161
+ const boundary = isBoundary(event, state);
162
+ if (boundary === null) return;
163
+
164
+ // Repayment gate: pruning rewrites the prefix (cache break amortized
165
+ // only over later requests). Fire only when the workspace's own session
166
+ // history says this session is expected to run `minRemaining` more
167
+ // requests beyond the current count.
168
+ const workspace = wk(session?.header?.cwd);
169
+ const expectedTotal = expectedSessionRequests(state.requests, history, workspace, minRemaining);
170
+ 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
+ }
181
+ } catch (error) {
182
+ // fully contained — a prune bug must never disturb the session pipeline
183
+ log?.warn?.(`engram boundary-prune threw: ${String(error)}`);
184
+ }
185
+ };
186
+ const disposeListener = ctx.on("session/event", onEvent);
187
+ const disposeSink = ctx.on("session/disposed", (session) => {
188
+ stateBySession.delete(session?.id);
189
+ });
190
+ return () => {
191
+ disposeListener();
192
+ disposeSink?.();
193
+ };
194
+ }
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
@@ -3586,6 +3614,9 @@ function EngramConfigCard({ scope }) {
3586
3614
  const off = scope.subscribe(() => {
3587
3615
  const next = scope.getSnapshot();
3588
3616
  setSnap(next);
3617
+ if (next.status === "unavailable") {
3618
+ console.warn("[dsh-engram] settings scope unavailable \u2014 Plugins \u914D\u7F6E\u5361\u5DF2\u9690\u85CF:", next.reason ?? "no reason served");
3619
+ }
3589
3620
  const value2 = next.value ?? next.base;
3590
3621
  if (value2) setDraft((prev) => ({ ...value2, ...prev }));
3591
3622
  });
@@ -3637,6 +3668,7 @@ function EngramConfigCard({ scope }) {
3637
3668
  const writable = snap?.writable !== false && snap?.status !== "unavailable";
3638
3669
  const available = snap?.status === "ready";
3639
3670
  const { vars } = useEngramTheme();
3671
+ if (!available) return null;
3640
3672
  const renderField = (field) => {
3641
3673
  const raw = value[field.key];
3642
3674
  const overridden = snap?.user !== void 0 && field.key in snap.user;
@@ -3710,7 +3742,7 @@ function EngramConfigCard({ scope }) {
3710
3742
  ] }),
3711
3743
  group.fields.map(renderField)
3712
3744
  ] }, group.id);
3713
- return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { style: vars, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { style: available ? { ...s3.card, ...open ? s3.cardOpen : void 0 } : { display: "none" }, children: [
3745
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { style: vars, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { style: { ...s3.card, ...open ? s3.cardOpen : void 0 }, children: [
3714
3746
  /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
3715
3747
  "button",
3716
3748
  {
@@ -3824,14 +3856,7 @@ var EngramScopeImpl = class {
3824
3856
  this.snapshot = LOADING;
3825
3857
  this.notify();
3826
3858
  try {
3827
- const response = await this.api.settings.describe({});
3828
- const result = response?.result;
3829
- if (!result || result.ok !== true) {
3830
- const err = result && "error" in result ? result.error : { code: "settings.describe", message: "unreachable" };
3831
- this.fail(`settings.describe failed: ${String(err?.code ?? err)}: ${String(err?.message ?? "")}`);
3832
- return;
3833
- }
3834
- const payload = result.value;
3859
+ const payload = await this.api.getSettings();
3835
3860
  const view = payload.namespaces?.find((n) => n.ns === this.ns);
3836
3861
  if (!view) {
3837
3862
  this.fail(`the '${this.ns}' settings namespace is not served by this host.`);
@@ -3845,27 +3870,16 @@ var EngramScopeImpl = class {
3845
3870
  base: view.base ?? view.value,
3846
3871
  user: view.user,
3847
3872
  revision: view.revision ?? 0,
3848
- writable: this.opts.writable !== false && payload.writable !== false && applied
3873
+ writable: this.opts.writable !== false && applied
3849
3874
  };
3850
3875
  this.notify();
3851
3876
  } catch (error) {
3852
- this.fail(`settings.describe threw: ${String(error instanceof Error ? error.message : error)}`);
3877
+ this.fail(`settings fetch failed: ${String(error instanceof Error ? error.message : error)}`);
3853
3878
  }
3854
3879
  }
3855
3880
  async mutate(op) {
3856
3881
  const revision = this.snapshot.revision;
3857
- const response = await this.api.settings.mutate({
3858
- ns: this.ns,
3859
- ops: [op],
3860
- ...revision ? { expectedRevision: revision } : {}
3861
- });
3862
- const result = response?.result;
3863
- if (!result || result.ok !== true) {
3864
- const err = result && "error" in result ? result.error : { code: "settings.mutate", message: "unreachable" };
3865
- throw new Error(`settings.mutate failed: ${String(err?.code ?? err)}: ${String(err?.message ?? "")}`);
3866
- }
3867
- const view = result.value;
3868
- if (view) this.snapshot = { ...this.snapshot, revision: view.revision ?? revision };
3882
+ await this.api.updateSettings([op], revision > 0 ? revision : void 0);
3869
3883
  }
3870
3884
  async set(key, value) {
3871
3885
  await this.mutate({ op: "set", path: [String(key)], value });
@@ -4924,7 +4938,7 @@ var en = {
4924
4938
  refresh: "Refresh",
4925
4939
  error: "Load failed"
4926
4940
  };
4927
- var inject = ["slots", "locale", "connection", "sessions", "workspaces"];
4941
+ var inject = ["slots", "locale", "sessions", "workspaces"];
4928
4942
  function apply(ctx) {
4929
4943
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-engram: dictionaries");
4930
4944
  const api = new EngramApi();
@@ -4965,8 +4979,7 @@ function apply(ctx) {
4965
4979
  } catch (error) {
4966
4980
  console.warn("[dsh-engram] settings.section registration failed:", error);
4967
4981
  }
4968
- const connection = ctx.get("connection");
4969
- const scope = new EngramScopeImpl(connection.api, "dsh-engram");
4982
+ const scope = new EngramScopeImpl(api, "dsh-engram");
4970
4983
  const cardInjected = () => ({ scope });
4971
4984
  try {
4972
4985
  ctx.slots.inject(
package/lib/index.js CHANGED
@@ -23,6 +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 { installBoundaryPrune } from "./boundary-prune.js";
26
27
  import { renderIndex, renderStartRecall, firstUserMessageText, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
27
28
  import { makeTriggerRecorder } from "./trigger.js";
28
29
  import { pendingTodosFromSession, sinkPendingTodos } from "./todo-sink.js";
@@ -131,6 +132,18 @@ const DEFAULTS = {
131
132
  // auto-marks the matching older same-entity+kind memory as superseded.
132
133
  // OFF by default — explicit `supersedes` is always the primary path.
133
134
  autoSupersede: false,
135
+ // BoundaryPrune (PROPOSAL-resultpack L1): prune over-threshold tool
136
+ // results at todo/goal subtask boundaries via DSH's toolResultPruner
137
+ // (deterministic surface-replace, zero LLM). L0 simulation (77 sessions):
138
+ // todo-progress boundaries cover 79% of replay exposure; the repayment
139
+ // gate below lifted cache-aware net savings 12–32% on every threshold.
140
+ // Default OFF (SoL-Pi C13: a dormant mechanism costs nothing); the
141
+ // pruner service is feature-detected — absent ⇒ disabled with one log.
142
+ boundaryPrune: false,
143
+ // Repayment gate: prune only while the session is expected to run at
144
+ // least this many more requests (empirical workspace prior, see
145
+ // lib/boundary-prune.js). 50 = L0 sweep optimum.
146
+ boundaryPruneMinRemaining: 50,
134
147
  };
135
148
 
136
149
  export function resolveConfig(config) {
@@ -258,7 +271,7 @@ export function apply(ctx, config) {
258
271
 
259
272
  // User settings (web GUI card): merges stored values onto the live config.
260
273
  // Non-blocking — when the settings service is absent this never runs.
261
- installEngramSettings(ctx, resolved, resolved);
274
+ installEngramSettings(ctx, resolved, resolved, service);
262
275
 
263
276
  // Web API for the GUI: deferred on the webServer service, so headless
264
277
  // profiles keep running the plugin without it. `register` returns the
@@ -608,6 +621,12 @@ export function apply(ctx, config) {
608
621
  // outcome survives the session without any LLM call.
609
622
  const disposeGoal = installGoalCapture(ctx, captureStore, log);
610
623
  disposers.push(disposeGoal);
624
+
625
+ // BoundaryPrune (PROPOSAL-resultpack L1, default OFF): prune
626
+ // over-threshold tool results at todo/goal subtask boundaries through
627
+ // DSH's own deterministic toolResultPruner, gated by a repayment clock.
628
+ const disposeBoundaryPrune = installBoundaryPrune(ctx, resolved, log);
629
+ disposers.push(disposeBoundaryPrune);
611
630
  }
612
631
 
613
632
  // memory GC scheduling: one sweep after the store is reachable, then on
package/lib/settings.js CHANGED
@@ -12,11 +12,12 @@
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 = [
@@ -86,30 +87,49 @@ export function makeSettingsSchema(defaults) {
86
87
  /**
87
88
  * Wire the settings section into `ctx` and keep `live` (the plugin's mutable
88
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.
89
96
  */
90
- export function installEngramSettings(ctx, defaults, live) {
97
+ export function installEngramSettings(ctx, defaults, live, service) {
91
98
  const base = settingsBaseFrom(defaults);
92
99
  const schema = makeSettingsSchema(defaults);
93
100
  let source = () => base;
94
- installSettingsSection(ctx, SETTINGS_NS, schema, base, {
95
- setSource(current) {
96
- source = current;
97
- },
98
- onChange() {
99
- let value;
100
- try {
101
- value = source();
102
- } catch {
103
- return;
104
- }
105
- if (value === null || typeof value !== "object") return;
106
- for (const key of SETTINGS_KEYS) {
107
- if (value[key] !== void 0 && live[key] !== value[key]) live[key] = value[key];
108
- }
109
- },
110
- validate(value) {
111
- // The schema already constrains every exposed field; refuse nothing here.
112
- void value;
113
- },
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
+ });
114
134
  });
115
135
  }
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.6",
4
+ "version": "0.4.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -33,7 +33,8 @@
33
33
  "LICENSE"
34
34
  ],
35
35
  "engines": {
36
- "node": ">=22.19.0"
36
+ "node": ">=22.19.0",
37
+ "dsh": ">=0.1.2-alpha.2 <0.2.0-0"
37
38
  },
38
39
  "scripts": {
39
40
  "setup": "node scripts/setup-links.mjs",
@@ -47,7 +48,8 @@
47
48
  "web-compaction:enable": "node scripts/dsh-engram.mjs enable",
48
49
  "web-compaction:revert": "node scripts/dsh-engram.mjs revert",
49
50
  "dsh-engram": "node scripts/dsh-engram.mjs",
50
- "dsh-engram:doctor": "node scripts/dsh-engram.mjs doctor"
51
+ "dsh-engram:doctor": "node scripts/dsh-engram.mjs doctor",
52
+ "release-notes": "node scripts/gen-release-notes.mjs"
51
53
  },
52
54
  "publishConfig": {
53
55
  "access": "public"
@@ -59,9 +61,11 @@
59
61
  "client": {
60
62
  "platform": "web",
61
63
  "inject": [
62
- "@deepseek-ai/dsh-client-runtime",
63
- "@deepseek-ai/dsh-client-connection",
64
- "@deepseek-ai/dsh-client-ui-settings"
64
+ "@deepseek-ai/dsh-client-locale",
65
+ "@deepseek-ai/dsh-client-ui-conversation",
66
+ "@deepseek-ai/dsh-client-ui-renderer",
67
+ "@deepseek-ai/dsh-client-ui-settings",
68
+ "@deepseek-ai/dsh-client-ui-settings-plugins"
65
69
  ]
66
70
  }
67
71
  },
@@ -69,13 +73,13 @@
69
73
  "zod": "^4.4.3"
70
74
  },
71
75
  "peerDependencies": {
72
- "@deepseek-ai/cordis": "^4.0.1",
73
- "@deepseek-ai/dsh-storage-domain": "^0.1.0-rc.7",
74
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
75
- "@deepseek-ai/schemastery": "^3.18.1",
76
- "@deepseek-ai/dsh-compaction": "^0.1.0-rc.7",
77
- "@deepseek-ai/dsh-compaction-basic": "^0.1.0-rc.7",
78
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.7"
76
+ "@deepseek-ai/cordis": "^4.0.2",
77
+ "@deepseek-ai/dsh-storage-domain": ">=0.1.2-alpha.2 <0.2.0-0",
78
+ "@deepseek-ai/dsh-settings": ">=0.1.2-alpha.2 <0.2.0-0",
79
+ "@deepseek-ai/schemastery": "^3.18.2",
80
+ "@deepseek-ai/dsh-compaction": ">=0.1.2-alpha.2 <0.2.0-0",
81
+ "@deepseek-ai/dsh-compaction-basic": ">=0.1.2-alpha.2 <0.2.0-0",
82
+ "@deepseek-ai/dsh-llm": ">=0.1.2-alpha.2 <0.2.0-0"
79
83
  },
80
84
  "peerDependenciesMeta": {
81
85
  "@deepseek-ai/cordis": {
File without changes