dsh-engram 0.2.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/LICENSE +21 -0
- package/README.md +331 -0
- package/README.zh.md +276 -0
- package/cordis.patch.yml +10 -0
- package/lib/api.js +418 -0
- package/lib/capture.js +208 -0
- package/lib/client.js +1438 -0
- package/lib/index-block.js +179 -0
- package/lib/index.js +322 -0
- package/lib/settings.js +95 -0
- package/lib/store.js +565 -0
- package/lib/tools.js +471 -0
- package/lib/usage.js +96 -0
- package/lib/util.js +119 -0
- package/package.json +92 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-engram: the [ENGRAM] symbolic index + [ESR] task block.
|
|
3
|
+
*
|
|
4
|
+
* The whole token-saving philosophy lives here: instead of injecting recalled
|
|
5
|
+
* raw text, we inject ONE compact line per memory (type prefix · short date ·
|
|
6
|
+
* ≤56-char summary · short id) so the model knows what exists and can drill
|
|
7
|
+
* down on demand. The block is rendered once per session and then frozen (the
|
|
8
|
+
* section provider caches by session), keeping the prefix byte-stable for KV
|
|
9
|
+
* cache reuse.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { label, shortId, fmtDate, escapeLt, truncate } from "./util.js";
|
|
13
|
+
|
|
14
|
+
/** Deterministic index line prefix per memory kind. */
|
|
15
|
+
export function prefixFor(kind) {
|
|
16
|
+
switch (kind) {
|
|
17
|
+
case "decision": return "D";
|
|
18
|
+
case "error": return "E";
|
|
19
|
+
case "procedure": return "P";
|
|
20
|
+
case "insight": return "I";
|
|
21
|
+
case "handoff": return "H";
|
|
22
|
+
case "task": return "T";
|
|
23
|
+
default: return "F";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const INDEX_LINE_CHARS = 56;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Git echoes captured before the plumbing filter still carry the full shell
|
|
31
|
+
* command ("git push: cd /repo && ...") — pure operational noise that should
|
|
32
|
+
* never steal a [ENGRAM] index line unless recall has proven it useful.
|
|
33
|
+
*/
|
|
34
|
+
function isGitPlumbingEcho(m) {
|
|
35
|
+
if (!Array.isArray(m.tags) || !m.tags.includes("auto-captured") || !m.tags.includes("git")) return false;
|
|
36
|
+
const text = String(m.text ?? "");
|
|
37
|
+
return /(^|\s)(cd\s|\&\&|;|\|)/.test(text);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Select the memories that earn an index line: knowingly stored high-signal
|
|
42
|
+
* entries, auto-captures past minIndexSignal, and low-signal entries promoted
|
|
43
|
+
* by repeated recall (hits >= promoteHits). Sorted newest first.
|
|
44
|
+
*/
|
|
45
|
+
export function selectIndexMemories(store, workspace, config) {
|
|
46
|
+
const all = store.listMemories(workspace, 200);
|
|
47
|
+
return all
|
|
48
|
+
.filter(
|
|
49
|
+
(m) =>
|
|
50
|
+
(isGitPlumbingEcho(m) && (m.hits ?? 0) < config.promoteHits
|
|
51
|
+
? false
|
|
52
|
+
: (m.signal ?? 0.5) >= config.minIndexSignal ||
|
|
53
|
+
(m.hits ?? 0) >= config.promoteHits ||
|
|
54
|
+
m.kind === "task"),
|
|
55
|
+
)
|
|
56
|
+
.slice(0, config.indexMaxLines);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function memoryLine(m) {
|
|
60
|
+
const px = prefixFor(m.kind);
|
|
61
|
+
const date = fmtDate(m.createdAt ?? m.updatedAt);
|
|
62
|
+
const text = truncate(String(m.text).replace(/\s*\n+\s*/g, " ").trim(), INDEX_LINE_CHARS);
|
|
63
|
+
return `[${px}] ${date} ${escapeLt(text)} #${shortId(m.id)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function taskStateLine(t) {
|
|
67
|
+
const gaps = [];
|
|
68
|
+
if (t.artifact === null || t.artifact === "") gaps.push("artifact");
|
|
69
|
+
if (t.evaluation === null || t.evaluation === "") gaps.push("evaluation");
|
|
70
|
+
if (t.memoryRefs.length === 0) gaps.push("memory_ref");
|
|
71
|
+
const state = t.state;
|
|
72
|
+
const suffix =
|
|
73
|
+
state === "stable"
|
|
74
|
+
? "STABLE (artifact ✓ evaluation ✓ memory_ref ✓)"
|
|
75
|
+
: gaps.length === 0
|
|
76
|
+
? "READY-to-close"
|
|
77
|
+
: `ACTIVE · gap: ${gaps.join(", ")}`;
|
|
78
|
+
const name = truncate(String(t.name).replace(/\s*\n+\s*/g, " ").trim(), INDEX_LINE_CHARS);
|
|
79
|
+
return `[T] ${fmtDate(t.createdAt)} ${name} — ${suffix} #${t.id.slice(0, 8)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render the full [ENGRAM] block (or "" when nothing to show). Pure function;
|
|
84
|
+
* called by the section provider and cached per session.
|
|
85
|
+
*/
|
|
86
|
+
export function renderIndex(store, workspace, cwd, config) {
|
|
87
|
+
const entries = store.workspaceEntries(workspace);
|
|
88
|
+
const memories = selectIndexMemories(store, workspace, config);
|
|
89
|
+
const tasks = store.listTasks(workspace, { includeStable: false });
|
|
90
|
+
const links = entries.links;
|
|
91
|
+
|
|
92
|
+
if (memories.length === 0 && tasks.length === 0 && links === 0) return "";
|
|
93
|
+
|
|
94
|
+
const lines = [];
|
|
95
|
+
lines.push(`[ENGRAM] workspace: ${label(cwd)} · ${entries.memories} memories · ${tasks.length} task(s) active · ${links} links · ${entries.nodes} node(s)`);
|
|
96
|
+
const entityNames = store.listEntities(workspace).map((e) => e.name);
|
|
97
|
+
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));
|
|
99
|
+
for (const t of tasks.slice(0, 4)) lines.push(taskStateLine(t));
|
|
100
|
+
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
|
+
return enforceCharCap(lines, config.indexMaxChars);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Keep the header + drill hint, drop the oldest content lines until the
|
|
106
|
+
* joined block fits the character budget (token discipline).
|
|
107
|
+
*/
|
|
108
|
+
function enforceCharCap(lines, maxChars) {
|
|
109
|
+
if (lines.length <= 2) return lines.join("\n");
|
|
110
|
+
let joined = lines.join("\n");
|
|
111
|
+
while (joined.length > maxChars && lines.length > 2) {
|
|
112
|
+
lines.splice(lines.length - 2, 1);
|
|
113
|
+
joined = lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
return joined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
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.
|
|
125
|
+
*/
|
|
126
|
+
export function escalationHint(store, workspace) {
|
|
127
|
+
const rows = store.usageRows(workspace);
|
|
128
|
+
if (rows.length === 0) return "";
|
|
129
|
+
const recent = rows.slice(-14);
|
|
130
|
+
const MEM = ["engram_store", "engram_recall", "engram_detail", "loom_store", "loom_recall", "loom_detail"];
|
|
131
|
+
const ESR = ["esr_task", "esr_node", "esr_close", "esr_link", "esr_gc"];
|
|
132
|
+
let memCalls = 0;
|
|
133
|
+
let esrCalls = 0;
|
|
134
|
+
for (const r of recent) {
|
|
135
|
+
const c = r.counts ?? {};
|
|
136
|
+
for (const k of MEM) memCalls += c[k] ?? 0;
|
|
137
|
+
for (const k of ESR) esrCalls += c[k] ?? 0;
|
|
138
|
+
}
|
|
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)`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Render the [ESR] block — active tasks with closure gaps. Only appears when
|
|
149
|
+
* at least one task exists. "STABLE" tasks are folded to a count line to save
|
|
150
|
+
* tokens.
|
|
151
|
+
*/
|
|
152
|
+
export function renderEsr(store, workspace, config) {
|
|
153
|
+
const active = store.listTasks(workspace, { includeStable: false });
|
|
154
|
+
const stable = store.listTasks(workspace, { includeStable: true }).filter((t) => t.state === "stable");
|
|
155
|
+
const hint = escalationHint(store, workspace);
|
|
156
|
+
if (active.length === 0 && stable.length === 0) {
|
|
157
|
+
// Keep the mechanism visible even when idle: one compact line teaches the
|
|
158
|
+
// model that multi-step work earns an esr_task closed with real evidence.
|
|
159
|
+
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}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const lines = [];
|
|
164
|
+
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)}`);
|
|
166
|
+
if (active.length > 8) lines.push(`- … ${active.length - 8} more`);
|
|
167
|
+
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}` : ""}`);
|
|
168
|
+
if (hint !== "") lines.push(hint);
|
|
169
|
+
return lines.join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function taskGapSuffix(t) {
|
|
173
|
+
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");
|
|
178
|
+
return gaps.length === 0 ? " · READY to close" : ` · gap: ${gaps.join(", ")}`;
|
|
179
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-engram — minimalist symbolic-index × pi-esr memory for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* Host-half plugin only, no browser half, no external servers.
|
|
5
|
+
* - zero-LLM auto-capture from tool results (tools/result)
|
|
6
|
+
* - a compact, per-session-frozen [ENGRAM] symbolic index injected through a
|
|
7
|
+
* systemPrompt section (order 40) — prefix-stable for KV cache reuse
|
|
8
|
+
* - an [ESR] task/closure block (order 41) listing evidence gaps
|
|
9
|
+
* - six tight tools: engram_store / engram_recall / engram_detail / esr_task /
|
|
10
|
+
* esr_close / esr_link
|
|
11
|
+
* - storage on ctx.storageDomain (JSON unit dsh_engram), reads synchronous
|
|
12
|
+
*
|
|
13
|
+
* All model-visible writes are immediate (no approval gate — zero friction,
|
|
14
|
+
* fully auditable through the session log provenance). Reads never call a
|
|
15
|
+
* model. The only optional model-free dependency is ctx.sessionQuery for the
|
|
16
|
+
* `search_sessions` FTS fallback.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { resolve as resolvePath } from "node:path";
|
|
20
|
+
import { openEngramDomain } from "./store.js";
|
|
21
|
+
import { registerTools } from "./tools.js";
|
|
22
|
+
import { makeCaptureHandler } from "./capture.js";
|
|
23
|
+
import { renderIndex, renderEsr } from "./index-block.js";
|
|
24
|
+
import { installEngramSettings } from "./settings.js";
|
|
25
|
+
import { makeEngramRoutes } from "./api.js";
|
|
26
|
+
|
|
27
|
+
/** Stable cordis plugin name. */
|
|
28
|
+
export const name = "engram";
|
|
29
|
+
|
|
30
|
+
/** Tools must exist before any registration. */
|
|
31
|
+
export const inject = ["tools"];
|
|
32
|
+
|
|
33
|
+
/** Defaults; every key can be overridden via profile patch config. */
|
|
34
|
+
const DEFAULTS = {
|
|
35
|
+
enabled: true,
|
|
36
|
+
// intake
|
|
37
|
+
autoCapture: true,
|
|
38
|
+
autoCapturePerSession: 40,
|
|
39
|
+
autoCaptureGlobalCap: 500,
|
|
40
|
+
// recall
|
|
41
|
+
sessionSearch: true,
|
|
42
|
+
// [ENGRAM] index budget (token discipline)
|
|
43
|
+
indexMaxLines: 12,
|
|
44
|
+
indexMaxChars: 700,
|
|
45
|
+
minIndexSignal: 0.4,
|
|
46
|
+
promoteHits: 3,
|
|
47
|
+
// retention / caps
|
|
48
|
+
expireDays: 180,
|
|
49
|
+
maxMemoriesPerWorkspace: 2000,
|
|
50
|
+
maxMemoryChars: 1600,
|
|
51
|
+
maxTasksPerWorkspace: 40,
|
|
52
|
+
// memory GC (pi-esr constraints, archive-only)
|
|
53
|
+
gcEnabled: true,
|
|
54
|
+
gcIntervalHours: 24,
|
|
55
|
+
gcStableRetentionDays: 120,
|
|
56
|
+
// GUI access: hostnames allowed past the loopback fence for /api/dsh-engram
|
|
57
|
+
// (e.g. an operator-authorized cloudflare tunnel). Empty = loopback-only.
|
|
58
|
+
trustedHosts: [],
|
|
59
|
+
// prompt section order (before the persona/tools bands per DSH convention)
|
|
60
|
+
engramIndexOrder: 40,
|
|
61
|
+
esrOrder: 41,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function resolveConfig(config) {
|
|
65
|
+
const src = config && typeof config === "object" ? config : {};
|
|
66
|
+
const out = { ...DEFAULTS };
|
|
67
|
+
for (const key of Object.keys(DEFAULTS)) {
|
|
68
|
+
if (src[key] !== void 0) out[key] = src[key];
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Mount the plugin. `ctx` is the scoped cordis context; `config` the optional
|
|
75
|
+
* resolved plugin config (plain object from the profile patch).
|
|
76
|
+
*/
|
|
77
|
+
export function apply(ctx, config) {
|
|
78
|
+
const resolved = resolveConfig(config);
|
|
79
|
+
if (!resolved.enabled) return;
|
|
80
|
+
|
|
81
|
+
const log = ctx.logger ? ctx.logger("engram") : undefined;
|
|
82
|
+
const sessionQuery = ctx.get("sessionQuery");
|
|
83
|
+
|
|
84
|
+
// `storageDomain` is provided asynchronously by the storage-domain row —
|
|
85
|
+
// its apply awaits a json backend via ctx.inject before providing the
|
|
86
|
+
// facility, and the loader starts rows concurrently. Snapshotting it here
|
|
87
|
+
// freezes `undefined` forever when the provide lands right after this
|
|
88
|
+
// apply (consistently the case on slower machines like Windows). Resolve it
|
|
89
|
+
// lazily through cordis's inject instead, with a bounded fallback so a
|
|
90
|
+
// genuinely facility-less profile still surfaces the friendly disabled
|
|
91
|
+
// message rather than hanging every request.
|
|
92
|
+
const storageDomainPromise = new Promise((resolve, reject) => {
|
|
93
|
+
let settled = false;
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
if (settled) return;
|
|
96
|
+
settled = true;
|
|
97
|
+
const current = ctx.get("storageDomain");
|
|
98
|
+
if (current !== void 0) resolve(current);
|
|
99
|
+
else {
|
|
100
|
+
reject(
|
|
101
|
+
new Error("the storage-domain facility is not available in this profile — engram memory is disabled here"),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}, 10_000);
|
|
105
|
+
ctx.inject(["storageDomain"], (scoped) => {
|
|
106
|
+
if (settled) return;
|
|
107
|
+
settled = true;
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
resolve(scoped.get("storageDomain") ?? ctx.get("storageDomain"));
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
let domainPromise;
|
|
114
|
+
let opened;
|
|
115
|
+
const getDomain = () => {
|
|
116
|
+
domainPromise ??= storageDomainPromise.then((storageDomain) => openEngramDomain(storageDomain)).then((domain) => {
|
|
117
|
+
opened = domain;
|
|
118
|
+
return domain;
|
|
119
|
+
});
|
|
120
|
+
return domainPromise;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const service = {
|
|
124
|
+
config: resolved,
|
|
125
|
+
getDomain,
|
|
126
|
+
openedDomain: () => opened,
|
|
127
|
+
ensureDomain: () => {
|
|
128
|
+
getDomain().catch(() => {});
|
|
129
|
+
},
|
|
130
|
+
sessionQuery,
|
|
131
|
+
log,
|
|
132
|
+
/** Cumulative auto-capture totals (GUI overview observability). */
|
|
133
|
+
captureStats: { total: 0, git: 0, file: 0, error: 0 },
|
|
134
|
+
/** Last GC sweep totals (GUI overview observability). */
|
|
135
|
+
gcStats: { lastRun: 0, archivedMemories: 0, archivedTasks: 0, removedLinks: 0 },
|
|
136
|
+
/** Recompute the exact [ENGRAM] block for a workspace (GUI overview cost). */
|
|
137
|
+
renderIndexBlock(workspace) {
|
|
138
|
+
if (opened === void 0) return "";
|
|
139
|
+
try {
|
|
140
|
+
return renderIndex(opened, workspace, workspace, resolved);
|
|
141
|
+
} catch {
|
|
142
|
+
return "";
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Mechanical, archive-only memory GC (pi-esr constraints). Runs the first
|
|
148
|
+
// sweep once the store is reachable, then on the configured interval. Every
|
|
149
|
+
// entry archived stays re-fetchable — nothing is hard-deleted by GC.
|
|
150
|
+
const runGcAll = async () => {
|
|
151
|
+
if (!resolved.gcEnabled) return;
|
|
152
|
+
try {
|
|
153
|
+
const domain = await getDomain();
|
|
154
|
+
const report = await domain.gc(void 0, resolved, { dryRun: false });
|
|
155
|
+
service.gcStats.lastRun = Date.now();
|
|
156
|
+
service.gcStats.archivedMemories += report.archivedMemories.length;
|
|
157
|
+
service.gcStats.archivedTasks += report.archivedTasks.length;
|
|
158
|
+
service.gcStats.removedLinks += report.removedLinks.length;
|
|
159
|
+
if (report.archivedMemories.length + report.archivedTasks.length + report.removedLinks.length > 0) {
|
|
160
|
+
log?.info?.(`engram gc: ${report.archivedMemories.length} memories archived, ${report.archivedTasks.length} tasks, ${report.removedLinks.length} links removed`);
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
log?.warn?.(`engram gc sweep failed: ${String(error)}`);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// User settings (web GUI card): merges stored values onto the live config.
|
|
168
|
+
// Non-blocking — when the settings service is absent this never runs.
|
|
169
|
+
installEngramSettings(ctx, resolved, resolved);
|
|
170
|
+
|
|
171
|
+
// Web API for the GUI: deferred on the webServer service, so headless
|
|
172
|
+
// profiles keep running the plugin without it. `register` returns the
|
|
173
|
+
// disposer immediately (the dsh-ssh family does the same).
|
|
174
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
175
|
+
const routes = makeEngramRoutes(service);
|
|
176
|
+
const disposers = [];
|
|
177
|
+
for (const route of routes) {
|
|
178
|
+
try {
|
|
179
|
+
disposers.push(webCtx.webServer.register(route));
|
|
180
|
+
} catch {}
|
|
181
|
+
}
|
|
182
|
+
return () => {
|
|
183
|
+
for (const dispose of disposers) {
|
|
184
|
+
try {
|
|
185
|
+
dispose();
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Per-session frozen blocks: render once, then reuse — prefix stability is
|
|
192
|
+
// the point (KV cache). New session (or resumed reload) object → fresh block.
|
|
193
|
+
const sessionCache = new WeakMap();
|
|
194
|
+
const blockFor = (agent) => {
|
|
195
|
+
if (agent === void 0) return "";
|
|
196
|
+
const session = agent.session;
|
|
197
|
+
if (session === void 0) return "";
|
|
198
|
+
const cwd = session.header?.cwd;
|
|
199
|
+
if (cwd === void 0 || cwd.length === 0) return "";
|
|
200
|
+
const cached = sessionCache.get(session);
|
|
201
|
+
if (cached !== void 0) return cached;
|
|
202
|
+
if (opened === void 0) {
|
|
203
|
+
service.ensureDomain();
|
|
204
|
+
return "";
|
|
205
|
+
}
|
|
206
|
+
let block = "";
|
|
207
|
+
try {
|
|
208
|
+
const workspace = resolvePath(cwd);
|
|
209
|
+
block = renderIndex(opened, workspace, cwd, resolved);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
log?.warn?.(`engram index render failed: ${String(error)}`);
|
|
212
|
+
}
|
|
213
|
+
sessionCache.set(session, block);
|
|
214
|
+
return block;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
ctx.effect(() => {
|
|
218
|
+
const disposers = [];
|
|
219
|
+
|
|
220
|
+
// tools
|
|
221
|
+
disposers.push(registerTools(ctx, service));
|
|
222
|
+
|
|
223
|
+
// systemPrompt sections (frozen per session)
|
|
224
|
+
const systemPrompt = ctx.get("systemPrompt");
|
|
225
|
+
if (systemPrompt !== void 0) {
|
|
226
|
+
const indexDispose = systemPrompt.section({
|
|
227
|
+
name: "engram:index",
|
|
228
|
+
order: resolved.engramIndexOrder,
|
|
229
|
+
text: (assemblyContext) => {
|
|
230
|
+
const agent = assemblyContext?.agent;
|
|
231
|
+
if (agent === void 0) return "";
|
|
232
|
+
const session = agent.session;
|
|
233
|
+
if (session === void 0) return "";
|
|
234
|
+
const cwd = session.header?.cwd;
|
|
235
|
+
if (cwd === void 0 || cwd.length === 0) return "";
|
|
236
|
+
return blockFor(agent);
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
disposers.push(() => indexDispose());
|
|
240
|
+
|
|
241
|
+
const esrDispose = systemPrompt.section({
|
|
242
|
+
name: "engram:esr",
|
|
243
|
+
order: resolved.esrOrder,
|
|
244
|
+
text: (assemblyContext) => {
|
|
245
|
+
const agent = assemblyContext?.agent;
|
|
246
|
+
if (agent === void 0) return "";
|
|
247
|
+
const session = agent.session;
|
|
248
|
+
if (session === void 0) return "";
|
|
249
|
+
const cwd = session.header?.cwd;
|
|
250
|
+
if (cwd === void 0 || cwd.length === 0) return "";
|
|
251
|
+
if (opened === void 0) return "";
|
|
252
|
+
try {
|
|
253
|
+
return renderEsr(opened, resolvePath(cwd), resolved);
|
|
254
|
+
} catch {
|
|
255
|
+
return "";
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
disposers.push(() => esrDispose());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// zero-LLM auto-capture
|
|
263
|
+
if (resolved.autoCapture) {
|
|
264
|
+
// The capture handler needs a storeMemory face, but the domain opens
|
|
265
|
+
// lazily. The adapter resolves it per call and drops events that arrive
|
|
266
|
+
// before the first open (rate buckets still counted, so nothing is lost
|
|
267
|
+
// once the domain is up).
|
|
268
|
+
const captureStore = {
|
|
269
|
+
storeMemory(...args) {
|
|
270
|
+
const domain = service.openedDomain();
|
|
271
|
+
if (domain === void 0) {
|
|
272
|
+
service.ensureDomain();
|
|
273
|
+
return Promise.reject(new Error("engram domain not open yet"));
|
|
274
|
+
}
|
|
275
|
+
return domain.storeMemory(...args);
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
const capture = makeCaptureHandler(captureStore, resolved, log, service.captureStats);
|
|
279
|
+
const disposeCapture = ctx.on("tools/result", (exec, result) => {
|
|
280
|
+
capture(exec, result);
|
|
281
|
+
});
|
|
282
|
+
disposers.push(disposeCapture);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// memory GC scheduling: one sweep after the store is reachable, then on
|
|
286
|
+
// the configured interval (cordis clears ctx-set timers on dispose).
|
|
287
|
+
// Guard against a boot-order race: ctx.setInterval routes through the
|
|
288
|
+
// `timer` service, which may not be injected yet at apply time, and an
|
|
289
|
+
// unguarded read crashes the whole server (`cannot get property "timer"
|
|
290
|
+
// without inject`). Fall back to a global interval; GC is best-effort.
|
|
291
|
+
getDomain()
|
|
292
|
+
.then(() => {
|
|
293
|
+
setTimeout(() => void runGcAll(), 0);
|
|
294
|
+
})
|
|
295
|
+
.catch(() => {});
|
|
296
|
+
const gcMs = Math.max(1, resolved.gcIntervalHours) * 60 * 60 * 1000;
|
|
297
|
+
let gcDispose;
|
|
298
|
+
try {
|
|
299
|
+
gcDispose = ctx.setInterval(() => void runGcAll(), gcMs);
|
|
300
|
+
} catch {
|
|
301
|
+
gcDispose = undefined;
|
|
302
|
+
}
|
|
303
|
+
if (gcDispose === undefined) {
|
|
304
|
+
const gcHandle = globalThis.setInterval(() => void runGcAll(), gcMs);
|
|
305
|
+
gcDispose = () => globalThis.clearInterval(gcHandle);
|
|
306
|
+
}
|
|
307
|
+
disposers.push(gcDispose);
|
|
308
|
+
|
|
309
|
+
return async () => {
|
|
310
|
+
for (const dispose of disposers) {
|
|
311
|
+
try {
|
|
312
|
+
dispose();
|
|
313
|
+
} catch {}
|
|
314
|
+
}
|
|
315
|
+
if (domainPromise !== void 0) {
|
|
316
|
+
try {
|
|
317
|
+
await (await domainPromise).close();
|
|
318
|
+
} catch {}
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
});
|
|
322
|
+
}
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-engram: user settings binding (host half).
|
|
3
|
+
*
|
|
4
|
+
* Registers the `dsh-engram` settings namespace through DSH's own settings
|
|
5
|
+
* service so the web GUI's Plugins configuration tab pairs the namespace with
|
|
6
|
+
* the plugin's card automatically. Changes land live on `service.config`
|
|
7
|
+
* (new sessions pick them up; blocks already frozen for a session stay frozen
|
|
8
|
+
* by design — prefix stability wins over mid-session knobs).
|
|
9
|
+
*
|
|
10
|
+
* The schema is schemastery (DSH's own schema engine), not zod: the settings
|
|
11
|
+
* transport and the form renderers are all schemastery-native.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import z from "@deepseek-ai/schemastery";
|
|
15
|
+
import { settingsNamespace, installSettingsSection } from "@deepseek-ai/dsh-settings";
|
|
16
|
+
|
|
17
|
+
/** Namespace join key — must match the client card's `key`. */
|
|
18
|
+
export const SETTINGS_NS_NAME = "dsh-engram";
|
|
19
|
+
export const SETTINGS_NS = settingsNamespace(SETTINGS_NS_NAME);
|
|
20
|
+
|
|
21
|
+
/** Fields the GUI exposes (a curated subset of the plugin DEFAULTS). */
|
|
22
|
+
export const SETTINGS_KEYS = [
|
|
23
|
+
"autoCapture",
|
|
24
|
+
"sessionSearch",
|
|
25
|
+
"autoCapturePerSession",
|
|
26
|
+
"indexMaxLines",
|
|
27
|
+
"indexMaxChars",
|
|
28
|
+
"minIndexSignal",
|
|
29
|
+
"promoteHits",
|
|
30
|
+
"expireDays",
|
|
31
|
+
"maxMemoriesPerWorkspace",
|
|
32
|
+
"gcEnabled",
|
|
33
|
+
"gcStableRetentionDays",
|
|
34
|
+
"trustedHosts",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** Base (composition) layer for the settings section, from plugin defaults. */
|
|
38
|
+
export function settingsBaseFrom(defaults) {
|
|
39
|
+
const base = {};
|
|
40
|
+
for (const key of SETTINGS_KEYS) {
|
|
41
|
+
if (defaults[key] !== void 0) base[key] = defaults[key];
|
|
42
|
+
}
|
|
43
|
+
return base;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** schemastery schema describing the GUI-editable fields. */
|
|
47
|
+
export function makeSettingsSchema(defaults) {
|
|
48
|
+
return z.object({
|
|
49
|
+
autoCapture: z.boolean().default(defaults.autoCapture),
|
|
50
|
+
sessionSearch: z.boolean().default(defaults.sessionSearch),
|
|
51
|
+
autoCapturePerSession: z.natural().max(1000).default(defaults.autoCapturePerSession),
|
|
52
|
+
indexMaxLines: z.natural().max(50).default(defaults.indexMaxLines),
|
|
53
|
+
indexMaxChars: z.natural().max(4000).default(defaults.indexMaxChars),
|
|
54
|
+
minIndexSignal: z.percent().default(defaults.minIndexSignal),
|
|
55
|
+
promoteHits: z.natural().max(20).default(defaults.promoteHits),
|
|
56
|
+
expireDays: z.natural().max(3650).default(defaults.expireDays),
|
|
57
|
+
maxMemoriesPerWorkspace: z.natural().max(10000).default(defaults.maxMemoriesPerWorkspace),
|
|
58
|
+
gcEnabled: z.boolean().default(defaults.gcEnabled),
|
|
59
|
+
gcStableRetentionDays: z.natural().max(3650).default(defaults.gcStableRetentionDays),
|
|
60
|
+
// Security knob, not rendered in the card: hostnames allowed past the
|
|
61
|
+
// loopback fence for /api/dsh-engram (operator-authorized tunnel hosts).
|
|
62
|
+
trustedHosts: z.array(z.string()).default([]),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Wire the settings section into `ctx` and keep `live` (the plugin's mutable
|
|
68
|
+
* config object) merged with what the user stored. Called from `apply()`.
|
|
69
|
+
*/
|
|
70
|
+
export function installEngramSettings(ctx, defaults, live) {
|
|
71
|
+
const base = settingsBaseFrom(defaults);
|
|
72
|
+
const schema = makeSettingsSchema(defaults);
|
|
73
|
+
let source = () => base;
|
|
74
|
+
installSettingsSection(ctx, SETTINGS_NS, schema, base, {
|
|
75
|
+
setSource(current) {
|
|
76
|
+
source = current;
|
|
77
|
+
},
|
|
78
|
+
onChange() {
|
|
79
|
+
let value;
|
|
80
|
+
try {
|
|
81
|
+
value = source();
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (value === null || typeof value !== "object") return;
|
|
86
|
+
for (const key of SETTINGS_KEYS) {
|
|
87
|
+
if (value[key] !== void 0 && live[key] !== value[key]) live[key] = value[key];
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
validate(value) {
|
|
91
|
+
// The schema already constrains every exposed field; refuse nothing here.
|
|
92
|
+
void value;
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|