@wjm200509/dsh-memory 0.0.0-stage → 0.1.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/lib/tools.js ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Model-facing memory tools: memory_list / memory_read / memory_write /
3
+ * memory_delete / memory_move / memory_search. Sessions use them with the partition taken from the calling
4
+ * session's working directory; dreaming reuses them with a fixed `cwdOf` and a
5
+ * change tracker. Cross-project dreaming names the project on every
6
+ * project-scope call instead.
7
+ *
8
+ * Rules enforced here rather than left to the prompt:
9
+ * - a NEW user-level note needs `basis: "user-stated"` outside cross-project
10
+ * dreaming; everything else starts in the project partition, and only
11
+ * cross-project dreaming promotes it once two projects confirm it;
12
+ * - INDEX.md must fit the injected budget, one short line per note;
13
+ * - a note over NOTE_SOFT_BYTES is saved with a warning to split it.
14
+ */
15
+ import { defineTool } from "@deepseek-ai/dsh-tools";
16
+ import { basename } from "node:path";
17
+ import { MemoryStore } from "./store.js";
18
+ import { sameProject, isIndexPath } from "./paths.js";
19
+ import { isSubagent } from "./trigger.js";
20
+
21
+ /** Longest INDEX.md line: a link plus one short sentence. */
22
+ export const INDEX_LINE_BYTES = 240;
23
+ /** Note size above which a write returns a warning. */
24
+ export const NOTE_SOFT_BYTES = 3072;
25
+
26
+ const SCOPE = {
27
+ type: "string",
28
+ enum: ["project", "user"],
29
+ required: true,
30
+ description: 'Memory partition: "project" (this working directory) or "user" (across projects).'
31
+ };
32
+ const PATH = { type: "string", required: true, description: 'Note path relative to the partition, ending in .md, e.g. "INDEX.md" or "testing/db.md".' };
33
+ const PROJECT = { type: "string", description: 'Project directory, required with scope "project"; one of the projects listed in your task.' };
34
+ const BASIS = {
35
+ type: "string",
36
+ enum: ["user-stated"],
37
+ description: 'Required to create a new user-level note: "user-stated" when the user explicitly stated this preference or way of working. Anything else goes to the project partition.'
38
+ };
39
+ const TEXT_OUTPUT = {
40
+ schema: { type: "object", additionalProperties: false, properties: { text: { type: "string", required: true, description: "Result text." } } },
41
+ render: (_args, value) => [{ type: "text", text: value.text }]
42
+ };
43
+
44
+ const CONFLICT_HINT = "the note was changed by another session or by dreaming since you read it. Call memory_read again and rewrite it based on the new content.";
45
+ const USER_GATE = 'A new user-level note needs basis "user-stated": only preferences or ways of working the user explicitly stated go straight to the user partition. Write other lessons to the project partition, with a front-matter line "candidate: user" when they look general; cross-project dreaming promotes them once a second project confirms them.';
46
+
47
+ /** Turn store errors into model-actionable tool errors. */
48
+ function explain(error) {
49
+ if (error?.code === "MEMORY_CONFLICT") return new Error(`Memory conflict: ${CONFLICT_HINT}`);
50
+ if (error?.code === "MEMORY_PATH_INVALID") return new Error(error.message);
51
+ return error;
52
+ }
53
+
54
+ const isIndex = isIndexPath;
55
+
56
+ const PROJECT_GONE = "This project's directory no longer exists, so no future session will load its notes: do not write to the project partition.";
57
+ const SUBAGENT_READONLY = "Subagents cannot change memory. Put what is worth remembering in your final result and let the parent agent decide.";
58
+ /** Secrets that are recognisable with near certainty: a note holding one is refused. */
59
+ const SECRET_PATTERNS = [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, /\bAKIA[0-9A-Z]{16}\b/, /\bsk-[A-Za-z0-9_-]{20,}/, /\bgh[pousr]_[A-Za-z0-9]{36,}/, /\bxox[abprs]-[A-Za-z0-9-]{10,}/, /\bAIza[0-9A-Za-z_-]{35}\b/];
60
+ const SECRET_REFUSED = "The note contains what looks like a secret (private key, API key or token). Remove it and say where the credential is configured instead; secrets are never stored in memory.";
61
+ const PRIVACY = "Never store secrets (API keys, tokens, passwords, private keys, credentials in connection strings) or personal data; note where a credential is configured, not its value.";
62
+
63
+ /** Refuse an INDEX.md that would be truncated on injection or carries paragraph-long lines. */
64
+ function checkIndex(content, maxBytes) {
65
+ const bytes = Buffer.byteLength(content);
66
+ if (bytes > maxBytes) throw new Error(`INDEX.md would be ${bytes} bytes; the limit is ${maxBytes}. Shorten summaries or merge notes, then write it again.`);
67
+ const long = content.split("\n").filter((line) => Buffer.byteLength(line) > INDEX_LINE_BYTES);
68
+ if (long.length > 0) throw new Error(`Each INDEX.md line must stay within ${INDEX_LINE_BYTES} bytes (a link plus one short sentence); ${long.length} line(s) are longer, e.g. "${long[0].slice(0, 60)}…". Move the details into the note.`);
69
+ }
70
+
71
+ /**
72
+ * Build the memory tools.
73
+ * @param {MemoryStore} store
74
+ * @param {{ cwdOf?: (exec: object) => string | undefined, tracker?: ReturnType<import("./store.js").createChangeTracker>,
75
+ * indexMaxBytes?: () => number, projects?: string[], projectWritable?: boolean }} [options]
76
+ * `cwdOf` defaults to the calling session's working directory. `projects`
77
+ * switches to cross-project mode: project-scope calls name one of them.
78
+ * `projectWritable: false` makes the project partition read-only (its project is gone).
79
+ * @returns {object[]} tool definitions for `ctx.tools.register`.
80
+ */
81
+ export function memoryTools(store, { cwdOf = (exec) => exec?.agent?.session?.header?.cwd, tracker, indexMaxBytes = () => 4096, projects, projectWritable = true, subagentReadOnly = true } = {}) {
82
+ const cross = projects !== undefined;
83
+ const mayWrite = (scope, exec) => {
84
+ if (subagentReadOnly && isSubagent(exec?.agent?.session?.header)) throw new Error(SUBAGENT_READONLY);
85
+ if (scope === "project" && !projectWritable) throw new Error(PROJECT_GONE);
86
+ };
87
+ const labelOf = (scope, cwd, path) => (scope === "project" && cross ? `project:${basename(cwd)}/${path}` : `${scope}/${path}`);
88
+ /** The change tracker as the store sees it: records under a note label, and copies the old content aside. */
89
+ const trackFor = (label) => tracker && { record: (kind) => tracker.record(kind, label), backup: tracker.backup };
90
+ const cwdFor = (args, exec) => {
91
+ if (!cross || args.scope !== "project") return cwdOf(exec);
92
+ const project = projects.find((candidate) => typeof args.project === "string" && args.project !== "" && sameProject(candidate, args.project));
93
+ if (project === undefined) throw new Error(`Unknown project "${args.project ?? ""}". Use one of: ${projects.join(", ")}.`);
94
+ return project;
95
+ };
96
+ const label = (args, cwd) => labelOf(args.scope, cwd, args.path);
97
+ const where = cross ? { scope: SCOPE, project: PROJECT } : { scope: SCOPE };
98
+ const run = async (fn) => {
99
+ try {
100
+ return { text: await fn() };
101
+ } catch (error) {
102
+ throw explain(error);
103
+ }
104
+ };
105
+ return [
106
+ defineTool({
107
+ name: "memory_list",
108
+ description: "List the notes in a memory partition with their size and version.",
109
+ parameters: where,
110
+ output: TEXT_OUTPUT,
111
+ isConcurrencySafe: () => true,
112
+ execute: (args, exec) => run(async () => {
113
+ const entries = await store.list(args.scope, cwdFor(args, exec));
114
+ if (entries.length === 0) return `No notes in the ${args.scope} partition.`;
115
+ return entries.map((entry) => `${entry.path} (${entry.bytes} bytes, version: ${entry.version})`).join("\n");
116
+ })
117
+ }),
118
+ defineTool({
119
+ name: "memory_read",
120
+ description: "Read one memory note. Returns its content and the version to pass to memory_write or memory_delete.",
121
+ parameters: { ...where, path: PATH },
122
+ output: TEXT_OUTPUT,
123
+ isConcurrencySafe: () => true,
124
+ execute: (args, exec) => run(async () => {
125
+ const note = await store.read(args.scope, cwdFor(args, exec), args.path);
126
+ if (note === null) throw new Error(`No note ${args.path} in the ${args.scope} partition.`);
127
+ return `version: ${note.version}\n\n${note.content}`;
128
+ })
129
+ }),
130
+ defineTool({
131
+ name: "memory_write",
132
+ description: `Create or replace a memory note. Pass the version from memory_read when replacing; omit it only when creating a new note. Keep one topic per note, under ${NOTE_SOFT_BYTES} bytes. ${PRIVACY}`,
133
+ parameters: {
134
+ ...where,
135
+ path: PATH,
136
+ content: { type: "string", required: true, description: "Full new content of the note (Markdown)." },
137
+ version: { type: "string", description: "Version returned by memory_read; omit when creating a new note." },
138
+ ...(cross ? {} : { basis: BASIS })
139
+ },
140
+ output: TEXT_OUTPUT,
141
+ execute: (args, exec) => run(async () => {
142
+ const cwd = cwdFor(args, exec);
143
+ mayWrite(args.scope, exec);
144
+ if (SECRET_PATTERNS.some((pattern) => pattern.test(args.content))) throw new Error(SECRET_REFUSED);
145
+ if (!cross && args.scope === "user" && args.version === undefined && !isIndex(args.path) && args.basis !== "user-stated") throw new Error(USER_GATE);
146
+ if (isIndex(args.path)) checkIndex(args.content, indexMaxBytes());
147
+ const { version } = await store.write(args.scope, cwd, args.path, args.content, args.version ?? null, trackFor(label(args, cwd)));
148
+ const bytes = Buffer.byteLength(args.content);
149
+ const warning = !isIndex(args.path) && bytes > NOTE_SOFT_BYTES ? `\nWarning: this note is ${bytes} bytes. Keep notes under ${NOTE_SOFT_BYTES} bytes with one topic each: split it or cut details.` : "";
150
+ return `Saved ${args.path} (${args.scope}). version: ${version}${warning}`;
151
+ })
152
+ }),
153
+ defineTool({
154
+ name: "memory_delete",
155
+ description: "Delete a memory note. Pass the version from memory_read.",
156
+ parameters: { ...where, path: PATH, version: { type: "string", required: true, description: "Version returned by memory_read." } },
157
+ output: TEXT_OUTPUT,
158
+ execute: (args, exec) => run(async () => {
159
+ const cwd = cwdFor(args, exec);
160
+ mayWrite(args.scope, exec);
161
+ await store.remove(args.scope, cwd, args.path, args.version, trackFor(label(args, cwd)));
162
+ return `Deleted ${args.path} (${args.scope}).`;
163
+ })
164
+ }),
165
+ defineTool({
166
+ name: "memory_move",
167
+ description: cross
168
+ ? "Move a note to another path or partition in one step, keeping its content: the note is never missing or duplicated. Pass the version from memory_read. The target must not exist. Update both INDEX.md files afterwards."
169
+ : "Rename or move a note within its partition in one step, keeping its content. Pass the version from memory_read. The target must not exist. Update INDEX.md afterwards.",
170
+ parameters: {
171
+ ...where,
172
+ path: PATH,
173
+ version: { type: "string", required: true, description: "Version returned by memory_read." },
174
+ ...(cross ? { toScope: { ...SCOPE, description: "Partition to move the note into." }, toProject: { ...PROJECT, description: 'Target project directory, required with toScope "project".' } } : {}),
175
+ toPath: { ...PATH, description: "New note path in the target partition, ending in .md." }
176
+ },
177
+ output: TEXT_OUTPUT,
178
+ execute: (args, exec) => run(async () => {
179
+ const cwd = cwdFor(args, exec);
180
+ const toScope = cross ? args.toScope : args.scope;
181
+ const toCwd = cross ? cwdFor({ scope: toScope, project: args.toProject }, exec) : cwd;
182
+ if (isIndex(args.path) || isIndex(args.toPath)) throw new Error("INDEX.md cannot be moved; rewrite it with memory_write.");
183
+ mayWrite(args.scope, exec);
184
+ mayWrite(toScope, exec);
185
+ const from = { scope: args.scope, cwd, path: args.path };
186
+ const to = { scope: toScope, cwd: toCwd, path: args.toPath };
187
+ const { version } = await store.move(from, to, args.version, tracker && { backup: tracker.backup });
188
+ tracker?.record("deleted", labelOf(args.scope, cwd, args.path));
189
+ tracker?.record("created", labelOf(toScope, toCwd, args.toPath));
190
+ return `Moved ${args.path} (${args.scope}) to ${args.toPath} (${toScope}). version: ${version}`;
191
+ })
192
+ }),
193
+ defineTool({
194
+ name: "memory_search",
195
+ description: "Case-insensitive literal search across the notes of one memory partition. Returns `path:line: text` hits.",
196
+ parameters: { ...where, query: { type: "string", required: true, description: "Literal text to find." } },
197
+ output: TEXT_OUTPUT,
198
+ isConcurrencySafe: () => true,
199
+ execute: (args, exec) => run(async () => {
200
+ const hits = await store.search(args.scope, cwdFor(args, exec), args.query);
201
+ return hits.length === 0 ? "No matches." : hits.join("\n");
202
+ })
203
+ })
204
+ ];
205
+ }
206
+
207
+ /**
208
+ * Register the session memory tools globally while the master switch is on,
209
+ * and withdraw them while it is off.
210
+ * @param ctx - host plugin context (`tools`, `on`).
211
+ * @param {MemoryStore} store
212
+ * @param config - live config references (`enabled`, `indexMaxBytes`).
213
+ */
214
+ export function installMemoryTools(ctx, store, config) {
215
+ let disposers = [];
216
+ const sync = () => {
217
+ const want = config.enabled.get();
218
+ if (want && disposers.length === 0) disposers = memoryTools(store, { indexMaxBytes: () => config.indexMaxBytes.get() }).map((tool) => ctx.tools.register(tool));
219
+ else if (!want && disposers.length > 0) {
220
+ for (const dispose of disposers) dispose();
221
+ disposers = [];
222
+ }
223
+ };
224
+ sync();
225
+ ctx.on("loader/volatile-update", sync);
226
+ }
package/lib/trigger.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Decide when a session has "ended" for dreaming purposes.
3
+ *
4
+ * DSH has no explicit end-of-session event, so a session counts as ended when
5
+ * it stays idle for `idleMs` after a `turn/end`, or when it is disposed —
6
+ * whichever comes first. It is reported only when the work since it was last
7
+ * reported is worth consolidating (see `isSubstantive`): a greeting, an
8
+ * aborted turn or an untouched session queues nothing, and their activity
9
+ * carries over until the session does real work.
10
+ */
11
+
12
+ /**
13
+ * Sessions that dreaming learns from: top-level user sessions bound to a
14
+ * project directory. Subagent children and cwd-less sessions (including the
15
+ * hidden dreaming agent itself) are excluded.
16
+ * @param {{ cwd?: string, origin?: string, parentSession?: string }} header
17
+ */
18
+ export function isEligible(header) {
19
+ return typeof header?.cwd === "string" && header.cwd.length > 0 && !isSubagent(header);
20
+ }
21
+
22
+ /** A subagent session: started by another agent, or the hidden dreaming agent itself. */
23
+ export function isSubagent(header) {
24
+ return header?.origin === "subagent" || header?.parentSession !== undefined;
25
+ }
26
+
27
+ /** @typedef {{ completedTurns: number, toolCalls: number }} Activity */
28
+
29
+ /** @returns {Activity} */
30
+ export const emptyActivity = () => ({ completedTurns: 0, toolCalls: 0 });
31
+
32
+ /**
33
+ * Fold one session event into its activity: turns that completed (not
34
+ * aborted or failed) and tool calls other than the memory tools.
35
+ * @param {Activity} activity
36
+ * @param {{ type: string, data?: any }} event
37
+ * @returns {Activity}
38
+ */
39
+ export function accumulate(activity, event) {
40
+ if (event.type === "turn/end" && event.data?.reason?.kind === "completed") return { ...activity, completedTurns: activity.completedTurns + 1 };
41
+ if (event.type === "assistant/message") {
42
+ const content = Array.isArray(event.data?.message?.content) ? event.data.message.content : [];
43
+ const calls = content.filter((block) => block?.type === "tool-call" && !String(block.name ?? "").startsWith("memory_")).length;
44
+ if (calls > 0) return { ...activity, toolCalls: activity.toolCalls + calls };
45
+ }
46
+ return activity;
47
+ }
48
+
49
+ /**
50
+ * Worth a dreaming run: at least two completed turns, or one completed turn
51
+ * that used a tool.
52
+ * @param {Activity} activity
53
+ */
54
+ export function isSubstantive(activity) {
55
+ return activity.completedTurns >= 2 || (activity.completedTurns >= 1 && activity.toolCalls >= 1);
56
+ }
57
+
58
+ /**
59
+ * @param ctx - host plugin context (`on`, `effect`).
60
+ * @param {{ idleMs: () => number, onEnded: (info: { sessionId: string, cwd: string, endedAt: number, seq: number }) => void, now?: () => number }} options
61
+ */
62
+ export function installTrigger(ctx, { idleMs, onEnded, now = Date.now }) {
63
+ const timers = new Map();
64
+ /** Activity since the session was last reported. */
65
+ const activity = new Map();
66
+
67
+ const cancel = (id) => {
68
+ clearTimeout(timers.get(id));
69
+ timers.delete(id);
70
+ };
71
+ const fire = (session) => {
72
+ const id = session.header.id;
73
+ cancel(id);
74
+ if (!isSubstantive(activity.get(id) ?? emptyActivity())) return;
75
+ activity.delete(id);
76
+ onEnded({ sessionId: id, cwd: session.header.cwd, endedAt: now(), seq: session.seq });
77
+ };
78
+
79
+ ctx.on("session/event", (session, event) => {
80
+ if (!isEligible(session.header)) return;
81
+ const id = session.header.id;
82
+ activity.set(id, accumulate(activity.get(id) ?? emptyActivity(), event));
83
+ if (event.type === "turn/start") cancel(id);
84
+ else if (event.type === "turn/end") {
85
+ cancel(id);
86
+ timers.set(id, setTimeout(() => fire(session), idleMs()));
87
+ }
88
+ });
89
+ ctx.on("session/disposed", (session) => {
90
+ if (!isEligible(session.header)) return;
91
+ fire(session);
92
+ activity.delete(session.header.id);
93
+ });
94
+ ctx.effect(() => () => {
95
+ for (const id of [...timers.keys()]) cancel(id);
96
+ });
97
+ }
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "@wjm200509/dsh-memory",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Cross-session agent memory for DSH: notes the agent keeps across sessions, plus automatic dreaming that corrects and compacts them against session transcripts",
5
+ "type": "module",
6
+ "author": "JiaMingWang-CN",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/JiaMingWang-CN/dsh-bundles#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/JiaMingWang-CN/dsh-bundles",
12
+ "directory": "dsh-memory"
13
+ },
14
+ "keywords": [
15
+ "dsh",
16
+ "dsh-plugin",
17
+ "deepseek-harness",
18
+ "memory",
19
+ "agent-memory"
20
+ ],
21
+ "files": [
22
+ "lib",
23
+ "patch.yml",
24
+ "README.md"
25
+ ],
26
+ "engines": {
27
+ "node": ">=22.15.0",
28
+ "dsh": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-rc.1"
29
+ },
30
+ "scripts": {
31
+ "test": "node --test"
32
+ },
33
+ "exports": {
34
+ ".": "./lib/index.js",
35
+ "./client": "./lib/client.js"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "peerDependencies": {
41
+ "@deepseek-ai/dsh-home-paths": "^0.2.2-rc.1",
42
+ "@deepseek-ai/dsh-llm": "^0.2.2-rc.1",
43
+ "@deepseek-ai/dsh-tools": "^0.2.2-rc.1"
44
+ },
45
+ "dependencies": {
46
+ "@deepseek-ai/schemastery": "^3.18.5"
47
+ },
48
+ "devDependencies": {
49
+ "@deepseek-ai/dsh-home-paths": "0.2.2-rc.1",
50
+ "@deepseek-ai/dsh-llm": "0.2.2-rc.1",
51
+ "@deepseek-ai/dsh-tools": "0.2.2-rc.1"
52
+ },
53
+ "dsh": {
54
+ "client": {
55
+ "platform": "web"
56
+ },
57
+ "bundle": {
58
+ "patch": "./patch.yml"
59
+ }
60
+ }
61
+ }
package/patch.yml ADDED
@@ -0,0 +1,9 @@
1
+ # Cross-session agent memory: notes kept per project and per user, an INDEX
2
+ # snapshot injected into every session, and automatic "dreaming" after a
3
+ # session ends. Every line is a declarative loader patch — nothing in DSH
4
+ # itself is modified, and removing this bundle restores the shipped behavior.
5
+ # Memory files live under <DSH home>/memory/ and are left in place on
6
+ # uninstall so a reinstall keeps what the agent learned.
7
+ - insert:
8
+ - id: memory
9
+ name: '@wjm200509/dsh-memory'