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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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.",
3
+ "version": "0.2.1",
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, delivered to every chat as a logged context message — at the start, after a change and after compaction — 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
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
package/src/index.js CHANGED
@@ -1,156 +1,217 @@
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 };
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 { createBriefings, createRecallLog, registerRoutes } from './routes.js';
24
+ import { createChatIndex } from './chats.js';
25
+ import { loadDefineTool } from './dsh-tools.js';
26
+ import { injectionReason, memoryMessage, pendingMemory } from './inject.js';
27
+ import { digestOf } from './routes.js';
28
+
29
+ export const name = 'dsh-memory-recall';
30
+
31
+ /**
32
+ * Required services only. The session query, approval and system-prompt
33
+ * services are resolved per use with `get`, so a composition without one
34
+ * loses that feature rather than failing to load the plugin.
35
+ */
36
+ export const inject = ['webServer', 'tools'];
37
+
38
+ /** Look a service up without throwing when it is absent. */
39
+ function service(ctx, id) {
40
+ try {
41
+ return ctx?.get?.(id);
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ /** A user's own chat — not a sub-agent the model started. */
48
+ function isTopLevel(agent) {
49
+ const header = agent?.session?.header;
50
+ return !header || (!header.parentSession && header.origin !== 'subagent');
51
+ }
52
+
53
+ /**
54
+ * @param {object} ctx host plugin context
55
+ * @param {object} [config]
56
+ * @param {object} [deps] test hooks: defineTool, loadDefineTool
57
+ */
58
+ export async function apply(ctx, config = {}, deps = {}) {
59
+ const dir = config.dir || join(homedir(), '.dsh', 'dsh-memory-recall');
60
+ const store = new MemoryStore({ dir, limits: config.limits });
61
+ await store.load();
62
+
63
+ const log = createRecallLog();
64
+ const grants = new Map();
65
+ // Every chat, grouped by workspace: the @ menu's list, and what a
66
+ // `workspace` given to session_search resolves against.
67
+ const chatIndex = createChatIndex({
68
+ getQuery: () => service(ctx, 'sessionQuery'),
69
+ getWorkspaces: () => service(ctx, 'workspaceRegistry'),
70
+ });
71
+ const cwdOf = async (sessionId) => {
72
+ try {
73
+ const rows = await service(ctx, 'sessionQuery')?.filterSessions?.([{ kind: 'id', values: [sessionId] }]);
74
+ return rows?.[0]?.header?.cwd ?? null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ };
79
+ const disposers = [];
80
+ // Whether the model actually got its tools. The prompt and the Memory
81
+ // panel both read this, so neither can promise a tool that is not there.
82
+ const tooling = { ok: false, error: null, tools: [] };
83
+
84
+ // ---- the model's tools ----------------------------------------------
85
+ if (ctx.tools?.register) {
86
+ try {
87
+ const defineTool = deps.defineTool ?? (await (deps.loadDefineTool ?? loadDefineTool)());
88
+ const tools = [createMemoryTool({ store, defineTool })];
89
+ if (config.recall !== false) {
90
+ const recall = createRecall({ getQuery: () => service(ctx, 'sessionQuery'), options: config.recallOptions });
91
+ tools.push(createRecallTool({
92
+ recall,
93
+ defineTool,
94
+ getApproval: () => service(ctx, 'approval'),
95
+ grants,
96
+ log,
97
+ askFirst: config.askBeforeRecall !== false,
98
+ resolveWorkspace: (name) => chatIndex.resolveWorkspace(name),
99
+ listWorkspaces: async () => (await chatIndex.list()).workspaces,
100
+ }));
101
+ }
102
+ for (const tool of tools) {
103
+ const dispose = ctx.tools.register(tool);
104
+ if (typeof dispose === 'function') disposers.push(dispose);
105
+ tooling.tools.push(tool.name);
106
+ }
107
+ tooling.ok = true;
108
+ } catch (error) {
109
+ tooling.error = String(error?.message ?? error);
110
+ ctx.logger?.warn?.(`[dsh-memory-recall] tools not registered: ${tooling.error}`);
111
+ }
112
+ } else {
113
+ tooling.error = 'this dsh has no tool registry';
114
+ }
115
+
116
+ // ---- each new chat: memory guidance in its prompt, frozen for its
117
+ // lifetime. dsh 0.1.7 removed `agent/session-start`; `agent/created` and
118
+ // `agent/disposed` exist on every dsh from 0.1.0-rc.7 through 0.2.x, so
119
+ // they are the seam — session-start stays wired as a back-compat path,
120
+ // deduped by the same map.
121
+ /** Agent contexts already carrying the section -> the registering fiber. */
122
+ const briefed = new WeakMap();
123
+ // sessionId -> what that chat's prompt was given, for the composer chip.
124
+ const briefings = createBriefings();
125
+
126
+ const installGuidance = (agent) => {
127
+ const agentCtx = agent?.ctx;
128
+ if (!agentCtx || briefed.has(agentCtx) || !isTopLevel(agent)) return;
129
+ try {
130
+ // Read now, once: this text is what the chat keeps, so a save made later
131
+ // in it never rewrites its prompt (which would cost the cached prefix).
132
+ // The guidance only: fixed text, so the cached prompt prefix holds.
133
+ const text = renderSection(null, store.limits, { tools: tooling.ok });
134
+ // `inject` rather than `get`: at agent/created the prompt service may not
135
+ // be resolvable yet; the callback runs once it is, and the fiber's
136
+ // dispose() takes the section back out.
137
+ const fiber = agentCtx.inject?.(['systemPrompt'], (scope) => {
138
+ if (!isTopLevel(agent)) return; // the header may have attached since created
139
+ try {
140
+ scope.systemPrompt?.section?.({ name: SECTION_NAME, order: SECTION_ORDER, text });
141
+ } catch {
142
+ // Already registered (e.g. after a plugin reload): a duplicate name throws.
143
+ }
144
+ });
145
+ if (fiber) briefed.set(agentCtx, fiber);
146
+ } catch (error) {
147
+ // Losing the guidance costs this chat its memory, never the chat.
148
+ ctx.logger?.warn?.(`[dsh-memory-recall] no memory for this chat: ${error?.message ?? error}`);
149
+ }
150
+ };
151
+
152
+ const uninstallGuidance = (agent) => {
153
+ const agentCtx = agent?.ctx;
154
+ const fiber = agentCtx && briefed.get(agentCtx);
155
+ if (!fiber) return;
156
+ briefed.delete(agentCtx);
157
+ try {
158
+ fiber.dispose?.();
159
+ } catch {
160
+ // Teardown must not throw.
161
+ }
162
+ };
163
+
164
+ // Agents that already exist when this plugin mounts (a reload).
165
+ ctx.inject?.(['agents'], (scope) => {
166
+ for (const agent of scope.agents?.list?.() ?? []) installGuidance(agent);
167
+ });
168
+ ctx.on?.('agent/created', (payload) => installGuidance(payload?.agent));
169
+ ctx.on?.('agent/disposed', (payload) => uninstallGuidance(payload?.agent));
170
+ // Older hosts fire this instead of agent/created; the map dedupes.
171
+ ctx.on?.('agent/session-start', (payload) => installGuidance(payload?.agent));
172
+
173
+ // ---- the memory itself: a logged context message, whenever the chat
174
+ // cannot see the current version (start, after compaction, after a change).
175
+ ctx.on?.('agent/pre-step', async (payload, next) => {
176
+ const decision = await next();
177
+ const agent = payload?.agent;
178
+ if (!decision || decision.kind === 'reject' || !agent?.session || !isTopLevel(agent)) return decision;
179
+ try {
180
+ const stores = await store.load();
181
+ const reason = injectionReason(agent.session, digestOf(stores));
182
+ const pending = pendingMemory(decision.messages);
183
+ if (!reason) {
184
+ return pending ? { ...decision, messages: decision.messages.filter((m) => m !== pending) } : decision;
185
+ }
186
+ const message = memoryMessage(stores, store.limits, reason);
187
+ if (agent.id) briefings.record(agent.id, stores, reason);
188
+ const messages = decision.messages ?? [];
189
+ return { ...decision, messages: pending ? messages.map((m) => (m === pending ? message : m)) : [...messages, message] };
190
+ } catch (error) {
191
+ // Memory that cannot be read costs this step its memory, never the step.
192
+ ctx.logger?.warn?.(`[dsh-memory-recall] memory not delivered: ${error?.message ?? error}`);
193
+ return decision;
194
+ }
195
+ });
196
+
197
+ // One "allow" lasts until the reply it was given in ends.
198
+ ctx.on?.('agent/status', (payload) => {
199
+ if (payload?.status === 'idle' && payload.agent?.id) grants.delete(payload.agent.id);
200
+ });
201
+
202
+ // ---- the browser half ----------------------------------------------
203
+ if (ctx.webServer?.register) {
204
+ disposers.push(registerRoutes(ctx.webServer, { store, log, chatIndex, cwdOf, tooling, briefings }));
205
+ } else {
206
+ ctx.logger?.warn?.('[dsh-memory-recall] webServer unavailable; the Memory panel and cards will be empty');
207
+ }
208
+
209
+ ctx.on?.('dispose', () => {
210
+ for (const dispose of disposers) {
211
+ try { dispose(); } catch { /* keep disposing the rest */ }
212
+ }
213
+ });
214
+ // cordis treats a returned value as an effect disposer; return nothing.
215
+ }
216
+
217
+ export default { apply, inject, name };
package/src/inject.js ADDED
@@ -0,0 +1,87 @@
1
+ // Memory as a logged context message, the way dsh delivers its skill catalog.
2
+ //
3
+ // Before each model step (`agent/pre-step`), memory is appended to the step's
4
+ // new messages when the chat does not already SEE the current version:
5
+ //
6
+ // start the chat has never been given memory
7
+ // compaction it was given memory, but that message is no longer on the
8
+ // visible surface — the conversation was compacted past it
9
+ // update the memory it can see is older than what is on disk
10
+ //
11
+ // dsh logs each one as a "Context injection · memory · <summary>" row the user
12
+ // can expand, and the model gets the current memory in the conversation itself,
13
+ // next to the work — no tool call, nothing the model has to remember to do.
14
+
15
+ import { randomUUID } from 'node:crypto';
16
+ import { digestOf } from './routes.js';
17
+ import { renderBlock } from './snapshot.js';
18
+
19
+ /** The producer label dsh shows on the row ("Context injection · memory"). */
20
+ export const INJECT_LABEL = 'memory';
21
+
22
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
23
+
24
+ /** "2 profile · 5 notes", the counts part of the row's summary. */
25
+ export function countsText(stores) {
26
+ return `${(stores.user ?? []).length} profile · ${plural((stores.memory ?? []).length, 'note', 'notes')}`;
27
+ }
28
+
29
+ /** Is this logged message one of ours? */
30
+ function isMemorySource(source) {
31
+ return source?.kind === 'plugin' && source?.plugin === INJECT_LABEL;
32
+ }
33
+
34
+ /**
35
+ * Why the chat needs memory now, or null when it already sees the current one.
36
+ * @param {object} session agent.session: seq, eventAt(i), surface.nodes
37
+ * @param {string} digest digestOf(the stores on disk)
38
+ */
39
+ export function injectionReason(session, digest) {
40
+ const visible = new Set(session?.surface?.nodes ?? []);
41
+ let seen = false;
42
+ for (let index = (session?.seq ?? 0) - 1; index >= 0; index -= 1) {
43
+ const event = session.eventAt(index);
44
+ if (event?.type !== 'user/message' || !isMemorySource(event.data?.source)) continue;
45
+ seen = true;
46
+ if (!visible.has(event.seq)) continue; // compacted away; an older one may still be visible
47
+ return event.data.source.digest === digest ? null : 'update';
48
+ }
49
+ return seen ? 'compaction' : 'start';
50
+ }
51
+
52
+ /** Our message among a step's pending messages, if one is already there. */
53
+ export function pendingMemory(messages = []) {
54
+ return messages.find((m) => isMemorySource(m?.source)) ?? null;
55
+ }
56
+
57
+ const LEAD = {
58
+ start: 'Your persistent memory, carried over from earlier chats.',
59
+ compaction: 'Your persistent memory again — the conversation was compacted, so here it is in full.',
60
+ update: 'Your persistent memory changed. This is the current version; it replaces any earlier memory message.',
61
+ };
62
+
63
+ /**
64
+ * The context message: what the model reads, and the source dsh logs and labels.
65
+ * @param {{user: string[], memory: string[]}} stores
66
+ * @param {{user: number, memory: number}} limits
67
+ * @param {'start'|'compaction'|'update'} reason
68
+ */
69
+ export function memoryMessage(stores, limits, reason, id = randomUUID()) {
70
+ const blocks = [renderBlock('user', stores.user ?? [], limits.user), renderBlock('memory', stores.memory ?? [], limits.memory)].filter(Boolean);
71
+ const text = [
72
+ '<system-reminder>',
73
+ LEAD[reason] ?? LEAD.start,
74
+ 'Use it without being asked: follow the preferences, rely on the facts and conventions instead of rediscovering them or asking',
75
+ 'again, and apply the lessons. Do not announce that you read it.',
76
+ '',
77
+ blocks.length ? blocks.join('\n\n') : '(Nothing is saved yet.)',
78
+ '</system-reminder>',
79
+ ].join('\n');
80
+ const summary = (reason === 'compaction' ? 'after compaction · ' : reason === 'update' ? 'updated · ' : '') + countsText(stores);
81
+ return Object.freeze({
82
+ id,
83
+ role: 'user',
84
+ content: Object.freeze([Object.freeze({ type: 'text', text })]),
85
+ source: Object.freeze({ kind: 'plugin', plugin: INJECT_LABEL, form: 'notice', summary, reason, digest: digestOf(stores) }),
86
+ });
87
+ }