@wjm200509/dsh-memory 0.0.0-stage → 0.1.1
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 +78 -2
- package/lib/client.js +374 -0
- package/lib/command.js +50 -0
- package/lib/dream.js +386 -0
- package/lib/fsutil.js +24 -0
- package/lib/health.js +66 -0
- package/lib/index.js +147 -0
- package/lib/inject.js +89 -0
- package/lib/paths.js +119 -0
- package/lib/prompt.js +133 -0
- package/lib/runner.js +583 -0
- package/lib/selection.js +15 -0
- package/lib/state.js +242 -0
- package/lib/status.js +55 -0
- package/lib/store.js +320 -0
- package/lib/tools.js +226 -0
- package/lib/trigger.js +97 -0
- package/package.json +59 -4
- package/patch.yml +9 -0
package/lib/dream.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One dreaming run: a hidden, tool-restricted background agent that
|
|
3
|
+
* consolidates memory. A project run reviews one project's ended sessions;
|
|
4
|
+
* a cross-project run reviews every partition together and moves notes to
|
|
5
|
+
* the partition where they hold.
|
|
6
|
+
*
|
|
7
|
+
* Mechanism (verified by the 2026-11-08 spike on DSH 0.2.2-rc.1):
|
|
8
|
+
* - `ctx.agents.create()` without `parentAgent` makes a runtime root agent;
|
|
9
|
+
* - `meta: { origin: "subagent" }` without `cwd`: the session is stored under
|
|
10
|
+
* `sessions/_no-cwd/`, never listed or searched, and hidden from the sidebar;
|
|
11
|
+
* - `agentCtx.tools.restrict({ allow: [] })` masks every global tool; the
|
|
12
|
+
* scoped registrations below stay visible;
|
|
13
|
+
* - messages must be built with `createUserMessage()` (it sets `role`);
|
|
14
|
+
* - `sessionTitle.rename()` pins the title and stops title generation;
|
|
15
|
+
* - the loop has no turn budget: a policy must `cancel()` the agent itself.
|
|
16
|
+
*
|
|
17
|
+
* Outcome: the loop appends `turn/end` with `reason.kind` one of
|
|
18
|
+
* "completed", "error" (model/tool failure, `reason.error.message`),
|
|
19
|
+
* "aborted", "max-tokens" (output cut off) or "blocked". A completed turn,
|
|
20
|
+
* or one this module cancelled at the step budget, is a success; anything
|
|
21
|
+
* the run could not finish (tool errors, unresolved items the agent
|
|
22
|
+
* reported, a missing report, the budget) is listed in `issues`.
|
|
23
|
+
*/
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
25
|
+
import { stat } from "node:fs/promises";
|
|
26
|
+
import { basename, dirname } from "node:path";
|
|
27
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
28
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
29
|
+
import { createChangeTracker, createBackup, pruneHistory } from "./store.js";
|
|
30
|
+
import { checkPartition, renderHealth } from "./health.js";
|
|
31
|
+
import { memoryTools } from "./tools.js";
|
|
32
|
+
import { sameProject } from "./paths.js";
|
|
33
|
+
import { dreamPrompt, crossDreamPrompt } from "./prompt.js";
|
|
34
|
+
|
|
35
|
+
/** Model calls one run may make before it is stopped. */
|
|
36
|
+
export const MAX_STEPS = { project: 40, cross: 80 };
|
|
37
|
+
/** Transcript pages one project run may read; the rest of its steps are for writing. */
|
|
38
|
+
export const READ_PAGES = 20;
|
|
39
|
+
/** Marker opening every dreaming prompt; also identifies old runs' sessions on disk. */
|
|
40
|
+
export const DREAM_PROMPT_MARKERS = ["Memory consolidation", 'You are "dreaming"'];
|
|
41
|
+
|
|
42
|
+
const TOOL_RESULT_CHARS = 400;
|
|
43
|
+
const TOOL_CALL_CHARS = 200;
|
|
44
|
+
const PAGE_CHARS = 12_000;
|
|
45
|
+
/** Longest USER / ASSISTANT line: a pasted log must not flood the dreaming context. */
|
|
46
|
+
const LINE_CHARS = 2_000;
|
|
47
|
+
const SEARCH_HITS = 50;
|
|
48
|
+
const ISSUE_CHARS = 200;
|
|
49
|
+
const ISSUE_LIMIT = 8;
|
|
50
|
+
|
|
51
|
+
const seqOf = (line) => Number(line.match(/^\[seq (\d+)\]/)?.[1] ?? 0);
|
|
52
|
+
const clip = (text, max) => (text.length > max ? `${text.slice(0, max)}…` : text);
|
|
53
|
+
const oneLine = (text) => text.replace(/\s+/g, " ").trim();
|
|
54
|
+
|
|
55
|
+
function blockText(block, max) {
|
|
56
|
+
if (block?.type === "text") return block.text;
|
|
57
|
+
if (block?.type === "reasoning") return "";
|
|
58
|
+
return clip(`[${block?.type}] ${JSON.stringify(block)}`, max);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A tool call as `TOOL CALL <name>: <arguments>`; provider call ids are noise and dropped. */
|
|
62
|
+
function toolCallLine(block) {
|
|
63
|
+
if (block.type !== "tool-call") return `TOOL CALL: ${oneLine(blockText(block, TOOL_CALL_CHARS))}`;
|
|
64
|
+
const args = typeof block.arguments === "string" ? block.arguments : JSON.stringify(block.arguments ?? {});
|
|
65
|
+
return `TOOL CALL ${block.name}: ${clip(oneLine(args), TOOL_CALL_CHARS)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render a session's events as compact transcript lines: real user input,
|
|
70
|
+
* assistant text and tool activity. Injected context (workspace instructions,
|
|
71
|
+
* reminders) and model reasoning are dropped.
|
|
72
|
+
* @param {Array<{ seq: number, type: string, data: any }>} events
|
|
73
|
+
* @returns {string} one `[seq N] ROLE: text` line per kept message.
|
|
74
|
+
*/
|
|
75
|
+
export function renderTranscript(events) {
|
|
76
|
+
const lines = [];
|
|
77
|
+
for (const event of events) {
|
|
78
|
+
/* A user/message event carries the message itself as `data`; assistant/message and tool/result wrap it in `data.message`. */
|
|
79
|
+
const message = event.type === "user/message" ? event.data : event.data?.message;
|
|
80
|
+
if (message === undefined || message === null) continue;
|
|
81
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
82
|
+
if (event.type === "user/message") {
|
|
83
|
+
if (message.source?.kind !== "user") continue;
|
|
84
|
+
lines.push(`[seq ${event.seq}] USER: ${clip(oneLine(content.map((block) => blockText(block, TOOL_CALL_CHARS)).join(" ")), LINE_CHARS)}`);
|
|
85
|
+
} else if (event.type === "assistant/message") {
|
|
86
|
+
const text = oneLine(content.filter((block) => block.type === "text").map((block) => block.text).join(" "));
|
|
87
|
+
if (text !== "") lines.push(`[seq ${event.seq}] ASSISTANT: ${clip(text, LINE_CHARS)}`);
|
|
88
|
+
for (const block of content) {
|
|
89
|
+
if (block.type !== "text" && block.type !== "reasoning") lines.push(`[seq ${event.seq}] ${toolCallLine(block)}`);
|
|
90
|
+
}
|
|
91
|
+
} else if (event.type === "tool/result") {
|
|
92
|
+
const text = oneLine(content.map((block) => blockText(block, TOOL_RESULT_CHARS)).join(" "));
|
|
93
|
+
lines.push(`[seq ${event.seq}] TOOL RESULT${message.isError ? " (error)" : ""}: ${clip(text, TOOL_RESULT_CHARS)}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether a project directory is there, was deleted, or cannot be judged.
|
|
101
|
+
* A missing directory counts as deleted only when an ancestor exists below the
|
|
102
|
+
* filesystem root's own mount (so a drive or share that is not mounted right
|
|
103
|
+
* now is "unreachable" and its sessions wait instead of being dropped).
|
|
104
|
+
* @returns {Promise<"present" | "gone" | "unreachable">}
|
|
105
|
+
*/
|
|
106
|
+
export async function projectState(cwd) {
|
|
107
|
+
try {
|
|
108
|
+
await stat(cwd);
|
|
109
|
+
return "present";
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error.code !== "ENOENT" && error.code !== "ENOTDIR") return "unreachable";
|
|
112
|
+
}
|
|
113
|
+
for (let dir = dirname(cwd); ;) {
|
|
114
|
+
if (await stat(dir).then(() => true, () => false)) return "gone";
|
|
115
|
+
const parent = dirname(dir);
|
|
116
|
+
if (parent === dir) return "unreachable";
|
|
117
|
+
dir = parent;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const TEXT_OUTPUT = {
|
|
122
|
+
schema: { type: "object", additionalProperties: false, properties: { text: { type: "string", required: true, description: "Result text." } } },
|
|
123
|
+
render: (_args, value) => [{ type: "text", text: value.text }]
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Find a session by a full id or a unique prefix (notes written by earlier
|
|
128
|
+
* runs may cite a shortened id), limited to sessions `allowed` accepts.
|
|
129
|
+
* @returns {Promise<{ session: object, events: object[] }>}
|
|
130
|
+
*/
|
|
131
|
+
async function resolveSession(sessionQuery, sessionId, allowed) {
|
|
132
|
+
try {
|
|
133
|
+
return await sessionQuery.readSession(sessionId);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (typeof sessionQuery.listSessions !== "function") throw error;
|
|
136
|
+
}
|
|
137
|
+
const wanted = sessionId.replace(/^session-/, "");
|
|
138
|
+
const matches = (await sessionQuery.listSessions()).map(({ header }) => header).filter((header) => header.id.replace(/^session-/, "").startsWith(wanted) && allowed(header));
|
|
139
|
+
if (matches.length === 1) return sessionQuery.readSession(matches[0].id);
|
|
140
|
+
if (matches.length > 1) throw new Error(`Session id "${sessionId}" is ambiguous: ${matches.slice(0, 5).map((header) => header.id).join(", ")}. Use the full id.`);
|
|
141
|
+
throw new Error(`No session "${sessionId}" here. Cite full session ids (session-<uuid>); a note whose evidence cannot be found stays unverified: list it in dream_report.`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Read-only transcript tools for the dreaming agent. A project run reads
|
|
146
|
+
* that project's sessions and searches its batch; a cross-project run
|
|
147
|
+
* (`cwd` undefined) reads any project's session to see where a cited
|
|
148
|
+
* session belongs, and has no search.
|
|
149
|
+
* @param ctx - context providing `sessionQuery`.
|
|
150
|
+
* @param {{ cwd?: string, sessionIds: string[], progress?: Map<string, { seq: number, done: boolean }>,
|
|
151
|
+
* fromSeqs?: Record<string, number>, readPages?: number }} job
|
|
152
|
+
* `progress` receives, per session, how far the agent has read without skipping anything (from `fromSeqs`, where
|
|
153
|
+
* the previous run stopped) and whether the end was reached. `readPages` limits `transcript_read` calls.
|
|
154
|
+
*/
|
|
155
|
+
export function transcriptTools(ctx, { cwd, sessionIds, progress = new Map(), fromSeqs = {}, readPages = Infinity }) {
|
|
156
|
+
let pagesRead = 0;
|
|
157
|
+
const cross = cwd === undefined;
|
|
158
|
+
const allowed = (header) => typeof header.cwd === "string" && (cross || sameProject(header.cwd, cwd));
|
|
159
|
+
const cache = new Map();
|
|
160
|
+
const load = async (sessionId) => {
|
|
161
|
+
if (!cache.has(sessionId)) {
|
|
162
|
+
cache.set(sessionId, resolveSession(ctx.sessionQuery, sessionId, allowed).then(({ session, events }) => {
|
|
163
|
+
if (!allowed(session)) throw new Error(`Session ${sessionId} belongs to another project; only this project's sessions can be read.`);
|
|
164
|
+
return { id: session.id, cwd: session.cwd, lines: renderTranscript(events).split("\n").filter((line) => line !== "") };
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
return cache.get(sessionId);
|
|
168
|
+
};
|
|
169
|
+
const read = defineTool({
|
|
170
|
+
name: "transcript_read",
|
|
171
|
+
description: "Read a session transcript as `[seq N] ROLE: text` lines, one page at a time. Transcripts are the ground truth for checking memory notes. The first page names the session's full id and project.",
|
|
172
|
+
parameters: {
|
|
173
|
+
sessionId: { type: "string", required: true, description: "Full session id (session-<uuid>); a unique prefix also works." },
|
|
174
|
+
fromSeq: { type: "integer", description: "Start at this event seq; omit to start from the beginning." }
|
|
175
|
+
},
|
|
176
|
+
output: TEXT_OUTPUT,
|
|
177
|
+
isConcurrencySafe: () => true,
|
|
178
|
+
async execute(args) {
|
|
179
|
+
const { id, cwd: project, lines } = await load(args.sessionId);
|
|
180
|
+
if (++pagesRead > readPages) return { text: `Reading budget used up (${readPages} pages per run). Do not read more: write what you learned from what you read, then call dream_report. Sessions you did not finish are reviewed again by the next run.` };
|
|
181
|
+
const from = args.fromSeq ?? 0;
|
|
182
|
+
const out = from === 0 ? [`Session ${id} · project ${project}`] : [];
|
|
183
|
+
let size = 0;
|
|
184
|
+
let lastSeq = 0;
|
|
185
|
+
let more = false;
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
const seq = seqOf(line);
|
|
188
|
+
if (seq < from) continue;
|
|
189
|
+
/* A page ends only between events, never inside one, so the next page's `fromSeq` always advances. */
|
|
190
|
+
if (size > PAGE_CHARS && seq !== lastSeq) {
|
|
191
|
+
out.push(`[more: call transcript_read with fromSeq=${seq}]`);
|
|
192
|
+
more = true;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
out.push(line);
|
|
196
|
+
lastSeq = seq;
|
|
197
|
+
size += line.length + 1;
|
|
198
|
+
}
|
|
199
|
+
/* Only a page that continues where the agent already was counts: skipping ahead does not mark the skipped part as read. */
|
|
200
|
+
const covered = progress.get(id) ?? { seq: fromSeqs[id] ?? 0, done: false };
|
|
201
|
+
const skipped = lines.some((line) => seqOf(line) > covered.seq && seqOf(line) < from);
|
|
202
|
+
if (!skipped) progress.set(id, { seq: Math.max(covered.seq, lastSeq), done: covered.done || !more });
|
|
203
|
+
return { text: out.length === 0 ? "No transcript lines at or after that seq." : out.join("\n") };
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
if (cross) return [read];
|
|
207
|
+
return [
|
|
208
|
+
read,
|
|
209
|
+
defineTool({
|
|
210
|
+
name: "transcript_search",
|
|
211
|
+
description: "Case-insensitive literal search across the transcripts of the sessions under review.",
|
|
212
|
+
parameters: { query: { type: "string", required: true, description: "Literal text to find." } },
|
|
213
|
+
output: TEXT_OUTPUT,
|
|
214
|
+
isConcurrencySafe: () => true,
|
|
215
|
+
async execute(args) {
|
|
216
|
+
const needle = args.query.toLowerCase();
|
|
217
|
+
const hits = [];
|
|
218
|
+
for (const sessionId of sessionIds) {
|
|
219
|
+
for (const line of (await load(sessionId)).lines) {
|
|
220
|
+
if (line.toLowerCase().includes(needle)) hits.push(`${sessionId} ${line}`);
|
|
221
|
+
if (hits.length >= SEARCH_HITS) break;
|
|
222
|
+
}
|
|
223
|
+
if (hits.length >= SEARCH_HITS) break;
|
|
224
|
+
}
|
|
225
|
+
return { text: hits.length === 0 ? "No matches." : hits.join("\n") };
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The closing report the agent must file; `holder.report` receives it. */
|
|
232
|
+
export function reportTool(holder) {
|
|
233
|
+
return defineTool({
|
|
234
|
+
name: "dream_report",
|
|
235
|
+
description: "Call once when you are done: what you changed and why, and everything left unresolved.",
|
|
236
|
+
parameters: {
|
|
237
|
+
summary: { type: "string", required: true, description: "Short summary of what you changed and why." },
|
|
238
|
+
unresolved: { type: "string", description: "One item per line: notes you could not verify or fix, checks that failed, work you skipped, and why. Empty when none." }
|
|
239
|
+
},
|
|
240
|
+
output: TEXT_OUTPUT,
|
|
241
|
+
async execute(args) {
|
|
242
|
+
holder.report = { summary: args.summary, unresolved: (args.unresolved ?? "").split("\n").map((line) => line.trim()).filter((line) => line !== "") };
|
|
243
|
+
return { text: "Report recorded. End your turn now." };
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Tool errors in the run's own events, as short issue lines. */
|
|
249
|
+
function toolErrors(events) {
|
|
250
|
+
return events.filter((event) => event.type === "tool/result" && event.data?.message?.isError).map((event) => {
|
|
251
|
+
const content = Array.isArray(event.data.message.content) ? event.data.message.content : [];
|
|
252
|
+
return clip(oneLine(content.map((block) => blockText(block, ISSUE_CHARS)).join(" ")), ISSUE_CHARS);
|
|
253
|
+
})
|
|
254
|
+
/* Expected retries, not problems: a version conflict is redone on the new content, and the user-level gate redirects the note to the project partition. */
|
|
255
|
+
.filter((text) => !/^Memory conflict:|^A new user-level note needs basis/.test(text))
|
|
256
|
+
.map((text) => `tool error: ${text}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Pre-computed checks for the prompt; a failing check only costs the section. */
|
|
260
|
+
async function healthFor(store, partitions, indexMaxBytes) {
|
|
261
|
+
try {
|
|
262
|
+
return renderHealth(await Promise.all(partitions.map(async ({ label, scope, cwd }) => ({ label, issues: await checkPartition(store, scope, cwd, indexMaxBytes) }))));
|
|
263
|
+
} catch {
|
|
264
|
+
return "";
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Run one dreaming pass.
|
|
270
|
+
* @param ctx - context providing `agents`, `sessionQuery`, and optionally `on` (step budget) and `get` (`sessionTitle`).
|
|
271
|
+
* @param {{ cwd?: string, sessionIds?: string[], projects?: Array<{ cwd: string, notes: number }>,
|
|
272
|
+
* fromSeqs?: Record<string, number>, selection?: { provider: string, model: string, reasoningEffort?: string },
|
|
273
|
+
* store: import("./store.js").MemoryStore, indexMaxBytes: number, signal?: AbortSignal }} job
|
|
274
|
+
* `fromSeqs` maps a session id to the last seq an earlier run reviewed.
|
|
275
|
+
* `projects` makes it a cross-project run; otherwise it consolidates `cwd`.
|
|
276
|
+
* @returns {Promise<{ ok: true, changes: object, issues: string[], summary?: string, dreamSessionId: string, unfinished?: Record<string, number> }
|
|
277
|
+
* | { ok: false, error: string, dreamSessionId?: string, skipped?: boolean }>}
|
|
278
|
+
* `unfinished` maps each session that was not read to the end (the run hit its budget) to the seq it got through.
|
|
279
|
+
* `skipped` means the run did not start: the project directory cannot be reached right now.
|
|
280
|
+
*/
|
|
281
|
+
export async function runDream(ctx, { cwd, sessionIds = [], fromSeqs = {}, projects, selection, store, indexMaxBytes, signal }) {
|
|
282
|
+
if (signal?.aborted) return { ok: false, error: "cancelled before it started" };
|
|
283
|
+
const cross = projects !== undefined;
|
|
284
|
+
const maxSteps = cross ? MAX_STEPS.cross : MAX_STEPS.project;
|
|
285
|
+
const sessionId = `session-${randomUUID()}`;
|
|
286
|
+
const tracker = createChangeTracker({ backup: createBackup(store.root, sessionId) });
|
|
287
|
+
const holder = { report: undefined };
|
|
288
|
+
/** Per session: last seq shown to the agent, and whether its end was reached. */
|
|
289
|
+
const progress = new Map();
|
|
290
|
+
/* What counts as reviewed if the run is cut off: the reading progress at the agent's last note change, since reading alone consolidates nothing. */
|
|
291
|
+
let credited = new Map();
|
|
292
|
+
const record = tracker.record.bind(tracker);
|
|
293
|
+
tracker.record = (kind, path) => {
|
|
294
|
+
record(kind, path);
|
|
295
|
+
credited = new Map([...progress].map(([id, state]) => [id, { ...state }]));
|
|
296
|
+
};
|
|
297
|
+
let handle;
|
|
298
|
+
let steps = 0;
|
|
299
|
+
let budgetHit = false;
|
|
300
|
+
let stopListening;
|
|
301
|
+
try {
|
|
302
|
+
let prompt;
|
|
303
|
+
let tools;
|
|
304
|
+
if (cross) {
|
|
305
|
+
const health = await healthFor(store, [{ label: "user", scope: "user" }, ...projects.map((project) => ({ label: `project ${project.cwd}`, scope: "project", cwd: project.cwd }))], indexMaxBytes);
|
|
306
|
+
prompt = crossDreamPrompt({ projects, indexMaxBytes, maxSteps, health });
|
|
307
|
+
tools = [...memoryTools(store, { projects: projects.map((project) => project.cwd), tracker, indexMaxBytes: () => indexMaxBytes, subagentReadOnly: false }), ...transcriptTools(ctx, { sessionIds: [] })];
|
|
308
|
+
} else {
|
|
309
|
+
/* A deleted project's notes would never be loaded again: its partition turns read-only and the prompt keeps only what the user stated.
|
|
310
|
+
A directory that cannot be reached right now (unmounted drive) is neither: the run waits. */
|
|
311
|
+
const state = await projectState(cwd);
|
|
312
|
+
if (state === "unreachable") return { ok: false, skipped: true, error: `project directory ${cwd} cannot be reached right now` };
|
|
313
|
+
const projectGone = state === "gone";
|
|
314
|
+
const health = await healthFor(store, [...(projectGone ? [] : [{ label: "project", scope: "project", cwd }]), { label: "user", scope: "user" }], indexMaxBytes);
|
|
315
|
+
prompt = dreamPrompt({ cwd, sessionIds, fromSeqs, indexMaxBytes, projectGone, maxSteps, health, readPages: READ_PAGES });
|
|
316
|
+
tools = [...memoryTools(store, { cwdOf: () => cwd, tracker, indexMaxBytes: () => indexMaxBytes, projectWritable: !projectGone, subagentReadOnly: false }), ...transcriptTools(ctx, { cwd, sessionIds, progress, fromSeqs, readPages: READ_PAGES })];
|
|
317
|
+
}
|
|
318
|
+
/* Cancelled (memory turned off, plugin stopping) while the checks ran: do not create the agent at all. */
|
|
319
|
+
if (signal?.aborted) return { ok: false, error: "cancelled before it started" };
|
|
320
|
+
handle = await ctx.agents.create({
|
|
321
|
+
sessionId,
|
|
322
|
+
meta: { origin: "subagent" },
|
|
323
|
+
agentOptions: selection === undefined ? {} : { ...selection },
|
|
324
|
+
setup(agentCtx) {
|
|
325
|
+
agentCtx.tools.restrict({ allow: [] });
|
|
326
|
+
for (const tool of [...tools, reportTool(holder)]) agentCtx.tools.register(tool);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
const agent = handle.agent;
|
|
330
|
+
if (signal?.aborted) return { ok: false, error: "cancelled before it started", dreamSessionId: sessionId };
|
|
331
|
+
try {
|
|
332
|
+
ctx.get?.("sessionTitle")?.rename(agent.session, `记忆整理 · ${cross ? "跨项目" : basename(cwd)}`);
|
|
333
|
+
} catch {
|
|
334
|
+
/* A missing title only costs a fallback title. */
|
|
335
|
+
}
|
|
336
|
+
stopListening = ctx.on?.("session/event", (session, event) => {
|
|
337
|
+
if (session.header?.id !== sessionId || event.type !== "assistant/message") return;
|
|
338
|
+
if (++steps > maxSteps && !budgetHit) {
|
|
339
|
+
budgetHit = true;
|
|
340
|
+
agent.cancel(new Error(`dreaming step budget of ${maxSteps} reached`));
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
const onAbort = () => agent.cancel(signal.reason);
|
|
344
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
345
|
+
try {
|
|
346
|
+
agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" } }));
|
|
347
|
+
await agent.whenIdle();
|
|
348
|
+
} finally {
|
|
349
|
+
signal?.removeEventListener("abort", onAbort);
|
|
350
|
+
}
|
|
351
|
+
const events = [...agent.session.snapshotEvents(0)];
|
|
352
|
+
const turnEnd = [...events].reverse().find((event) => event.type === "turn/end");
|
|
353
|
+
const reason = turnEnd?.data?.reason;
|
|
354
|
+
if (turnEnd === undefined) return { ok: false, error: "dreaming agent did not finish a turn", dreamSessionId: sessionId };
|
|
355
|
+
if (reason?.kind === "completed" || budgetHit) {
|
|
356
|
+
/* Cut off at the budget without a report: sessions not read to the end stay queued, from where this run got to. */
|
|
357
|
+
const unfinished = {};
|
|
358
|
+
if (budgetHit && holder.report === undefined) {
|
|
359
|
+
for (const id of sessionIds) if (credited.get(id)?.done !== true) unfinished[id] = credited.get(id)?.seq ?? fromSeqs[id] ?? 0;
|
|
360
|
+
}
|
|
361
|
+
const pending = Object.keys(unfinished).length;
|
|
362
|
+
const issues = [
|
|
363
|
+
...(budgetHit ? [`step budget of ${maxSteps} reached${pending > 0 ? `; ${pending} session(s) not read to the end will be reviewed again from where this run stopped` : holder.report === undefined ? "" : "; the report was already filed"}`] : []),
|
|
364
|
+
...toolErrors(events),
|
|
365
|
+
...(holder.report?.unresolved ?? []).map((item) => clip(item, ISSUE_CHARS)),
|
|
366
|
+
...(holder.report === undefined && !budgetHit ? ["ended without dream_report"] : [])
|
|
367
|
+
];
|
|
368
|
+
return {
|
|
369
|
+
ok: true,
|
|
370
|
+
changes: tracker.summary(),
|
|
371
|
+
issues: issues.length > ISSUE_LIMIT ? [...issues.slice(0, ISSUE_LIMIT), `…and ${issues.length - ISSUE_LIMIT} more`] : issues,
|
|
372
|
+
...(holder.report ? { summary: clip(holder.report.summary, 600) } : {}),
|
|
373
|
+
...(pending > 0 ? { unfinished } : {}),
|
|
374
|
+
dreamSessionId: sessionId
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
if (reason?.kind === "error") return { ok: false, error: reason.error?.message ?? "model error", dreamSessionId: sessionId };
|
|
378
|
+
return { ok: false, error: `dreaming turn ended with "${reason?.kind ?? "unknown"}"`, dreamSessionId: sessionId };
|
|
379
|
+
} catch (error) {
|
|
380
|
+
return { ok: false, error: error?.message ?? String(error), ...(handle ? { dreamSessionId: sessionId } : {}) };
|
|
381
|
+
} finally {
|
|
382
|
+
stopListening?.();
|
|
383
|
+
await handle?.dispose().catch(() => {});
|
|
384
|
+
await pruneHistory(store.root).catch(() => {});
|
|
385
|
+
}
|
|
386
|
+
}
|
package/lib/fsutil.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small filesystem helpers shared by the store and the dreaming state.
|
|
3
|
+
*/
|
|
4
|
+
import { rename } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
/** Errors Windows raises while an antivirus scanner or indexer briefly holds a file. */
|
|
7
|
+
const TRANSIENT_RENAME = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `rename`, retried a few times on the transient errors above.
|
|
11
|
+
* @param {string} from
|
|
12
|
+
* @param {string} to
|
|
13
|
+
* @param {{ attempts?: number, delayMs?: number }} [options]
|
|
14
|
+
*/
|
|
15
|
+
export async function renameWithRetry(from, to, { attempts = 5, delayMs = 40 } = {}) {
|
|
16
|
+
for (let attempt = 1; ; attempt++) {
|
|
17
|
+
try {
|
|
18
|
+
return await rename(from, to);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (attempt >= attempts || !TRANSIENT_RENAME.has(error?.code)) throw error;
|
|
21
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs * attempt));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
package/lib/health.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mechanical checks over a partition's notes, computed in code and put into
|
|
3
|
+
* the dreaming prompt so the model spends its steps on judgment instead:
|
|
4
|
+
* oversized or over-long INDEX.md, dangling links, notes the INDEX does not
|
|
5
|
+
* list, notes over the soft size limit.
|
|
6
|
+
*/
|
|
7
|
+
import { posix } from "node:path";
|
|
8
|
+
import { INDEX_LINE_BYTES, NOTE_SOFT_BYTES } from "./tools.js";
|
|
9
|
+
|
|
10
|
+
const LIMIT_PER_PARTITION = 12;
|
|
11
|
+
const LINK = /\]\(([^)\s]+?\.md)(?:#[^)]*)?\)/gi;
|
|
12
|
+
|
|
13
|
+
/** Link targets of a note, resolved against the note's own directory; web and absolute links are skipped. */
|
|
14
|
+
function linkTargets(notePath, content) {
|
|
15
|
+
const targets = [];
|
|
16
|
+
for (const match of content.matchAll(LINK)) {
|
|
17
|
+
const raw = match[1];
|
|
18
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith("/")) continue;
|
|
19
|
+
targets.push(posix.normalize(posix.join(posix.dirname(notePath), raw)));
|
|
20
|
+
}
|
|
21
|
+
return targets;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {import("./store.js").MemoryStore} store
|
|
26
|
+
* @param {"project" | "user"} scope
|
|
27
|
+
* @param {string | undefined} cwd - project directory for the project scope.
|
|
28
|
+
* @param {number} indexMaxBytes
|
|
29
|
+
* @returns {Promise<string[]>} one line per problem found.
|
|
30
|
+
*/
|
|
31
|
+
export async function checkPartition(store, scope, cwd, indexMaxBytes) {
|
|
32
|
+
const entries = await store.list(scope, cwd);
|
|
33
|
+
const known = new Map(entries.map((entry) => [entry.path.toLowerCase(), entry.path]));
|
|
34
|
+
const issues = [];
|
|
35
|
+
const linkedFromIndex = new Set();
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const note = await store.read(scope, cwd, entry.path);
|
|
38
|
+
if (note === null) continue;
|
|
39
|
+
const isIndex = entry.path === "INDEX.md";
|
|
40
|
+
if (isIndex) {
|
|
41
|
+
if (entry.bytes > indexMaxBytes) issues.push(`INDEX.md is ${entry.bytes} bytes; the limit is ${indexMaxBytes}`);
|
|
42
|
+
const long = note.content.split("\n").filter((line) => Buffer.byteLength(line) > INDEX_LINE_BYTES).length;
|
|
43
|
+
if (long > 0) issues.push(`INDEX.md has ${long} line(s) over ${INDEX_LINE_BYTES} bytes`);
|
|
44
|
+
} else if (entry.bytes > NOTE_SOFT_BYTES) issues.push(`${entry.path} is ${entry.bytes} bytes (soft limit ${NOTE_SOFT_BYTES}): split it or cut details`);
|
|
45
|
+
for (const target of linkTargets(entry.path, note.content)) {
|
|
46
|
+
const found = known.get(target.toLowerCase());
|
|
47
|
+
if (found === undefined) issues.push(`${entry.path} links to ${target}, which does not exist`);
|
|
48
|
+
else if (isIndex) linkedFromIndex.add(found);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (known.has("index.md")) {
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (entry.path !== "INDEX.md" && !linkedFromIndex.has(entry.path)) issues.push(`${entry.path} is not listed in INDEX.md`);
|
|
54
|
+
}
|
|
55
|
+
} else if (entries.length > 0) issues.push("there is no INDEX.md although the partition has notes");
|
|
56
|
+
return issues.length > LIMIT_PER_PARTITION ? [...issues.slice(0, LIMIT_PER_PARTITION), `…and ${issues.length - LIMIT_PER_PARTITION} more`] : issues;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Render the checks of several partitions as prompt lines.
|
|
61
|
+
* @param {Array<{ label: string, issues: string[] }>} partitions
|
|
62
|
+
* @returns {string} empty when nothing is wrong.
|
|
63
|
+
*/
|
|
64
|
+
export function renderHealth(partitions) {
|
|
65
|
+
return partitions.flatMap(({ label, issues }) => issues.map((issue) => `- ${label}: ${issue}`)).join("\n");
|
|
66
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-memory — host half.
|
|
3
|
+
*
|
|
4
|
+
* Cross-session memory for DSH agents: notes the agent keeps in a per-project
|
|
5
|
+
* and a per-user partition, an INDEX snapshot injected into each session, and
|
|
6
|
+
* "dreaming" — a hidden background agent that corrects, merges and compacts
|
|
7
|
+
* the notes against session transcripts after a session ends.
|
|
8
|
+
*
|
|
9
|
+
* Settings channel (plan Task 0, choice B): every field is volatile. The
|
|
10
|
+
* client edits the user-facing fields and writes `dreamRequest`; only the
|
|
11
|
+
* host writes `status`, through `ctx.settings.update()`.
|
|
12
|
+
*
|
|
13
|
+
* Behavior is described in README.md.
|
|
14
|
+
*/
|
|
15
|
+
import z from "@deepseek-ai/schemastery";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { stat } from "node:fs/promises";
|
|
18
|
+
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
19
|
+
import { resolveMemoryRoot, sameProject } from "./paths.js";
|
|
20
|
+
import { MemoryStore } from "./store.js";
|
|
21
|
+
import { installMemoryTools } from "./tools.js";
|
|
22
|
+
import { installIndexInjection } from "./inject.js";
|
|
23
|
+
import { DreamState } from "./state.js";
|
|
24
|
+
import { installTrigger, isEligible } from "./trigger.js";
|
|
25
|
+
import { runDream } from "./dream.js";
|
|
26
|
+
import { DreamRunner, reconcile, pruneDreamSessions } from "./runner.js";
|
|
27
|
+
import { installDreamCommand } from "./command.js";
|
|
28
|
+
import { createStatusPublisher } from "./status.js";
|
|
29
|
+
import { resolveSelection } from "./selection.js";
|
|
30
|
+
|
|
31
|
+
/** Stable Cordis plugin name; also the settings namespace. */
|
|
32
|
+
const name = "memory";
|
|
33
|
+
|
|
34
|
+
/** Host services this plugin needs. */
|
|
35
|
+
const inject = ["tools", "systemPrompt", "agents", "sessionQuery"];
|
|
36
|
+
|
|
37
|
+
/** Delay before the startup scan for sessions that ended unnoticed. */
|
|
38
|
+
const RECONCILE_DELAY_MS = 30_000;
|
|
39
|
+
|
|
40
|
+
/** Status snapshot shown on the settings page before the first run. */
|
|
41
|
+
const EMPTY_STATUS = { paused: null, running: null, pendingCount: 0, log: [] };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Durable settings schema. All fields are volatile so settings writes reach
|
|
45
|
+
* the running plugin without a remount.
|
|
46
|
+
*/
|
|
47
|
+
const Config = z.object({
|
|
48
|
+
enabled: z.boolean().default(true).volatile(),
|
|
49
|
+
autoDream: z.boolean().default(true).volatile(),
|
|
50
|
+
idleMinutes: z.natural().min(1).default(10).volatile(),
|
|
51
|
+
concurrency: z.natural().min(1).default(4).volatile(),
|
|
52
|
+
indexMaxBytes: z.natural().min(512).default(4096).volatile(),
|
|
53
|
+
dreamProvider: z.string().default("").volatile(),
|
|
54
|
+
dreamModel: z.string().default("").volatile(),
|
|
55
|
+
dreamEffort: z.string().default("").volatile(),
|
|
56
|
+
status: z.any().default(EMPTY_STATUS).volatile(),
|
|
57
|
+
dreamRequest: z.string().default("").volatile()
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Wire the memory feature into the host.
|
|
62
|
+
* @param ctx - host plugin context.
|
|
63
|
+
* @param config - live volatile config reference.
|
|
64
|
+
*/
|
|
65
|
+
async function apply(ctx, config) {
|
|
66
|
+
const root = resolveMemoryRoot();
|
|
67
|
+
const store = new MemoryStore(root);
|
|
68
|
+
installMemoryTools(ctx, store, config);
|
|
69
|
+
installIndexInjection(ctx, store, config);
|
|
70
|
+
|
|
71
|
+
const state = new DreamState(root);
|
|
72
|
+
await state.load();
|
|
73
|
+
if (state.recovered) ctx.logger.warn("memory: state.json was unreadable and has been set aside as state.json.corrupt-*; dreaming starts from an empty queue");
|
|
74
|
+
/* Leftovers of crashed writes; best effort, off the startup path. */
|
|
75
|
+
store.sweep().catch(() => {});
|
|
76
|
+
|
|
77
|
+
/* Settings channel: the host publishes `status` (retrying writes made before the plugin is active). */
|
|
78
|
+
const status = createStatusPublisher({ name, root, warn: (message) => ctx.logger.warn(message) });
|
|
79
|
+
ctx.effect(() => () => status.detach());
|
|
80
|
+
/* Projects a cross-project run covers: every existing directory with sessions, and its note count. */
|
|
81
|
+
const listProjects = async () => {
|
|
82
|
+
const projects = [];
|
|
83
|
+
for (const { header } of await ctx.sessionQuery.listSessions()) {
|
|
84
|
+
if (!isEligible(header) || projects.some((project) => sameProject(project.cwd, header.cwd))) continue;
|
|
85
|
+
if (await stat(header.cwd).then(() => true, () => false)) projects.push({ cwd: header.cwd, notes: 0 });
|
|
86
|
+
}
|
|
87
|
+
for (const project of projects) project.notes = (await store.list("project", project.cwd)).filter((entry) => entry.path !== "INDEX.md").length;
|
|
88
|
+
return projects;
|
|
89
|
+
};
|
|
90
|
+
const pruneCache = new Map();
|
|
91
|
+
const runner = new DreamRunner({
|
|
92
|
+
state,
|
|
93
|
+
config,
|
|
94
|
+
onStatus: (snapshot) => status.publish(snapshot),
|
|
95
|
+
runDream: (job) => runDream(ctx, { ...job, store }),
|
|
96
|
+
resolveSelection: () => resolveSelection(config, ctx.get("agentDefaultModel")),
|
|
97
|
+
listProjects,
|
|
98
|
+
countUserNotes: async () => (await store.list("user")).filter((entry) => entry.path !== "INDEX.md").length,
|
|
99
|
+
afterRun: () => pruneDreamSessions(join(resolveDshHome(), "sessions"), { cache: pruneCache })
|
|
100
|
+
});
|
|
101
|
+
ctx.effect(() => () => runner.stop());
|
|
102
|
+
installTrigger(ctx, {
|
|
103
|
+
idleMs: () => config.idleMinutes.get() * 60_000,
|
|
104
|
+
onEnded: (info) => {
|
|
105
|
+
runner.onSessionEnded(info).catch((error) => ctx.logger.warn("memory: could not queue session %s: %s", info.sessionId, error?.message));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
ctx.inject(["settings"], (child) => {
|
|
109
|
+
child.effect(() => child.settings.configure({ auto: false }, ctx.fiber));
|
|
110
|
+
runner.emit();
|
|
111
|
+
status.attach(child.settings);
|
|
112
|
+
child.effect(() => () => status.detach());
|
|
113
|
+
});
|
|
114
|
+
/* Every settings change: turning memory off cancels runs in flight, and turning on auto dreaming, memory or a higher concurrency starts the waiting queue.
|
|
115
|
+
A changed `dreamRequest` nonce from the settings page asks for a manual run over every project (after the same catch-up scan as /dream), then a cross-project run. */
|
|
116
|
+
let lastRequest = config.dreamRequest.get();
|
|
117
|
+
let wasEnabled = config.enabled.get();
|
|
118
|
+
ctx.on("loader/volatile-update", () => {
|
|
119
|
+
const enabled = config.enabled.get();
|
|
120
|
+
if (wasEnabled && !enabled) runner.cancelAll("memory was turned off");
|
|
121
|
+
wasEnabled = enabled;
|
|
122
|
+
runner.pump();
|
|
123
|
+
const request = config.dreamRequest.get();
|
|
124
|
+
if (request === lastRequest) return;
|
|
125
|
+
lastRequest = request;
|
|
126
|
+
if (request !== "" && enabled) {
|
|
127
|
+
reconcile(ctx.sessionQuery, state)
|
|
128
|
+
.catch((error) => ctx.logger.warn("memory: catch-up scan failed: %s", error?.message))
|
|
129
|
+
.then(() => runner.runManual({ cross: true }))
|
|
130
|
+
.catch((error) => ctx.logger.warn("memory: manual dreaming failed: %s", error?.message));
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
ctx.inject(["commands"], (child) => {
|
|
134
|
+
installDreamCommand(child, { config, runner, reconcile: (scope) => reconcile(ctx.sessionQuery, state, scope) });
|
|
135
|
+
});
|
|
136
|
+
const timer = setTimeout(() => {
|
|
137
|
+
if (!config.enabled.get()) return;
|
|
138
|
+
/* Sessions that ended while the app was closed, plus whatever the last run left queued, are consolidated now (when auto dreaming is on). */
|
|
139
|
+
reconcile(ctx.sessionQuery, state).then(() => {
|
|
140
|
+
runner.emit();
|
|
141
|
+
runner.pump();
|
|
142
|
+
}, (error) => ctx.logger.warn("memory: startup scan failed: %s", error?.message));
|
|
143
|
+
}, RECONCILE_DELAY_MS);
|
|
144
|
+
ctx.effect(() => () => clearTimeout(timer));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export { apply, inject, name, Config, EMPTY_STATUS };
|