dsh-engram 0.2.0 → 0.3.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 +91 -5
- package/README.zh.md +46 -4
- package/lib/api.js +83 -10
- package/lib/capture.js +11 -7
- package/lib/client.js +4590 -1057
- package/lib/index-block.js +19 -5
- package/lib/index.js +16 -0
- 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 +314 -32
- package/lib/tools.js +287 -61
- 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);
|
package/lib/index.js
CHANGED
|
@@ -39,6 +39,9 @@ const DEFAULTS = {
|
|
|
39
39
|
autoCaptureGlobalCap: 500,
|
|
40
40
|
// recall
|
|
41
41
|
sessionSearch: true,
|
|
42
|
+
// session-diversified recall: max memories per session before cross-session
|
|
43
|
+
// evidence takes over (agentmemory 候选①; "few and precise")
|
|
44
|
+
maxRecallPerSession: 3,
|
|
42
45
|
// [ENGRAM] index budget (token discipline)
|
|
43
46
|
indexMaxLines: 12,
|
|
44
47
|
indexMaxChars: 700,
|
|
@@ -56,6 +59,10 @@ const DEFAULTS = {
|
|
|
56
59
|
// GUI access: hostnames allowed past the loopback fence for /api/dsh-engram
|
|
57
60
|
// (e.g. an operator-authorized cloudflare tunnel). Empty = loopback-only.
|
|
58
61
|
trustedHosts: [],
|
|
62
|
+
// ESR evidence hardening: a non-URL artifact must exist on disk (relative
|
|
63
|
+
// artifacts resolve against the workspace = the session cwd DSH provides).
|
|
64
|
+
// esr_close's force:true bypasses this existence check.
|
|
65
|
+
verifyArtifact: true,
|
|
59
66
|
// prompt section order (before the persona/tools bands per DSH convention)
|
|
60
67
|
engramIndexOrder: 40,
|
|
61
68
|
esrOrder: 41,
|
|
@@ -142,6 +149,15 @@ export function apply(ctx, config) {
|
|
|
142
149
|
return "";
|
|
143
150
|
}
|
|
144
151
|
},
|
|
152
|
+
/** Recompute the exact [ESR] block for a workspace (GUI inject preview). */
|
|
153
|
+
renderEsrBlock(workspace) {
|
|
154
|
+
if (opened === void 0) return "";
|
|
155
|
+
try {
|
|
156
|
+
return renderEsr(opened, workspace, resolved);
|
|
157
|
+
} catch {
|
|
158
|
+
return "";
|
|
159
|
+
}
|
|
160
|
+
},
|
|
145
161
|
};
|
|
146
162
|
|
|
147
163
|
// Mechanical, archive-only memory GC (pi-esr constraints). Runs the first
|
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
|
+
}
|
package/lib/settings.js
CHANGED
|
@@ -22,6 +22,7 @@ export const SETTINGS_NS = settingsNamespace(SETTINGS_NS_NAME);
|
|
|
22
22
|
export const SETTINGS_KEYS = [
|
|
23
23
|
"autoCapture",
|
|
24
24
|
"sessionSearch",
|
|
25
|
+
"maxRecallPerSession",
|
|
25
26
|
"autoCapturePerSession",
|
|
26
27
|
"indexMaxLines",
|
|
27
28
|
"indexMaxChars",
|
|
@@ -48,6 +49,7 @@ export function makeSettingsSchema(defaults) {
|
|
|
48
49
|
return z.object({
|
|
49
50
|
autoCapture: z.boolean().default(defaults.autoCapture),
|
|
50
51
|
sessionSearch: z.boolean().default(defaults.sessionSearch),
|
|
52
|
+
maxRecallPerSession: z.natural().min(1).max(10).default(defaults.maxRecallPerSession),
|
|
51
53
|
autoCapturePerSession: z.natural().max(1000).default(defaults.autoCapturePerSession),
|
|
52
54
|
indexMaxLines: z.natural().max(50).default(defaults.indexMaxLines),
|
|
53
55
|
indexMaxChars: z.natural().max(4000).default(defaults.indexMaxChars),
|
|
@@ -57,6 +59,7 @@ export function makeSettingsSchema(defaults) {
|
|
|
57
59
|
maxMemoriesPerWorkspace: z.natural().max(10000).default(defaults.maxMemoriesPerWorkspace),
|
|
58
60
|
gcEnabled: z.boolean().default(defaults.gcEnabled),
|
|
59
61
|
gcStableRetentionDays: z.natural().max(3650).default(defaults.gcStableRetentionDays),
|
|
62
|
+
verifyArtifact: z.boolean().default(defaults.verifyArtifact).description("ESR 闭环时校验 artifact 路径真实存在"),
|
|
60
63
|
// Security knob, not rendered in the card: hostnames allowed past the
|
|
61
64
|
// loopback fence for /api/dsh-engram (operator-authorized tunnel hosts).
|
|
62
65
|
trustedHosts: z.array(z.string()).default([]),
|