dsh-engram 0.2.0 → 0.3.2
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 +115 -5
- package/README.zh.md +60 -4
- package/lib/api.js +91 -10
- package/lib/capture.js +11 -7
- package/lib/client.js +4590 -1057
- package/lib/index-block.js +163 -27
- package/lib/index.js +43 -1
- package/lib/mental.js +170 -0
- package/lib/obs.js +71 -0
- package/lib/rerank.js +50 -0
- package/lib/settings.js +3 -0
- package/lib/store.js +330 -32
- package/lib/tools.js +349 -62
- package/lib/trigger.js +379 -0
- package/lib/usage.js +224 -60
- package/lib/util.js +107 -0
- package/package.json +3 -7
package/lib/index-block.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* cache reuse.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { label, shortId, fmtDate, escapeLt, truncate } from "./util.js";
|
|
12
|
+
import { label, shortId, fmtDate, escapeLt, truncate, byRecency } from "./util.js";
|
|
13
13
|
|
|
14
14
|
/** Deterministic index line prefix per memory kind. */
|
|
15
15
|
export function prefixFor(kind) {
|
|
@@ -37,10 +37,17 @@ function isGitPlumbingEcho(m) {
|
|
|
37
37
|
return /(^|\s)(cd\s|\&\&|;|\|)/.test(text);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** A procedure that has been used (hit) enough times to count as proven. */
|
|
41
|
+
export function isProvenProcedure(m, config) {
|
|
42
|
+
return (m?.kind ?? "") === "procedure" && (m?.hits ?? 0) >= (config?.promoteHits ?? 3);
|
|
43
|
+
}
|
|
44
|
+
|
|
40
45
|
/**
|
|
41
46
|
* Select the memories that earn an index line: knowingly stored high-signal
|
|
42
47
|
* entries, auto-captures past minIndexSignal, and low-signal entries promoted
|
|
43
|
-
* by repeated recall (hits >= promoteHits).
|
|
48
|
+
* by repeated recall (hits >= promoteHits). Proven procedures (a Skill-ish
|
|
49
|
+
* asset: a workflow validated by repeated use) are ranked first, stably —
|
|
50
|
+
* each group still newest-first so the block stays deterministic per session.
|
|
44
51
|
*/
|
|
45
52
|
export function selectIndexMemories(store, workspace, config) {
|
|
46
53
|
const all = store.listMemories(workspace, 200);
|
|
@@ -53,14 +60,21 @@ export function selectIndexMemories(store, workspace, config) {
|
|
|
53
60
|
(m.hits ?? 0) >= config.promoteHits ||
|
|
54
61
|
m.kind === "task"),
|
|
55
62
|
)
|
|
63
|
+
.sort((a, b) => {
|
|
64
|
+
const ap = isProvenProcedure(a, config) ? 0 : 1;
|
|
65
|
+
const bp = isProvenProcedure(b, config) ? 0 : 1;
|
|
66
|
+
if (ap !== bp) return ap - bp;
|
|
67
|
+
return byRecency(a, b);
|
|
68
|
+
})
|
|
56
69
|
.slice(0, config.indexMaxLines);
|
|
57
70
|
}
|
|
58
71
|
|
|
59
|
-
function memoryLine(m) {
|
|
72
|
+
function memoryLine(m, config) {
|
|
60
73
|
const px = prefixFor(m.kind);
|
|
74
|
+
const mark = isProvenProcedure(m, config) ? `${px}✓` : px;
|
|
61
75
|
const date = fmtDate(m.createdAt ?? m.updatedAt);
|
|
62
76
|
const text = truncate(String(m.text).replace(/\s*\n+\s*/g, " ").trim(), INDEX_LINE_CHARS);
|
|
63
|
-
return `[${
|
|
77
|
+
return `[${mark}] ${date} ${escapeLt(text)} #${shortId(m.id)}`;
|
|
64
78
|
}
|
|
65
79
|
|
|
66
80
|
function taskStateLine(t) {
|
|
@@ -95,7 +109,7 @@ export function renderIndex(store, workspace, cwd, config) {
|
|
|
95
109
|
lines.push(`[ENGRAM] workspace: ${label(cwd)} · ${entries.memories} memories · ${tasks.length} task(s) active · ${links} links · ${entries.nodes} node(s)`);
|
|
96
110
|
const entityNames = store.listEntities(workspace).map((e) => e.name);
|
|
97
111
|
if (entityNames.length > 0) lines.push(`nodes: ${truncate(entityNames.join(", "), 72)}`);
|
|
98
|
-
for (const m of memories.slice(0, config.indexMaxLines)) lines.push(memoryLine(m));
|
|
112
|
+
for (const m of memories.slice(0, config.indexMaxLines)) lines.push(memoryLine(m, config));
|
|
99
113
|
for (const t of tasks.slice(0, 4)) lines.push(taskStateLine(t));
|
|
100
114
|
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)");
|
|
101
115
|
return enforceCharCap(lines, config.indexMaxChars);
|
|
@@ -116,16 +130,36 @@ function enforceCharCap(lines, maxChars) {
|
|
|
116
130
|
}
|
|
117
131
|
|
|
118
132
|
/**
|
|
119
|
-
* One-line
|
|
120
|
-
*
|
|
121
|
-
* its own under-proactivity
|
|
122
|
-
* Returns "" when there is nothing to correct (no data,
|
|
123
|
-
* or the balance is healthy) — the reminder disappears
|
|
124
|
-
* improves, closing the loop.
|
|
133
|
+
* One-line escalation reminder from an arbitrary balance view
|
|
134
|
+
* ({ memCalls, esrCalls, days }). Surfaces the workspace real recent
|
|
135
|
+
* mem-vs-esr balance so the model sees its own under-proactivity and can
|
|
136
|
+
* self-correct NOW. Returns "" when there is nothing to correct (no data,
|
|
137
|
+
* too small a sample, or the balance is healthy) — the reminder disappears
|
|
138
|
+
* once behaviour improves, closing the loop. Pure, deterministic.
|
|
125
139
|
*/
|
|
126
|
-
export function
|
|
140
|
+
export function esrBalanceHint(balance) {
|
|
141
|
+
if (balance === void 0 || balance === null) return "";
|
|
142
|
+
const memCalls = balance.memCalls ?? 0;
|
|
143
|
+
const esrCalls = balance.esrCalls ?? 0;
|
|
144
|
+
const days = balance.days ?? 0;
|
|
145
|
+
// No data or too little signal — lecturing on <3 memory ops is noise.
|
|
146
|
+
if (memCalls < 3 || days < 1) return "";
|
|
147
|
+
const ratio = esrCalls / (memCalls + esrCalls);
|
|
148
|
+
// Healthy once esr exceeds ~a third of memory-ops volume; otherwise nudge.
|
|
149
|
+
if (ratio >= 0.34) return "";
|
|
150
|
+
return `escalate: last ${days}d ${memCalls} mem ops vs ${esrCalls} esr calls (${Math.round(ratio * 100)}%) — multi-step work → esr_task now, recurring objects → esr_node, related things → esr_link, and close with evidence (esr_close) #suggest-escalate`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Balance view derived from the per-(workspace,day) `usage` table (legacy).
|
|
155
|
+
* The table is no longer written in production (assessment P3 moved
|
|
156
|
+
* observability to on-demand session-log scans); this path exists for
|
|
157
|
+
* backward compatibility and store-driven tests. Production prefers the
|
|
158
|
+
* live in-memory recorder view (lib/trigger.js) so the nudge is not starved.
|
|
159
|
+
*/
|
|
160
|
+
export function usageBalanceView(store, workspace) {
|
|
127
161
|
const rows = store.usageRows(workspace);
|
|
128
|
-
if (rows.length === 0) return
|
|
162
|
+
if (rows.length === 0) return null;
|
|
129
163
|
const recent = rows.slice(-14);
|
|
130
164
|
const MEM = ["engram_store", "engram_recall", "engram_detail", "loom_store", "loom_recall", "loom_detail"];
|
|
131
165
|
const ESR = ["esr_task", "esr_node", "esr_close", "esr_link", "esr_gc"];
|
|
@@ -136,44 +170,146 @@ export function escalationHint(store, workspace) {
|
|
|
136
170
|
for (const k of MEM) memCalls += c[k] ?? 0;
|
|
137
171
|
for (const k of ESR) esrCalls += c[k] ?? 0;
|
|
138
172
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
173
|
+
return { memCalls, esrCalls, days: recent.length };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Legacy entry point (store-driven escalation nudge). Kept for callers/tests
|
|
178
|
+
* that predate the decision-point recorder; prefer `esrBalanceHint` with a
|
|
179
|
+
* live balance view.
|
|
180
|
+
*/
|
|
181
|
+
export function escalationHint(store, workspace) {
|
|
182
|
+
return esrBalanceHint(usageBalanceView(store, workspace));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The three ESR closure evidence gates for a task. Null/empty counts as a gap.
|
|
187
|
+
*/
|
|
188
|
+
export function evidenceGaps(t) {
|
|
189
|
+
const gaps = [];
|
|
190
|
+
if (t.artifact === null || t.artifact === "") gaps.push("artifact");
|
|
191
|
+
if (t.evaluation === null || t.evaluation === "") gaps.push("evaluation");
|
|
192
|
+
if (t.memoryRefs.length === 0) gaps.push("memory_ref");
|
|
193
|
+
return gaps;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* "What should I do with this task next" sort rank — READY-to-close first
|
|
198
|
+
* (evidence complete → esr_close is the highest-value next move), then claimed
|
|
199
|
+
* in-progress, then other active, then drafts.
|
|
200
|
+
*/
|
|
201
|
+
function taskNextRank(t) {
|
|
202
|
+
if (t.state === "stable") return 10;
|
|
203
|
+
if (evidenceGaps(t).length === 0) return 0;
|
|
204
|
+
if (t.assignee) return 1;
|
|
205
|
+
if (t.state === "active") return 2;
|
|
206
|
+
if (t.state === "draft") return 3;
|
|
207
|
+
return 4;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Order active tasks so the model can act top-down without re-scanning. */
|
|
211
|
+
export function sortActiveForNext(tasks) {
|
|
212
|
+
return [...tasks].sort((a, b) => {
|
|
213
|
+
const ra = taskNextRank(a);
|
|
214
|
+
const rb = taskNextRank(b);
|
|
215
|
+
if (ra !== rb) return ra - rb;
|
|
216
|
+
const ga = evidenceGaps(a).length;
|
|
217
|
+
const gb = evidenceGaps(b).length;
|
|
218
|
+
if (ga !== gb) return ga - gb;
|
|
219
|
+
return (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Recurring-failure candidates (P0-B): error memories that earned
|
|
225
|
+
* `hits >= minErrorHits` through repeat-failure revival — durable breakage,
|
|
226
|
+
* the strongest "turn this into a root-cause task" signal. Most-hit first.
|
|
227
|
+
*/
|
|
228
|
+
export function selectRootCauseCandidates(store, workspace, config) {
|
|
229
|
+
const min = (config?.minErrorHits ?? 2) | 0;
|
|
230
|
+
if (min < 1) return [];
|
|
231
|
+
return store
|
|
232
|
+
.listMemories(workspace, 200)
|
|
233
|
+
.filter((m) => m.kind === "error" && (m.hits ?? 0) >= min)
|
|
234
|
+
.sort((a, b) => (b.hits ?? 0) - (a.hits ?? 0) || (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
|
235
|
+
.map((m) => ({ id: m.id, text: String(m.text), hits: m.hits ?? 0 }));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Stale-task candidates (P3): active tasks that have not been touched for
|
|
240
|
+
* `staleTaskDays` (default 14) — close with evidence or drop the intent.
|
|
241
|
+
*/
|
|
242
|
+
export function selectStaleCandidates(tasks, config) {
|
|
243
|
+
const days = (config?.staleTaskDays ?? 14) | 0;
|
|
244
|
+
if (days < 1) return [];
|
|
245
|
+
const cutoff = Date.now() - days * 86_400_000;
|
|
246
|
+
return tasks
|
|
247
|
+
.filter((t) => (t.updatedAt ?? t.createdAt ?? 0) < cutoff)
|
|
248
|
+
.map((t) => ({ id: t.id, name: t.name, updatedAt: t.updatedAt ?? t.createdAt, createdAt: t.createdAt }));
|
|
145
249
|
}
|
|
146
250
|
|
|
147
251
|
/**
|
|
148
252
|
* Render the [ESR] block — active tasks with closure gaps. Only appears when
|
|
149
253
|
* at least one task exists. "STABLE" tasks are folded to a count line to save
|
|
150
254
|
* tokens.
|
|
255
|
+
*
|
|
256
|
+
* @param opts {{
|
|
257
|
+
* recorder?: import("./trigger.js").TriggerRecorder, // decision-point recorder (P0-A–P3)
|
|
258
|
+
* sessionId?: string, // live session for the hints
|
|
259
|
+
* }}
|
|
151
260
|
*/
|
|
152
|
-
export function renderEsr(store, workspace, config) {
|
|
261
|
+
export function renderEsr(store, workspace, config, opts = {}) {
|
|
262
|
+
const recorder = opts?.recorder;
|
|
263
|
+
const sessionId = opts?.sessionId;
|
|
264
|
+
const live = recorder !== void 0 && sessionId !== void 0;
|
|
153
265
|
const active = store.listTasks(workspace, { includeStable: false });
|
|
154
266
|
const stable = store.listTasks(workspace, { includeStable: true }).filter((t) => t.state === "stable");
|
|
155
|
-
const
|
|
267
|
+
const ordered = sortActiveForNext(active);
|
|
268
|
+
// Prefer the live in-memory balance (recorder); fall back to the legacy
|
|
269
|
+
// usage-table view so store-driven callers/tests keep working unchanged.
|
|
270
|
+
const balance = recorder !== void 0 ? recorder.recentBalance(workspace) : usageBalanceView(store, workspace);
|
|
271
|
+
const hint = esrBalanceHint(balance);
|
|
272
|
+
const promote = live ? recorder.promoteHint(sessionId, workspace, active.length) : "";
|
|
273
|
+
const rootCause = live ? recorder.errorHint(sessionId, workspace, selectRootCauseCandidates(store, workspace, config)) : "";
|
|
274
|
+
const closure = live ? recorder.closureHint(sessionId, workspace, ordered.filter((t) => evidenceGaps(t).length === 0)) : "";
|
|
275
|
+
const stale = live ? recorder.staleHint(sessionId, workspace, selectStaleCandidates(active, config)) : "";
|
|
276
|
+
// P4: count only what is actually injected for a live session (never the
|
|
277
|
+
// GUI preview, which renders the same lines without a sessionId).
|
|
278
|
+
if (live) {
|
|
279
|
+
if (promote !== "") recorder.emitHint("promote", workspace, sessionId);
|
|
280
|
+
if (rootCause !== "") recorder.emitHint("rootcause", workspace, sessionId);
|
|
281
|
+
if (closure !== "") recorder.emitHint("close", workspace, sessionId);
|
|
282
|
+
if (stale !== "") recorder.emitHint("stale", workspace, sessionId);
|
|
283
|
+
if (hint !== "") recorder.emitHint("escalate", workspace, sessionId);
|
|
284
|
+
}
|
|
285
|
+
|
|
156
286
|
if (active.length === 0 && stable.length === 0) {
|
|
157
287
|
// Keep the mechanism visible even when idle: one compact line teaches the
|
|
158
288
|
// model that multi-step work earns an esr_task closed with real evidence.
|
|
159
289
|
const base = "[ESR] no open tasks — BE PROACTIVE: multi-step work gets a task now (esr_task); recurring domain objects get a node (esr_node); related things get a link (esr_link). Close tasks with real evidence (esr_close)";
|
|
160
|
-
|
|
290
|
+
const rest = [promote, rootCause, hint].filter((l) => l !== "");
|
|
291
|
+
return rest.length === 0 ? base : `${base}\n${rest.join("\n")}`;
|
|
161
292
|
}
|
|
162
293
|
|
|
163
294
|
const lines = [];
|
|
164
295
|
lines.push(`[ESR] tasks: ${active.length} active / ${stable.length} stable`);
|
|
165
|
-
|
|
296
|
+
const firstReady = ordered.find((t) => evidenceGaps(t).length === 0);
|
|
297
|
+
if (firstReady !== void 0) {
|
|
298
|
+
lines.push(`next: esr_close ${firstReady.id.slice(0, 6)} — ${escapeLt(truncate(firstReady.name, 40))} (all evidence present)`);
|
|
299
|
+
}
|
|
300
|
+
for (const t of ordered.slice(0, 8)) lines.push(`- ${t.id.slice(0, 6)}: ${escapeLt(truncate(t.name, 60))} — ${t.state.toUpperCase()}${taskGapSuffix(t)}`);
|
|
166
301
|
if (active.length > 8) lines.push(`- … ${active.length - 8} more`);
|
|
167
302
|
if (stable.length > 0) lines.push(`- closed: ${stable.slice(0, 6).map((t) => `${t.id.slice(0, 6)} (${truncate(t.name, 28)})`).join(" · ")}${stable.length > 6 ? ` +${stable.length - 6}` : ""}`);
|
|
303
|
+
if (promote !== "") lines.push(promote);
|
|
304
|
+
if (rootCause !== "") lines.push(rootCause);
|
|
305
|
+
if (closure !== "") lines.push(closure);
|
|
306
|
+
if (stale !== "") lines.push(stale);
|
|
168
307
|
if (hint !== "") lines.push(hint);
|
|
169
308
|
return lines.join("\n");
|
|
170
309
|
}
|
|
171
310
|
|
|
172
311
|
function taskGapSuffix(t) {
|
|
173
312
|
if (t.state === "stable") return "";
|
|
174
|
-
const gaps =
|
|
175
|
-
if (t.artifact === null || t.artifact === "") gaps.push("artifact");
|
|
176
|
-
if (t.evaluation === null || t.evaluation === "") gaps.push("evaluation");
|
|
177
|
-
if (t.memoryRefs.length === 0) gaps.push("memory_ref");
|
|
313
|
+
const gaps = evidenceGaps(t);
|
|
178
314
|
return gaps.length === 0 ? " · READY to close" : ` · gap: ${gaps.join(", ")}`;
|
|
179
315
|
}
|
package/lib/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { openEngramDomain } from "./store.js";
|
|
|
21
21
|
import { registerTools } from "./tools.js";
|
|
22
22
|
import { makeCaptureHandler } from "./capture.js";
|
|
23
23
|
import { renderIndex, renderEsr } from "./index-block.js";
|
|
24
|
+
import { makeTriggerRecorder } from "./trigger.js";
|
|
24
25
|
import { installEngramSettings } from "./settings.js";
|
|
25
26
|
import { makeEngramRoutes } from "./api.js";
|
|
26
27
|
|
|
@@ -39,6 +40,9 @@ const DEFAULTS = {
|
|
|
39
40
|
autoCaptureGlobalCap: 500,
|
|
40
41
|
// recall
|
|
41
42
|
sessionSearch: true,
|
|
43
|
+
// session-diversified recall: max memories per session before cross-session
|
|
44
|
+
// evidence takes over (agentmemory 候选①; "few and precise")
|
|
45
|
+
maxRecallPerSession: 3,
|
|
42
46
|
// [ENGRAM] index budget (token discipline)
|
|
43
47
|
indexMaxLines: 12,
|
|
44
48
|
indexMaxChars: 700,
|
|
@@ -56,9 +60,23 @@ const DEFAULTS = {
|
|
|
56
60
|
// GUI access: hostnames allowed past the loopback fence for /api/dsh-engram
|
|
57
61
|
// (e.g. an operator-authorized cloudflare tunnel). Empty = loopback-only.
|
|
58
62
|
trustedHosts: [],
|
|
63
|
+
// ESR evidence hardening: a non-URL artifact must exist on disk (relative
|
|
64
|
+
// artifacts resolve against the workspace = the session cwd DSH provides).
|
|
65
|
+
// esr_close's force:true bypasses this existence check.
|
|
66
|
+
verifyArtifact: true,
|
|
59
67
|
// prompt section order (before the persona/tools bands per DSH convention)
|
|
60
68
|
engramIndexOrder: 40,
|
|
61
69
|
esrOrder: 41,
|
|
70
|
+
// decision-point triggers (P0-A): promote a session plan to an ESR task
|
|
71
|
+
// when the pending-todo count is at least minTodosForPromote and exceeds
|
|
72
|
+
// active ESR tasks × todoPromoteRatio (default: pending > active).
|
|
73
|
+
minTodosForPromote: 2,
|
|
74
|
+
todoPromoteRatio: 1,
|
|
75
|
+
// P0-B: an error memory becomes a root-cause task candidate at this many
|
|
76
|
+
// repeat-failure hits.
|
|
77
|
+
minErrorHits: 2,
|
|
78
|
+
// P3: an active task with no update for this many days is surfaced as stale.
|
|
79
|
+
staleTaskDays: 14,
|
|
62
80
|
};
|
|
63
81
|
|
|
64
82
|
function resolveConfig(config) {
|
|
@@ -120,10 +138,15 @@ export function apply(ctx, config) {
|
|
|
120
138
|
return domainPromise;
|
|
121
139
|
};
|
|
122
140
|
|
|
141
|
+
// Decision-point triggers (P0-A + P1): live mem-vs-esr balance + the
|
|
142
|
+
// todo_write promote hook. Process-local; fed by a tools/result listener.
|
|
143
|
+
const recorder = makeTriggerRecorder(resolved);
|
|
144
|
+
|
|
123
145
|
const service = {
|
|
124
146
|
config: resolved,
|
|
125
147
|
getDomain,
|
|
126
148
|
openedDomain: () => opened,
|
|
149
|
+
recorder,
|
|
127
150
|
ensureDomain: () => {
|
|
128
151
|
getDomain().catch(() => {});
|
|
129
152
|
},
|
|
@@ -142,6 +165,15 @@ export function apply(ctx, config) {
|
|
|
142
165
|
return "";
|
|
143
166
|
}
|
|
144
167
|
},
|
|
168
|
+
/** Recompute the exact [ESR] block for a workspace (GUI inject preview). */
|
|
169
|
+
renderEsrBlock(workspace) {
|
|
170
|
+
if (opened === void 0) return "";
|
|
171
|
+
try {
|
|
172
|
+
return renderEsr(opened, workspace, resolved, { recorder });
|
|
173
|
+
} catch {
|
|
174
|
+
return "";
|
|
175
|
+
}
|
|
176
|
+
},
|
|
145
177
|
};
|
|
146
178
|
|
|
147
179
|
// Mechanical, archive-only memory GC (pi-esr constraints). Runs the first
|
|
@@ -250,7 +282,10 @@ export function apply(ctx, config) {
|
|
|
250
282
|
if (cwd === void 0 || cwd.length === 0) return "";
|
|
251
283
|
if (opened === void 0) return "";
|
|
252
284
|
try {
|
|
253
|
-
return renderEsr(opened, resolvePath(cwd), resolved
|
|
285
|
+
return renderEsr(opened, resolvePath(cwd), resolved, {
|
|
286
|
+
recorder,
|
|
287
|
+
sessionId: session.id,
|
|
288
|
+
});
|
|
254
289
|
} catch {
|
|
255
290
|
return "";
|
|
256
291
|
}
|
|
@@ -259,6 +294,13 @@ export function apply(ctx, config) {
|
|
|
259
294
|
disposers.push(() => esrDispose());
|
|
260
295
|
}
|
|
261
296
|
|
|
297
|
+
// decision-point triggers: always on (independent of autoCapture) —
|
|
298
|
+
// feeds the live mem-vs-esr balance and the todo_write promote hint.
|
|
299
|
+
const disposeTrigger = ctx.on("tools/result", (exec, result) => {
|
|
300
|
+
recorder.handle(exec, result);
|
|
301
|
+
});
|
|
302
|
+
disposers.push(disposeTrigger);
|
|
303
|
+
|
|
262
304
|
// zero-LLM auto-capture
|
|
263
305
|
if (resolved.autoCapture) {
|
|
264
306
|
// The capture handler needs a storeMemory face, but the domain opens
|
package/lib/mental.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mental models — precomputed standing answers over a workspace.
|
|
3
|
+
*
|
|
4
|
+
* Hindsight lets you define a question once and rewrites the answer in the
|
|
5
|
+
* background as the bank learns; reading one is a DB read, no LLM call, so an
|
|
6
|
+
* agent boots with a page of settled knowledge instead of rediscovering it
|
|
7
|
+
* every session. This is the zero-LLM analogue: `compileSummary` aggregates
|
|
8
|
+
* the workspace's tasks / entity graph / observations / memory kinds into a
|
|
9
|
+
* compact markdown blob, `getModel` serves the cached copy and recomputes only
|
|
10
|
+
* when a write happened (`dirty`), the input set changed (`sources_hash`) or
|
|
11
|
+
* the cache went stale (>10 min, a safety net for missed bumps).
|
|
12
|
+
*
|
|
13
|
+
* Pure + deterministic: `now` injectable, no model calls.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { hashText } from "./util.js";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_MAX_AGE_MS = 10 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
/** Escape a name for markdown (pipes/newlines). */
|
|
21
|
+
function mdCell(value) {
|
|
22
|
+
return String(value ?? "")
|
|
23
|
+
.replace(/\|/g, "\\|")
|
|
24
|
+
.replace(/\n/g, " ")
|
|
25
|
+
.trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Aggregate the workspace into a standing-answer markdown blob. Purely derives
|
|
30
|
+
* from already-indexed data — safe to run on every read.
|
|
31
|
+
*/
|
|
32
|
+
export function compileSummary(domain, ws, { now = Date.now() } = {}) {
|
|
33
|
+
const summary = domain.summarize();
|
|
34
|
+
const scope = ws && summary.workspaces[ws] ? { ...summary.workspaces[ws] } : { ...summary.totals };
|
|
35
|
+
const label = ws ? String(ws).replace(/^.*[\\/]/, "") : "全部工作区";
|
|
36
|
+
|
|
37
|
+
let tasks = [];
|
|
38
|
+
try {
|
|
39
|
+
tasks = domain.listTasks?.(ws, { includeStable: true }) ?? [];
|
|
40
|
+
} catch {
|
|
41
|
+
tasks = [];
|
|
42
|
+
}
|
|
43
|
+
const active = tasks.filter((t) => t.state === "active" || t.state === "draft");
|
|
44
|
+
let ready = 0;
|
|
45
|
+
const risky = [];
|
|
46
|
+
for (const t of active) {
|
|
47
|
+
let gaps = [];
|
|
48
|
+
try {
|
|
49
|
+
gaps = domain.evidenceGate?.(ws, t, { verifyArtifact: false })?.gaps ?? [];
|
|
50
|
+
} catch {
|
|
51
|
+
gaps = [];
|
|
52
|
+
}
|
|
53
|
+
if (gaps.length === 0) ready += 1;
|
|
54
|
+
else {
|
|
55
|
+
const ageDays = Math.max(0, Math.floor((now - (t.createdAt ?? now)) / 86400000));
|
|
56
|
+
risky.push(`- ${mdCell(t.name)} · ${gaps.join("/")} · ${ageDays}天`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const stable = tasks.filter((t) => t.state === "stable").length;
|
|
60
|
+
|
|
61
|
+
const obs = domain.listObservations?.(ws) ?? [];
|
|
62
|
+
const obsEvidence = obs.reduce((s, o) => s + (o.proof?.count ?? 0), 0);
|
|
63
|
+
const topObs = obs
|
|
64
|
+
.slice()
|
|
65
|
+
.sort((a, b) => (b.proof?.count ?? 0) - (a.proof?.count ?? 0))
|
|
66
|
+
.slice(0, 3);
|
|
67
|
+
|
|
68
|
+
let kindHist = "";
|
|
69
|
+
try {
|
|
70
|
+
const mems = domain.listMemories?.(ws, 1000) ?? [];
|
|
71
|
+
const byKind = {};
|
|
72
|
+
for (const m of mems) byKind[m.kind] = (byKind[m.kind] ?? 0) + 1;
|
|
73
|
+
const top = Object.entries(byKind).sort((a, b) => b[1] - a[1]).slice(0, 3);
|
|
74
|
+
if (top.length > 0) kindHist = top.map(([k, n]) => `${k} ${n}`).join(" · ");
|
|
75
|
+
} catch {
|
|
76
|
+
kindHist = "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const lines = [
|
|
80
|
+
`## ${label} · 常驻摘要`,
|
|
81
|
+
`- 任务:${active.length} 进行中(${ready} 就绪)· ${stable} 已闭环`,
|
|
82
|
+
`- 实体图:${scope.nodes ?? 0} 节点 · ${scope.links ?? 0} 链接`,
|
|
83
|
+
`- 观测:${obs.length} 条信念(累计 ${obsEvidence} 证据)`,
|
|
84
|
+
`- 记忆:${scope.memories ?? 0} 条${kindHist ? `(${kindHist})` : ""}`,
|
|
85
|
+
];
|
|
86
|
+
if (topObs.length > 0) {
|
|
87
|
+
lines.push("", "重点信念:");
|
|
88
|
+
for (const o of topObs) lines.push(`- ${mdCell(o.text)} — ×${o.proof?.count ?? 0}`);
|
|
89
|
+
}
|
|
90
|
+
if (risky.length > 0) {
|
|
91
|
+
lines.push("", "未闭环风险:");
|
|
92
|
+
lines.push(...risky.slice(0, 5));
|
|
93
|
+
}
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Hash of the aggregation inputs — detects changes even when a bump was missed. */
|
|
98
|
+
export function computeSourcesHash(domain, ws) {
|
|
99
|
+
const parts = [];
|
|
100
|
+
try {
|
|
101
|
+
const summary = domain.summarize();
|
|
102
|
+
parts.push(JSON.stringify(ws ? summary.workspaces[ws] : summary.totals));
|
|
103
|
+
const tasks = domain.listTasks?.(ws, { includeStable: true }) ?? [];
|
|
104
|
+
parts.push(tasks.map((t) => `${t.id}:${t.state}:${t.updatedAt ?? 0}`).join(","));
|
|
105
|
+
const obs = domain.listObservations?.(ws) ?? [];
|
|
106
|
+
parts.push(obs.map((o) => `${o.id}:${o.proof?.count ?? 0}:${o.negations ?? 0}:${o.updated_at ?? 0}`).join(","));
|
|
107
|
+
} catch {
|
|
108
|
+
parts.push("err");
|
|
109
|
+
}
|
|
110
|
+
return hashText(parts.join("|"));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether the cached model needs a recompute. */
|
|
114
|
+
export function modelStale(model, domain, ws, { now = Date.now(), maxAgeMs = DEFAULT_MAX_AGE_MS } = {}) {
|
|
115
|
+
if (model === void 0 || model === null) return true;
|
|
116
|
+
if (model.dirty === true) return true;
|
|
117
|
+
if (typeof model.generated_at === "number" && now - model.generated_at > maxAgeMs) return true;
|
|
118
|
+
if (model.sources_hash !== computeSourcesHash(domain, ws)) return true;
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Read the standing answer: cached copy unless stale, then recompute + persist.
|
|
124
|
+
* Pure aggregation — cheap enough to rebuild on a cold cache.
|
|
125
|
+
*
|
|
126
|
+
* @returns {Promise<{ws: string; content: string; generated_at: number; dirty: boolean; sources_hash: string; fresh: boolean}>}
|
|
127
|
+
*/
|
|
128
|
+
/**
|
|
129
|
+
* Prime-style brief mode: fold the standing answer's headline rows into a
|
|
130
|
+
* one-liner (~50-token reminder). Derived from the full content — same source
|
|
131
|
+
* of truth, so the brief can never drift from what the full model says.
|
|
132
|
+
*/
|
|
133
|
+
export function briefify(fullContent) {
|
|
134
|
+
const bits = [];
|
|
135
|
+
for (const prefix of ["- 任务:", "- 实体图:", "- 观测:"]) {
|
|
136
|
+
for (const line of fullContent.split("\n")) {
|
|
137
|
+
if (line.startsWith(prefix)) { bits.push(line.slice(2)); break; }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
let risks = 0;
|
|
141
|
+
const lines = fullContent.split("\n");
|
|
142
|
+
for (let i = 0; i < lines.length; i++) {
|
|
143
|
+
if (lines[i].trim().startsWith("未闭环风险")) {
|
|
144
|
+
for (let j = i + 1; j < lines.length && lines[j].startsWith("- "); j++) risks++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
bits.push(risks > 0 ? `风险 ${risks}` : "无未闭环风险");
|
|
148
|
+
return bits.join(" · ").slice(0, 240);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function getModel(domain, ws, { now = Date.now(), maxAgeMs, compile = compileSummary, hash = computeSourcesHash, mode = "full" } = {}) {
|
|
152
|
+
const current = domain.getModel(ws);
|
|
153
|
+
if (!modelStale(current, domain, ws, { now, maxAgeMs })) {
|
|
154
|
+
const hit = { ...current, fresh: false };
|
|
155
|
+
if (mode === "brief") hit.content = briefify(hit.content);
|
|
156
|
+
return hit;
|
|
157
|
+
}
|
|
158
|
+
const ev = { now };
|
|
159
|
+
const content = compile(domain, ws, ev);
|
|
160
|
+
const fresh = {
|
|
161
|
+
ws,
|
|
162
|
+
content,
|
|
163
|
+
generated_at: now,
|
|
164
|
+
dirty: false,
|
|
165
|
+
sources_hash: hash(domain, ws),
|
|
166
|
+
};
|
|
167
|
+
await domain.putModel(ws, fresh);
|
|
168
|
+
if (mode === "brief") fresh.content = briefify(fresh.content);
|
|
169
|
+
return { ...fresh, fresh: true };
|
|
170
|
+
}
|
package/lib/obs.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observation VIEW — evidence-grounded beliefs, derived not persisted.
|
|
3
|
+
*
|
|
4
|
+
* Assessment P2 (2026-08-22): observations were a second persisted
|
|
5
|
+
* representation with write-path side effects (`integrateObservation` ran on
|
|
6
|
+
* every store). Demoted to a pure deterministic projection of memories: a
|
|
7
|
+
* memory with `hits >= OBSERVATION_MIN_PROOF` IS an observation — proof count
|
|
8
|
+
* is its hit count, its span is created→updated, trend is computed from
|
|
9
|
+
* recency. Nothing is written, nothing is stored; the GUI/API/tools keep the
|
|
10
|
+
* exact same shape (`o.proof.count`, `o.negations`, `o.trend`, `o.updated_at`).
|
|
11
|
+
*
|
|
12
|
+
* All functions are pure — zero LLM, zero storage.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A memory only counts as a belief once it has been evidenced twice. */
|
|
16
|
+
export const OBSERVATION_MIN_PROOF = 2;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Trend from evidence recency (30/90-day windows, mirroring Hindsight):
|
|
20
|
+
* - no evidence in the old window at all -> stale
|
|
21
|
+
* - newest evidence older than recentDays -> weakening
|
|
22
|
+
* - every source within recentDays -> new
|
|
23
|
+
* - ≥2 sources, latest recent and history before -> strengthening
|
|
24
|
+
* - otherwise -> stable
|
|
25
|
+
*/
|
|
26
|
+
export function computeTrend(span, now = Date.now(), { recentDays = 30, oldDays = 90 } = {}) {
|
|
27
|
+
const DAY = 86400000;
|
|
28
|
+
const first = span?.first_seen_at ?? null;
|
|
29
|
+
const last = span?.last_seen_at ?? first;
|
|
30
|
+
if (first === null || last === null) return "new";
|
|
31
|
+
const firstAge = Math.max(0, now - first) / DAY;
|
|
32
|
+
const lastAge = Math.max(0, now - last) / DAY;
|
|
33
|
+
if (firstAge > oldDays && lastAge > oldDays) return "stale";
|
|
34
|
+
if (lastAge > recentDays) return "weakening";
|
|
35
|
+
if (firstAge <= recentDays) return "new";
|
|
36
|
+
return "strengthening";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Derive the observations of a workspace from its active memories.
|
|
41
|
+
* Best-first by proof (hits), then newest first. Shape is stable and
|
|
42
|
+
* compatible with the old persisted records so every consumer
|
|
43
|
+
* (tools / mental / api / client) keeps working unchanged.
|
|
44
|
+
*/
|
|
45
|
+
export function deriveObservations(memories, { now = Date.now() } = {}) {
|
|
46
|
+
return memories
|
|
47
|
+
.filter((m) => (m.hits ?? 0) >= OBSERVATION_MIN_PROOF)
|
|
48
|
+
.map((m) => {
|
|
49
|
+
const createdAt = m.createdAt ?? now;
|
|
50
|
+
const updatedAt = m.updatedAt ?? createdAt;
|
|
51
|
+
const span = { first_seen_at: createdAt, last_seen_at: updatedAt };
|
|
52
|
+
return {
|
|
53
|
+
id: m.id,
|
|
54
|
+
workspace: m.workspace,
|
|
55
|
+
text: m.text,
|
|
56
|
+
kind: m.kind === "error" ? "pattern" : "belief",
|
|
57
|
+
proof: { count: m.hits ?? 0, sources: [m.id] },
|
|
58
|
+
span,
|
|
59
|
+
negations: 0,
|
|
60
|
+
trend: computeTrend(span, now),
|
|
61
|
+
tags: Array.isArray(m.tags) ? m.tags : [],
|
|
62
|
+
entity: m.entity ?? null,
|
|
63
|
+
updated_at: updatedAt,
|
|
64
|
+
};
|
|
65
|
+
})
|
|
66
|
+
.sort(
|
|
67
|
+
(a, b) =>
|
|
68
|
+
(b.proof?.count ?? 0) - (a.proof?.count ?? 0) ||
|
|
69
|
+
(b.updated_at ?? 0) - (a.updated_at ?? 0),
|
|
70
|
+
);
|
|
71
|
+
}
|
package/lib/rerank.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evidence-aware reranking signals for deterministic recall.
|
|
3
|
+
*
|
|
4
|
+
* Hindsight's reranking folds proof strength into the relevance score as a
|
|
5
|
+
* multiplicative boost (conservative, ±5% at weight 0.1). Here the analogue
|
|
6
|
+
* of "proof count" is a memory's cumulative recall hits — which our D2
|
|
7
|
+
* failure-revival path already increments on repeat failures, so high-hits
|
|
8
|
+
* memories are exactly the ones we want to stay findable. All functions are
|
|
9
|
+
* pure and dependency-free: `now` and weights are injectable for deterministic
|
|
10
|
+
* tests, matching the injectability of `util.bm25Rank`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Multiplicative evidence strength boost: 1 + alpha·min(max(0, hits), cap).
|
|
15
|
+
* Flat beyond `cap` so one runaway popular memory can't dominate ranking.
|
|
16
|
+
*/
|
|
17
|
+
export function evidenceBoost(hits, { alpha = 0.1, cap = 5 } = {}) {
|
|
18
|
+
const h = Math.max(0, Number.isFinite(hits) ? hits : 0);
|
|
19
|
+
return 1 + alpha * Math.min(h, cap);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Combine a base match score with the evidence boost. */
|
|
23
|
+
export function scoreCandidate(matchScore, hits, { alpha = 0.1, cap = 5 } = {}) {
|
|
24
|
+
return (Number.isFinite(matchScore) ? matchScore : 0) * evidenceBoost(hits, { alpha, cap });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Stable sort of candidates by (score desc, tiebreak). Returns a new array;
|
|
29
|
+
* equal scores keep their input order.
|
|
30
|
+
*
|
|
31
|
+
* @param {Array} list
|
|
32
|
+
* @param {object} opts
|
|
33
|
+
* @param {(r:any)=>number} [opts.getScore] base score per candidate (default 0)
|
|
34
|
+
* @param {(r:any)=>number} [opts.getHits] evidence count per candidate
|
|
35
|
+
* @param {number} [opts.alpha] boost weight
|
|
36
|
+
* @param {number} [opts.cap] boost saturation point
|
|
37
|
+
* @param {(a:any,b:any)=>number} [opts.tiebreak] secondary comparator
|
|
38
|
+
*/
|
|
39
|
+
export function sortByEvidence(list, { getScore = () => 0, getHits = (r) => r.hits ?? 0, alpha = 0.1, cap = 5, tiebreak = null } = {}) {
|
|
40
|
+
const scored = list.map((r, i) => ({ r, i, s: scoreCandidate(getScore(r), getHits(r), { alpha, cap }) }));
|
|
41
|
+
scored.sort((a, b) => {
|
|
42
|
+
if (b.s !== a.s) return b.s - a.s;
|
|
43
|
+
if (tiebreak) {
|
|
44
|
+
const t = tiebreak(a.r, b.r);
|
|
45
|
+
if (t !== 0) return t;
|
|
46
|
+
}
|
|
47
|
+
return a.i - b.i; // stable
|
|
48
|
+
});
|
|
49
|
+
return scored.map((e) => e.r);
|
|
50
|
+
}
|