dsh-memory-recall 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/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "dsh-memory-recall",
3
+ "version": "0.1.0",
4
+ "description": "Persistent memory for dsh in the style of Hermes Agent: a memory tool that keeps a user profile and the model's own notes in every new chat's prompt, and a session_search tool that reads earlier chats only after the user approves, with a Memory panel to see and edit what is kept.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "lib",
16
+ "cordis.patch.yml",
17
+ "package.json",
18
+ "README.md",
19
+ "LICENSE",
20
+ "dsh.plugin.json",
21
+ "screenshots.json"
22
+ ],
23
+ "scripts": {
24
+ "test": "node --test \"test/*.test.js\""
25
+ },
26
+ "dsh": {
27
+ "bundle": {
28
+ "patch": "./cordis.patch.yml"
29
+ },
30
+ "client": {
31
+ "platform": "web",
32
+ "inject": [
33
+ "@deepseek-ai/dsh-client-ui-conversation",
34
+ "@deepseek-ai/dsh-client-ui-sidebar"
35
+ ]
36
+ }
37
+ },
38
+ "keywords": [
39
+ "dsh-plugin",
40
+ "dsh",
41
+ "deepseek-harness",
42
+ "memory",
43
+ "recall",
44
+ "session-search",
45
+ "hermes"
46
+ ],
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/adithyanraj03/dsh-memory-recall.git"
50
+ },
51
+ "homepage": "https://adithyanraj03.github.io/dsh-memory-recall/",
52
+ "bugs": {
53
+ "url": "https://github.com/adithyanraj03/dsh-memory-recall/issues"
54
+ },
55
+ "author": "Adithya N Raj (adithyanraj03)",
56
+ "license": "MIT",
57
+ "engines": {
58
+ "node": ">=22"
59
+ }
60
+ }
@@ -0,0 +1,6 @@
1
+ [
2
+ "assets/memory-panel.png",
3
+ "assets/saved-chip.png",
4
+ "assets/recall-card.png",
5
+ "assets/memory-panel-light.png"
6
+ ]
package/src/chats.js ADDED
@@ -0,0 +1,160 @@
1
+ // Every chat the user can @-mention, grouped by the workspace it belongs to.
2
+ //
3
+ // dsh's own @ list shows at most 50 sessions and includes sub-agent sessions
4
+ // — the model's workers, which have no titles and show as raw ids — so real
5
+ // chats get pushed out by them. This index lists only the user's own
6
+ // top-level chats, all of them, under the workspace titles the sidebar shows,
7
+ // and gives every chat a readable name: its title, or failing that the start
8
+ // of its first message.
9
+ //
10
+ // Workspaces come from dsh's workspace registry (same titles and order as the
11
+ // sidebar); a chat in no workspace is listed under "No workspace". The whole
12
+ // index is cached briefly — the @ menu asks on every keystroke — and first
13
+ // messages are cached for good, since a chat's first message never changes.
14
+
15
+ const CACHE_MS = 15_000;
16
+ const LABEL_CHARS = 70;
17
+
18
+ const isRoot = (header) => header && !header.parentSession && header.origin !== 'subagent';
19
+
20
+ /** One line of text, cut to fit a menu row. */
21
+ export function labelFrom(text, max = LABEL_CHARS) {
22
+ const line = String(text ?? '').replace(/\s+/g, ' ').trim();
23
+ return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line;
24
+ }
25
+
26
+ /** Case-insensitive path equality on Windows, exact elsewhere; trailing separators ignored. */
27
+ export function samePath(a, b, platform = process.platform) {
28
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
29
+ const norm = (p) => p.replace(/[\\/]+$/, '').replace(/\//g, '\\');
30
+ return platform === 'win32' ? norm(a).toLowerCase() === norm(b).toLowerCase() : a.replace(/\/+$/, '') === b.replace(/\/+$/, '');
31
+ }
32
+
33
+ /**
34
+ * @param {object} p
35
+ * @param {() => object|undefined} p.getQuery ctx.sessionQuery
36
+ * @param {() => object|undefined} p.getWorkspaces ctx.workspaceRegistry
37
+ * @param {() => number} [p.now]
38
+ */
39
+ export function createChatIndex({ getQuery, getWorkspaces, now = () => Date.now() }) {
40
+ const firstMessages = new Map(); // sessionId → label (or '' when there is none)
41
+ let cached = null;
42
+
43
+ async function firstMessageOf(query, id) {
44
+ if (firstMessages.has(id)) return firstMessages.get(id);
45
+ let label = '';
46
+ try {
47
+ const docs = await query.filterEvents(id, [{ kind: 'type', values: ['user/message'] }, { kind: 'seq', from: 0, to: 200 }]);
48
+ const first = [...(docs ?? [])].sort((a, b) => a.seq - b.seq).find((d) => String(d.text ?? '').trim());
49
+ label = first ? labelFrom(first.text) : '';
50
+ } catch { /* unreadable: stays untitled */ }
51
+ firstMessages.set(id, label);
52
+ return label;
53
+ }
54
+
55
+ async function titlesOf(query, ids) {
56
+ const out = new Map();
57
+ for (let i = 0; i < ids.length; i += 100) {
58
+ try {
59
+ const rows = await query.readTitleSnapshots(ids.slice(i, i + 100));
60
+ for (const row of rows ?? []) {
61
+ if (row?.status === 'fulfilled' && row.value?.title?.title) out.set(row.sessionId, row.value.title.title);
62
+ }
63
+ } catch { /* a failed batch leaves its chats to the fallback */ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** Run `fn` over `items`, at most `limit` at a time. */
69
+ async function pooled(items, limit, fn) {
70
+ let next = 0;
71
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
72
+ while (next < items.length) {
73
+ const item = items[next++];
74
+ await fn(item);
75
+ }
76
+ });
77
+ await Promise.all(workers);
78
+ }
79
+
80
+ async function build() {
81
+ const query = getQuery();
82
+ if (!query || typeof query.listSessions !== 'function') throw new Error("dsh's session query service is not available");
83
+ const headers = (await query.listSessions()).map((r) => r.header).filter(isRoot);
84
+ const byId = new Map(headers.map((h) => [h.id, h]));
85
+ const titles = await titlesOf(query, headers.map((h) => h.id));
86
+ const untitled = headers.filter((h) => !titles.has(h.id));
87
+ const untitledIds = new Set(untitled.map((h) => h.id));
88
+ await pooled(untitled, 4, async (h) => {
89
+ const label = await firstMessageOf(query, h.id);
90
+ if (label) titles.set(h.id, label);
91
+ });
92
+
93
+ const chat = (h) => ({
94
+ sessionId: h.id,
95
+ title: titles.get(h.id) ?? null,
96
+ fromMessage: !!titles.get(h.id) && untitledIds.has(h.id),
97
+ createdAt: h.createdAt,
98
+ cwd: h.cwd ?? null,
99
+ });
100
+
101
+ let registry = [];
102
+ try {
103
+ registry = getWorkspaces()?.list?.() ?? [];
104
+ } catch { /* no workspaces: everything is ungrouped */ }
105
+
106
+ const placed = new Set();
107
+ const workspaces = [];
108
+ for (const ws of registry) {
109
+ const chats = [];
110
+ for (const id of ws.sessionIds ?? []) {
111
+ const h = byId.get(id);
112
+ if (!h || placed.has(id)) continue;
113
+ placed.add(id);
114
+ chats.push(chat(h));
115
+ }
116
+ // A chat whose folder matches but that the registry has not recorded yet.
117
+ for (const h of headers) {
118
+ if (!placed.has(h.id) && samePath(h.cwd, ws.path)) {
119
+ placed.add(h.id);
120
+ chats.push(chat(h));
121
+ }
122
+ }
123
+ if (!chats.length) continue;
124
+ chats.sort((a, b) => b.createdAt - a.createdAt);
125
+ workspaces.push({ id: String(ws.id), title: ws.title || ws.path, path: ws.path, chats });
126
+ }
127
+ const ungrouped = headers.filter((h) => !placed.has(h.id)).map(chat).sort((a, b) => b.createdAt - a.createdAt);
128
+ return { builtAt: now(), workspaces, ungrouped };
129
+ }
130
+
131
+ /** The grouped index, rebuilt at most every 15 seconds. */
132
+ async function list({ fresh = false } = {}) {
133
+ if (!fresh && cached && now() - cached.builtAt < CACHE_MS) return cached;
134
+ cached = await build();
135
+ return cached;
136
+ }
137
+
138
+ /** Which workspace a session belongs to, by membership or folder. */
139
+ function workspaceOf(index, sessionId, cwd) {
140
+ return index.workspaces.find((w) => w.chats.some((c) => c.sessionId === sessionId))
141
+ ?? (cwd ? index.workspaces.find((w) => samePath(w.path, cwd)) : undefined)
142
+ ?? null;
143
+ }
144
+
145
+ /**
146
+ * Resolve what the user or model named — a workspace title, its folder, or
147
+ * its id — to the workspace. Titles may repeat; an exact folder wins.
148
+ */
149
+ async function resolveWorkspace(name) {
150
+ const wanted = String(name ?? '').trim();
151
+ if (!wanted) return null;
152
+ const index = await list();
153
+ return index.workspaces.find((w) => w.id === wanted || samePath(w.path, wanted))
154
+ ?? index.workspaces.find((w) => w.title.toLowerCase() === wanted.toLowerCase())
155
+ ?? index.workspaces.find((w) => w.title.toLowerCase().includes(wanted.toLowerCase()))
156
+ ?? null;
157
+ }
158
+
159
+ return { list, workspaceOf, resolveWorkspace, invalidate: () => { cached = null; } };
160
+ }
@@ -0,0 +1,75 @@
1
+ // Getting dsh's `defineTool`, however this plugin was installed.
2
+ //
3
+ // A plugin installed as a copy inside the profile's node_modules can import
4
+ // `@deepseek-ai/dsh-tools` by name: that is where dsh's loader makes its own
5
+ // packages resolvable. Installed as a LINK (`dsh plugin add /path`, which pnpm
6
+ // records as `link:`), Node resolves imports from the plugin's real folder
7
+ // instead — outside the profile — and the import fails. It failed silently
8
+ // here once: the plugin loaded, told the model about its memory tool, and
9
+ // registered none.
10
+ //
11
+ // So: the ordinary import first; failing that, the copy of dsh-tools inside
12
+ // dsh's own installation, found by walking up from dsh's launcher. That is the
13
+ // very file dsh loaded, so it is the same module instance, not a second copy.
14
+
15
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
16
+ import { dirname, join } from 'node:path';
17
+ import { pathToFileURL } from 'node:url';
18
+
19
+ const PACKAGE = ['node_modules', '@deepseek-ai', 'dsh-tools'];
20
+
21
+ /** The entry file a package.json names for a plain `import`. */
22
+ function entryOf(dir) {
23
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
24
+ const root = pkg.exports?.['.'] ?? pkg.exports;
25
+ const entry = typeof root === 'string' ? root : root?.import ?? root?.default ?? pkg.main ?? 'index.js';
26
+ return join(dir, entry);
27
+ }
28
+
29
+ /**
30
+ * The dsh-tools entry file above `start` (a file or folder), or null.
31
+ * @param {string} start normally process.argv[1], dsh's bin.js
32
+ */
33
+ export function findDshTools(start) {
34
+ if (!start) return null;
35
+ let dir;
36
+ try {
37
+ dir = dirname(realpathSync(start));
38
+ } catch {
39
+ return null;
40
+ }
41
+ for (;;) {
42
+ const candidate = join(dir, ...PACKAGE);
43
+ if (existsSync(join(candidate, 'package.json'))) {
44
+ try {
45
+ const entry = entryOf(candidate);
46
+ if (existsSync(entry)) return entry;
47
+ } catch { /* unreadable package.json: keep looking */ }
48
+ }
49
+ const parent = dirname(dir);
50
+ if (parent === dir) return null;
51
+ dir = parent;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * dsh's defineTool. Throws the ORIGINAL import error when neither way works,
57
+ * so the reason logged is the real one.
58
+ * @param {object} [o]
59
+ * @param {() => Promise<object>} [o.importByName] the ordinary import (a hook for tests)
60
+ * @param {string} [o.launcher] where dsh was started from
61
+ */
62
+ export async function loadDefineTool({ importByName = () => import('@deepseek-ai/dsh-tools'), launcher = process.argv[1] } = {}) {
63
+ try {
64
+ const mod = await importByName();
65
+ if (typeof mod?.defineTool === 'function') return mod.defineTool;
66
+ throw new Error('@deepseek-ai/dsh-tools has no defineTool export');
67
+ } catch (first) {
68
+ const entry = findDshTools(launcher);
69
+ if (entry) {
70
+ const mod = await import(pathToFileURL(entry).href);
71
+ if (typeof mod?.defineTool === 'function') return mod.defineTool;
72
+ }
73
+ throw first;
74
+ }
75
+ }
package/src/index.js ADDED
@@ -0,0 +1,156 @@
1
+ // dsh-memory-recall — Hermes Agent-style memory for dsh.
2
+ //
3
+ // memory the model saves, corrects and forgets facts in USER.md and
4
+ // MEMORY.md; every new chat starts with them in its prompt
5
+ // session_search the model looks things up in earlier chats — after the
6
+ // user allows it, through dsh's own approval prompt
7
+ //
8
+ // Data lives in ~/.dsh/dsh-memory-recall/. Nothing leaves the machine.
9
+ //
10
+ // Config (all optional, set on this plugin's row in cordis.patch.yml):
11
+ // dir where USER.md and MEMORY.md live
12
+ // limits: {memory, user} character budgets (default 2200 / 1375)
13
+ // recall: false drop session_search, keep memory
14
+ // askBeforeRecall: false read earlier chats without asking (default: ask)
15
+
16
+ import { homedir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { MemoryStore } from './store.js';
19
+ import { SECTION_NAME, SECTION_ORDER, renderSection } from './snapshot.js';
20
+ import { createMemoryTool } from './memory-tool.js';
21
+ import { createRecall } from './recall.js';
22
+ import { createRecallTool } from './recall-tool.js';
23
+ import { createRecallLog, registerRoutes } from './routes.js';
24
+ import { createChatIndex } from './chats.js';
25
+ import { loadDefineTool } from './dsh-tools.js';
26
+
27
+ export const name = 'dsh-memory-recall';
28
+
29
+ /**
30
+ * Required services only. The session query, approval and system-prompt
31
+ * services are resolved per use with `get`, so a composition without one
32
+ * loses that feature rather than failing to load the plugin.
33
+ */
34
+ export const inject = ['webServer', 'tools'];
35
+
36
+ /** Look a service up without throwing when it is absent. */
37
+ function service(ctx, id) {
38
+ try {
39
+ return ctx?.get?.(id);
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
44
+
45
+ /** A user's own chat — not a sub-agent the model started. */
46
+ function isTopLevel(agent) {
47
+ const header = agent?.session?.header;
48
+ return !header || (!header.parentSession && header.origin !== 'subagent');
49
+ }
50
+
51
+ /**
52
+ * @param {object} ctx host plugin context
53
+ * @param {object} [config]
54
+ * @param {object} [deps] test hooks: defineTool, loadDefineTool
55
+ */
56
+ export async function apply(ctx, config = {}, deps = {}) {
57
+ const dir = config.dir || join(homedir(), '.dsh', 'dsh-memory-recall');
58
+ const store = new MemoryStore({ dir, limits: config.limits });
59
+ await store.load();
60
+
61
+ const log = createRecallLog();
62
+ const grants = new Map();
63
+ // Every chat, grouped by workspace: the @ menu's list, and what a
64
+ // `workspace` given to session_search resolves against.
65
+ const chatIndex = createChatIndex({
66
+ getQuery: () => service(ctx, 'sessionQuery'),
67
+ getWorkspaces: () => service(ctx, 'workspaceRegistry'),
68
+ });
69
+ const cwdOf = async (sessionId) => {
70
+ try {
71
+ const rows = await service(ctx, 'sessionQuery')?.filterSessions?.([{ kind: 'id', values: [sessionId] }]);
72
+ return rows?.[0]?.header?.cwd ?? null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ };
77
+ const disposers = [];
78
+ // Whether the model actually got its tools. The prompt and the Memory
79
+ // panel both read this, so neither can promise a tool that is not there.
80
+ const tooling = { ok: false, error: null, tools: [] };
81
+
82
+ // ---- the model's tools ----------------------------------------------
83
+ if (ctx.tools?.register) {
84
+ try {
85
+ const defineTool = deps.defineTool ?? (await (deps.loadDefineTool ?? loadDefineTool)());
86
+ const tools = [createMemoryTool({ store, defineTool })];
87
+ if (config.recall !== false) {
88
+ const recall = createRecall({ getQuery: () => service(ctx, 'sessionQuery'), options: config.recallOptions });
89
+ tools.push(createRecallTool({
90
+ recall,
91
+ defineTool,
92
+ getApproval: () => service(ctx, 'approval'),
93
+ grants,
94
+ log,
95
+ askFirst: config.askBeforeRecall !== false,
96
+ resolveWorkspace: (name) => chatIndex.resolveWorkspace(name),
97
+ listWorkspaces: async () => (await chatIndex.list()).workspaces,
98
+ }));
99
+ }
100
+ for (const tool of tools) {
101
+ const dispose = ctx.tools.register(tool);
102
+ if (typeof dispose === 'function') disposers.push(dispose);
103
+ tooling.tools.push(tool.name);
104
+ }
105
+ tooling.ok = true;
106
+ } catch (error) {
107
+ tooling.error = String(error?.message ?? error);
108
+ ctx.logger?.warn?.(`[dsh-memory-recall] tools not registered: ${tooling.error}`);
109
+ }
110
+ } else {
111
+ tooling.error = 'this dsh has no tool registry';
112
+ }
113
+
114
+ // ---- each new chat: memory in its prompt, frozen for its lifetime ---
115
+ const briefed = new WeakSet();
116
+ ctx.on?.('agent/session-start', (payload) => {
117
+ const agent = payload?.agent;
118
+ const agentCtx = agent?.ctx;
119
+ if (!agentCtx || briefed.has(agentCtx) || !isTopLevel(agent)) return;
120
+ briefed.add(agentCtx);
121
+ try {
122
+ const prompt = service(agentCtx, 'systemPrompt');
123
+ if (typeof prompt?.section !== 'function') return;
124
+ // Read now, once: this text is what the chat keeps, so a save made later
125
+ // in it never rewrites its prompt (which would cost the cached prefix).
126
+ const text = renderSection(store.loadSync(), store.limits, { tools: tooling.ok });
127
+ const register = () => prompt.section({ name: SECTION_NAME, order: SECTION_ORDER, text });
128
+ if (typeof agentCtx.effect === 'function') agentCtx.effect(register, 'dsh-memory-recall: memory snapshot');
129
+ else register();
130
+ } catch (error) {
131
+ // Losing the snapshot costs this chat its memory, never the chat.
132
+ ctx.logger?.warn?.(`[dsh-memory-recall] no memory for this chat: ${error?.message ?? error}`);
133
+ }
134
+ });
135
+
136
+ // One "allow" lasts until the reply it was given in ends.
137
+ ctx.on?.('agent/status', (payload) => {
138
+ if (payload?.status === 'idle' && payload.agent?.id) grants.delete(payload.agent.id);
139
+ });
140
+
141
+ // ---- the browser half ----------------------------------------------
142
+ if (ctx.webServer?.register) {
143
+ disposers.push(registerRoutes(ctx.webServer, { store, log, chatIndex, cwdOf, tooling }));
144
+ } else {
145
+ ctx.logger?.warn?.('[dsh-memory-recall] webServer unavailable; the Memory panel and cards will be empty');
146
+ }
147
+
148
+ ctx.on?.('dispose', () => {
149
+ for (const dispose of disposers) {
150
+ try { dispose(); } catch { /* keep disposing the rest */ }
151
+ }
152
+ });
153
+ // cordis treats a returned value as an effect disposer; return nothing.
154
+ }
155
+
156
+ export default { apply, inject, name };
@@ -0,0 +1,136 @@
1
+ // The `memory` tool: how the model saves, corrects and forgets.
2
+ //
3
+ // Hermes Agent's tool, kept close to the original because its wording is the
4
+ // result of a lot of tuning: one tool, two targets, add/replace/remove, and a
5
+ // batch form whose budget is checked on the FINAL result — which is what lets
6
+ // a full store make room and add in a single call instead of failing forever.
7
+ //
8
+ // The first words of every result are fixed ("Saved to memory", "Already in
9
+ // memory", "Not saved", "Blocked"): the chat card reads them to label itself.
10
+
11
+ import { TARGETS } from './store.js';
12
+
13
+ export const MEMORY_TOOL = 'memory';
14
+
15
+ export const TEXT_OUTPUT = {
16
+ schema: { type: 'object', properties: { text: { type: 'string' } }, additionalProperties: false },
17
+ render: (_args, value) => [{ type: 'text', text: value.text }],
18
+ };
19
+
20
+ const ACTIONS = ['add', 'replace', 'remove'];
21
+
22
+ export const MEMORY_DESCRIPTION = [
23
+ 'Save durable facts to persistent memory that carries across chats. Memory is placed in the system prompt of every new chat,',
24
+ 'so keep entries compact and high-signal. Changes are saved at once but show in the prompt from the next new chat.',
25
+ '',
26
+ 'HOW: for several changes, make them ALL in ONE call with an `operations` list (each item {action, content?, old_text?}).',
27
+ 'The batch is applied all-or-nothing and the character budget is checked only on the FINAL result, so one call can remove',
28
+ 'or shorten stale entries to make room AND add new ones. For a single change, use action/content/old_text instead.',
29
+ 'The result confirms the update is complete; do not repeat it.',
30
+ '',
31
+ "TARGETS: 'user' = who the user is (name, role, preferences, working style). 'memory' = your own notes",
32
+ '(environment facts, conventions, tool quirks, lessons that apply to every task).',
33
+ '',
34
+ 'WHEN: facts that will matter in future chats regardless of the task. The user sees every save and can edit or delete it.',
35
+ '',
36
+ 'IF FULL: an add is refused with the current entries listed. Reissue as ONE batch that merges or removes enough stale',
37
+ 'entries and adds the new one together.',
38
+ '',
39
+ 'SKIP: trivial or easily re-discovered facts, raw data, task progress, logs of finished work, temporary to-dos, and',
40
+ 'anything secret. To look something up from an earlier chat instead of remembering it, use session_search.',
41
+ ].join('\n');
42
+
43
+ const fmt = (n) => Number(n).toLocaleString('en-US');
44
+
45
+ /** "42% — 578/1,375 chars" */
46
+ export function usageText(usage) {
47
+ return `${usage.percent}% — ${fmt(usage.chars)}/${fmt(usage.limit)} chars`;
48
+ }
49
+
50
+ function countChanges(changed = {}) {
51
+ const parts = [];
52
+ if (changed.added?.length) parts.push(`added ${changed.added.length}`);
53
+ if (changed.replaced?.length) parts.push(`replaced ${changed.replaced.length}`);
54
+ if (changed.removed?.length) parts.push(`removed ${changed.removed.length}`);
55
+ return parts.join(', ');
56
+ }
57
+
58
+ /** The model-facing text for a store result. */
59
+ export function describeResult(result) {
60
+ const label = TARGETS[result.target]?.label ?? result.target;
61
+ if (result.success) {
62
+ const what = countChanges(result.changed);
63
+ if (!what) {
64
+ return `Already in memory (${label}) — nothing changed. ${usageText(result.usage)}. This update is complete — do not repeat it.`;
65
+ }
66
+ return `Saved to memory (${label}): ${what}. Now ${usageText(result.usage)}, ${result.entryCount} entr${result.entryCount === 1 ? 'y' : 'ies'}. `
67
+ + 'This update is complete — do not repeat it.';
68
+ }
69
+ const head = result.blocked ? `Blocked — not saved (${label}): ${result.error}` : `Not saved (${label}): ${result.error}`;
70
+ if (!Array.isArray(result.currentEntries)) return head;
71
+ const list = result.currentEntries.length
72
+ ? result.currentEntries.map((entry, i) => `${i + 1}. ${entry}`).join('\n')
73
+ : '(empty)';
74
+ return `${head}\n\nCurrent ${label} entries (${usageText(result.usage)}):\n${list}`;
75
+ }
76
+
77
+ /**
78
+ * @param {object} p
79
+ * @param {import('./store.js').MemoryStore} p.store
80
+ * @param {Function} p.defineTool from @deepseek-ai/dsh-tools
81
+ */
82
+ export function createMemoryTool({ store, defineTool }) {
83
+ return defineTool({
84
+ name: MEMORY_TOOL,
85
+ description: MEMORY_DESCRIPTION,
86
+ parameters: {
87
+ target: {
88
+ type: 'string', enum: ['memory', 'user'], required: true,
89
+ description: "'user' for the user profile, 'memory' for your own notes.",
90
+ },
91
+ action: {
92
+ type: 'string', enum: ACTIONS,
93
+ description: 'A single change. Omit when using operations.',
94
+ },
95
+ content: {
96
+ type: 'string',
97
+ description: 'The entry text, for add and replace.',
98
+ },
99
+ old_text: {
100
+ type: 'string',
101
+ description: 'For replace and remove: a short piece of the existing entry, unique to it.',
102
+ },
103
+ operations: {
104
+ type: 'array',
105
+ description: 'Several changes applied all-or-nothing, the budget checked on the final result.',
106
+ items: {
107
+ type: 'object',
108
+ additionalProperties: false,
109
+ properties: {
110
+ action: { type: 'string', enum: ACTIONS, required: true },
111
+ content: { type: 'string', description: 'Entry text for add and replace.' },
112
+ old_text: { type: 'string', description: 'Piece of the entry to replace or remove.' },
113
+ },
114
+ },
115
+ },
116
+ },
117
+ output: TEXT_OUTPUT,
118
+ execute: async (args) => ({ text: describeResult(await runMemory(store, args)) }),
119
+ });
120
+ }
121
+
122
+ /** Dispatch one call to the store. Kept apart from the tool so it can be tested alone. */
123
+ export async function runMemory(store, args = {}) {
124
+ const target = args.target ?? 'memory';
125
+ if (!Object.hasOwn(TARGETS, target)) return store.fail(target, `unknown target '${target}' — use 'memory' or 'user'`);
126
+ // Some providers send JSON null for every omitted optional field.
127
+ const operations = Array.isArray(args.operations) && args.operations.length ? args.operations : null;
128
+ if (operations) return store.applyBatch(target, operations);
129
+ switch (args.action) {
130
+ case 'add': return store.add(target, args.content);
131
+ case 'replace': return store.replace(target, args.old_text, args.content);
132
+ case 'remove': return store.remove(target, args.old_text);
133
+ default:
134
+ return store.fail(target, `give either action (add, replace or remove) or an operations list — got action '${args.action ?? ''}'`);
135
+ }
136
+ }