dsh-memory-recall 0.1.0 → 0.2.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.
@@ -1,136 +1,167 @@
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
- }
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
+ /** `read` is a single action only: it changes nothing, so it never belongs in a batch. */
22
+ const SINGLE_ACTIONS = [...ACTIONS, 'read'];
23
+
24
+ export const MEMORY_DESCRIPTION = [
25
+ 'Save durable facts to persistent memory that carries across chats. Memory is placed in the system prompt of every new chat,',
26
+ 'so keep entries compact and high-signal. Changes are saved at once but show in the prompt from the next new chat.',
27
+ '',
28
+ 'HOW: for several changes, make them ALL in ONE call with an `operations` list (each item {action, content?, old_text?}).',
29
+ 'The batch is applied all-or-nothing and the character budget is checked only on the FINAL result, so one call can remove',
30
+ 'or shorten stale entries to make room AND add new ones. For a single change, use action/content/old_text instead.',
31
+ 'The result confirms the update is complete; do not repeat it.',
32
+ '',
33
+ "READ: action 'read' (no target needed) returns the current memory — both the user profile and your notes. You rarely need it:",
34
+ 'memory already arrives as a context message at the start, after a change and after compaction.',
35
+ '',
36
+ "TARGETS: 'user' = who the user is (name, role, preferences, working style). 'memory' = your own notes",
37
+ '(environment facts, conventions, tool quirks, lessons that apply to every task).',
38
+ '',
39
+ 'WHEN: facts that will matter in future chats regardless of the task. The user sees every save and can edit or delete it.',
40
+ '',
41
+ 'IF FULL: an add is refused with the current entries listed. Reissue as ONE batch that merges or removes enough stale',
42
+ 'entries and adds the new one together.',
43
+ '',
44
+ 'SKIP: trivial or easily re-discovered facts, raw data, task progress, logs of finished work, temporary to-dos, and',
45
+ 'anything secret. To look something up from an earlier chat instead of remembering it, use session_search.',
46
+ ].join('\n');
47
+
48
+ const fmt = (n) => Number(n).toLocaleString('en-US');
49
+
50
+ /** "42% — 578/1,375 chars" */
51
+ export function usageText(usage) {
52
+ return `${usage.percent}% — ${fmt(usage.chars)}/${fmt(usage.limit)} chars`;
53
+ }
54
+
55
+ function countChanges(changed = {}) {
56
+ const parts = [];
57
+ if (changed.added?.length) parts.push(`added ${changed.added.length}`);
58
+ if (changed.replaced?.length) parts.push(`replaced ${changed.replaced.length}`);
59
+ if (changed.removed?.length) parts.push(`removed ${changed.removed.length}`);
60
+ return parts.join(', ');
61
+ }
62
+
63
+ /** The model-facing text for a store result. */
64
+ export function describeResult(result) {
65
+ const label = TARGETS[result.target]?.label ?? result.target;
66
+ if (result.success) {
67
+ const what = countChanges(result.changed);
68
+ if (!what) {
69
+ return `Already in memory (${label}) — nothing changed. ${usageText(result.usage)}. This update is complete — do not repeat it.`;
70
+ }
71
+ return `Saved to memory (${label}): ${what}. Now ${usageText(result.usage)}, ${result.entryCount} entr${result.entryCount === 1 ? 'y' : 'ies'}. `
72
+ + 'This update is complete — do not repeat it.';
73
+ }
74
+ const head = result.blocked ? `Blocked — not saved (${label}): ${result.error}` : `Not saved (${label}): ${result.error}`;
75
+ if (!Array.isArray(result.currentEntries)) return head;
76
+ const list = result.currentEntries.length
77
+ ? result.currentEntries.map((entry, i) => `${i + 1}. ${entry}`).join('\n')
78
+ : '(empty)';
79
+ return `${head}\n\nCurrent ${label} entries (${usageText(result.usage)}):\n${list}`;
80
+ }
81
+
82
+ /**
83
+ * The model-facing text for a read: a fixed first line the chat card reads
84
+ * ("Read memory: N profile entries, M notes"), then the entries themselves.
85
+ */
86
+ export function describeRead(stores, target) {
87
+ const want = target === 'user' || target === 'memory' ? [target] : ['user', 'memory'];
88
+ const user = stores.user ?? [];
89
+ const memory = stores.memory ?? [];
90
+ const n = (k, one, many) => `${k} ${k === 1 ? one : many}`;
91
+ const head = `Read memory: ${n(user.length, 'profile entry', 'profile entries')}, ${n(memory.length, 'note', 'notes')} (current version).`;
92
+ const block = (t) => {
93
+ const entries = t === 'user' ? user : memory;
94
+ const title = t === 'user' ? 'USER PROFILE' : 'NOTES';
95
+ return entries.length ? `${title}:\n${entries.map((e, i) => `${i + 1}. ${e}`).join('\n')}` : `${title}: (empty)`;
96
+ };
97
+ return [head, ...want.map(block), 'Apply these for the rest of this chat without being asked.'].join('\n\n');
98
+ }
99
+
100
+ /**
101
+ * @param {object} p
102
+ * @param {import('./store.js').MemoryStore} p.store
103
+ * @param {Function} p.defineTool from @deepseek-ai/dsh-tools
104
+ */
105
+ export function createMemoryTool({ store, defineTool }) {
106
+ return defineTool({
107
+ name: MEMORY_TOOL,
108
+ description: MEMORY_DESCRIPTION,
109
+ parameters: {
110
+ target: {
111
+ type: 'string', enum: ['memory', 'user'],
112
+ description: "'user' for the user profile, 'memory' for your own notes. Needed for changes; omit for read to get both.",
113
+ },
114
+ action: {
115
+ type: 'string', enum: SINGLE_ACTIONS,
116
+ description: "A single change, or 'read' to load the current memory. Omit when using operations.",
117
+ },
118
+ content: {
119
+ type: 'string',
120
+ description: 'The entry text, for add and replace.',
121
+ },
122
+ old_text: {
123
+ type: 'string',
124
+ description: 'For replace and remove: a short piece of the existing entry, unique to it.',
125
+ },
126
+ operations: {
127
+ type: 'array',
128
+ description: 'Several changes applied all-or-nothing, the budget checked on the final result.',
129
+ items: {
130
+ type: 'object',
131
+ additionalProperties: false,
132
+ properties: {
133
+ action: { type: 'string', enum: ACTIONS, required: true },
134
+ content: { type: 'string', description: 'Entry text for add and replace.' },
135
+ old_text: { type: 'string', description: 'Piece of the entry to replace or remove.' },
136
+ },
137
+ },
138
+ },
139
+ },
140
+ output: TEXT_OUTPUT,
141
+ execute: async (args) => {
142
+ if (args?.action === 'read') return { text: describeRead(await store.load(), args.target) };
143
+ return { text: describeResult(await runMemory(store, args)) };
144
+ },
145
+ });
146
+ }
147
+
148
+ /** Dispatch one call to the store. Kept apart from the tool so it can be tested alone. */
149
+ export async function runMemory(store, args = {}) {
150
+ // `target` is optional in the schema only because a read needs none; a
151
+ // change without one is refused rather than guessed.
152
+ if (args.target === undefined || args.target === null) {
153
+ return store.fail('memory', "give a target — 'user' for the user profile or 'memory' for your notes");
154
+ }
155
+ const target = args.target;
156
+ if (!Object.hasOwn(TARGETS, target)) return store.fail(target, `unknown target '${target}' — use 'memory' or 'user'`);
157
+ // Some providers send JSON null for every omitted optional field.
158
+ const operations = Array.isArray(args.operations) && args.operations.length ? args.operations : null;
159
+ if (operations) return store.applyBatch(target, operations);
160
+ switch (args.action) {
161
+ case 'add': return store.add(target, args.content);
162
+ case 'replace': return store.replace(target, args.old_text, args.content);
163
+ case 'remove': return store.remove(target, args.old_text);
164
+ default:
165
+ return store.fail(target, `give either action (add, replace or remove) or an operations list — got action '${args.action ?? ''}'`);
166
+ }
167
+ }