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/README.md +104 -1
- package/README.zh.md +97 -1
- package/docs/ORACLE-ANALYSIS.zh.md +63 -0
- package/docs/PROPOSAL-resultpack.zh.md +135 -0
- package/docs/RELEASE-NOTES-v0.4.0.md +34 -0
- package/docs/RELEASE-NOTES.TEMPLATE.md +68 -0
- package/lib/api.js +70 -2
- package/lib/boundary-prune.js +194 -0
- package/lib/client.js +39 -26
- package/lib/index.js +20 -1
- package/lib/settings.js +43 -23
- package/package.json +17 -13
- package/scripts/dsh-engram.mjs +0 -0
- package/scripts/gen-release-notes.mjs +64 -0
- package/scripts/prepublish-check.mjs +4 -4
- package/scripts/setup-links.mjs +21 -11
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
|
|
9
|
-
* go through the same store methods the
|
|
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:
|
|
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
|
|
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 &&
|
|
3873
|
+
writable: this.opts.writable !== false && applied
|
|
3849
3874
|
};
|
|
3850
3875
|
this.notify();
|
|
3851
3876
|
} catch (error) {
|
|
3852
|
-
this.fail(`settings
|
|
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
|
-
|
|
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", "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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.
|
|
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-
|
|
63
|
-
"@deepseek-ai/dsh-client-
|
|
64
|
-
"@deepseek-ai/dsh-client-ui-
|
|
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.
|
|
73
|
-
"@deepseek-ai/dsh-storage-domain": "
|
|
74
|
-
"@deepseek-ai/dsh-settings": "
|
|
75
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
76
|
-
"@deepseek-ai/dsh-compaction": "
|
|
77
|
-
"@deepseek-ai/dsh-compaction-basic": "
|
|
78
|
-
"@deepseek-ai/dsh-llm": "
|
|
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": {
|
package/scripts/dsh-engram.mjs
CHANGED
|
File without changes
|