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.
@@ -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). Sorted newest first.
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 `[${px}] ${date} ${escapeLt(text)} #${shortId(m.id)}`;
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, data-driven escalation reminder. Surfaces the workspace real
120
- * recent mem-vs-esr balance (usage rollup, last 14 days) so the model sees
121
- * its own under-proactivity at the next session and can self-correct NOW.
122
- * Returns "" when there is nothing to correct (no data, too small a sample,
123
- * or the balance is healthy) — the reminder disappears once behaviour
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 escalationHint(store, workspace) {
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
- // No data or too little signal — lecturing on <3 memory ops is noise.
140
- if (memCalls < 3) return "";
141
- const ratio = esrCalls / (memCalls + esrCalls);
142
- // Healthy once esr exceeds ~half the memory-ops volume; otherwise nudge.
143
- if (ratio >= 0.34) return "";
144
- return `escalate: last ${recent.length}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)`;
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 hint = escalationHint(store, workspace);
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
- return hint === "" ? base : `${base}\n${hint}`;
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
- for (const t of active.slice(0, 8)) lines.push(`- ${t.id.slice(0, 6)}: ${escapeLt(truncate(t.name, 60))} — ${t.state.toUpperCase()}${taskGapSuffix(t)}`);
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
+ }