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/LICENSE +96 -0
- package/README.md +181 -0
- package/cordis.patch.yml +16 -0
- package/dsh.plugin.json +18 -0
- package/lib/client.js +1090 -0
- package/package.json +60 -0
- package/screenshots.json +6 -0
- package/src/chats.js +160 -0
- package/src/dsh-tools.js +75 -0
- package/src/index.js +156 -0
- package/src/memory-tool.js +136 -0
- package/src/recall-tool.js +203 -0
- package/src/recall.js +282 -0
- package/src/routes.js +153 -0
- package/src/snapshot.js +63 -0
- package/src/store.js +298 -0
- package/src/threats.js +113 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// The `session_search` tool: reading earlier chats, only with the user's say-so.
|
|
2
|
+
//
|
|
3
|
+
// Before anything is read, dsh's own approval prompt asks the user — attached
|
|
4
|
+
// to this tool call in the chat, with what the model wants to look for. One
|
|
5
|
+
// "allow" covers the rest of that reply (a search is usually followed by a
|
|
6
|
+
// read around the best hit, and asking twice for one question is noise); the
|
|
7
|
+
// grant is dropped the moment the chat goes idle, so the next question asks
|
|
8
|
+
// again. A decline, a withdrawn prompt, or no way to ask at all means nothing
|
|
9
|
+
// is read — the approval seam fails closed and so does this.
|
|
10
|
+
|
|
11
|
+
import { RecallError } from './recall.js';
|
|
12
|
+
import { TEXT_OUTPUT } from './memory-tool.js';
|
|
13
|
+
|
|
14
|
+
export const RECALL_TOOL = 'session_search';
|
|
15
|
+
|
|
16
|
+
export const RECALL_DESCRIPTION = [
|
|
17
|
+
'Look things up in the user\'s earlier chats. Asks the user for permission first; if they decline, say so and carry on',
|
|
18
|
+
'without it — do not call it again in the same reply. Results are the chats\' actual messages, not summaries.',
|
|
19
|
+
'',
|
|
20
|
+
'Shapes, picked by the arguments:',
|
|
21
|
+
'- `query`: find the chats that match best (top result comes with the messages around the match).',
|
|
22
|
+
'- `session_id` + `around_seq`: read the messages around one message of a chat found earlier.',
|
|
23
|
+
'- `session_id` alone: read how a chat began and ended.',
|
|
24
|
+
'- no arguments: list the most recent chats.',
|
|
25
|
+
'Add `workspace` (its title, its folder, or the workspace a user picked with @) to search or list only that',
|
|
26
|
+
"workspace's chats — a user message carrying [workspace \"Name\" — folder] means exactly that.",
|
|
27
|
+
'',
|
|
28
|
+
'The query is matched as an EXACT PHRASE, so use one or two distinctive keywords (a name, a file, an error) rather than',
|
|
29
|
+
'a sentence; if a multi-word phrase finds too little, each word is also tried on its own.',
|
|
30
|
+
'Use it when the user refers to an earlier conversation — "what did we decide about…", "like last time", "where did we',
|
|
31
|
+
'leave…". Searches chat history only: when the user names a file, URL or system, look at that directly.',
|
|
32
|
+
].join('\n');
|
|
33
|
+
|
|
34
|
+
const OUTCOME_TEXT = {
|
|
35
|
+
rejected: 'The user declined reading their past chats. Nothing was read. Tell them you cannot look it up without their permission, and do not ask again in this reply.',
|
|
36
|
+
cancelled: 'The permission request was withdrawn before an answer (the reply was stopped). Nothing was read.',
|
|
37
|
+
unavailable: 'Nobody could be asked for permission (no approval prompt is available in this dsh), so past chats were not read.',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const day = (ms) => (Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : 'unknown date');
|
|
41
|
+
|
|
42
|
+
function titleOf(s) {
|
|
43
|
+
return s.title ? `"${s.title}"` : '(untitled chat)';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sessionLine(s) {
|
|
47
|
+
return `${titleOf(s)} — ${day(s.createdAt)}${s.cwd ? ` — ${s.cwd}` : ''} — session_id ${s.sessionId}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function messageLines(list, mark) {
|
|
51
|
+
return list.map((m) => ` [#${m.seq} ${m.role}]${m.seq === mark ? ' ◀' : ''} ${m.text.replace(/\n+/g, ' ')}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The model-facing text for a recall result, capped at `max` characters. */
|
|
55
|
+
export function formatRecall(result, max = 9000) {
|
|
56
|
+
const out = [];
|
|
57
|
+
if (result.mode === 'discover') {
|
|
58
|
+
const engine = result.engine === 'fts' ? "dsh's full-text index" : 'a literal scan of recent chats (full-text search is off)';
|
|
59
|
+
if (!result.results.length) {
|
|
60
|
+
out.push(`No earlier chat${result.workspace ? ` in workspace "${result.workspace}"` : ''} matches "${result.query}" (searched with ${engine}). Try one distinctive keyword, or another word for it.`);
|
|
61
|
+
} else {
|
|
62
|
+
out.push(`Found ${result.results.length} earlier chat(s) for "${result.query}"${result.workspace ? ` in workspace "${result.workspace}"` : ''} (searched with ${engine}${result.widened ? '; also word by word' : ''}):`);
|
|
63
|
+
result.results.forEach((r, i) => {
|
|
64
|
+
out.push('', `${i + 1}. ${sessionLine(r)}`);
|
|
65
|
+
if (r.matchedWords) out.push(` Matched words: ${r.matchedWords.join(', ')}`);
|
|
66
|
+
out.push(` Best match (#${r.match.seq}, ${r.match.role}): ${r.match.snippet}`);
|
|
67
|
+
if (r.context?.length) out.push(' Around it:', ...messageLines(r.context, r.match.seq));
|
|
68
|
+
});
|
|
69
|
+
out.push('', 'To read more of one: session_search with its session_id and around_seq set to a message number.');
|
|
70
|
+
}
|
|
71
|
+
} else if (result.mode === 'scroll') {
|
|
72
|
+
out.push(`From ${sessionLine(result.session)}, around message #${result.aroundSeq}:`, ...messageLines(result.messages, result.aroundSeq));
|
|
73
|
+
} else if (result.mode === 'read') {
|
|
74
|
+
out.push(`${sessionLine(result.session)} — ${result.total} messages.`);
|
|
75
|
+
if (result.head.length) out.push('How it began:', ...messageLines(result.head));
|
|
76
|
+
if (result.omitted) out.push(` … ${result.omitted} message(s) in between — use around_seq to read any part …`);
|
|
77
|
+
if (result.tail.length) out.push(result.omitted ? 'How it ended:' : 'Then:', ...messageLines(result.tail));
|
|
78
|
+
} else if (result.mode === 'browse') {
|
|
79
|
+
const where = result.workspace ? ` in workspace "${result.workspace}"` : '';
|
|
80
|
+
out.push(result.sessions.length ? `Most recent earlier chats${where}:` : `There are no earlier chats${where} yet.`);
|
|
81
|
+
result.sessions.forEach((s, i) => out.push(`${i + 1}. ${sessionLine(s)}`));
|
|
82
|
+
}
|
|
83
|
+
const text = out.join('\n');
|
|
84
|
+
return text.length > max ? `${text.slice(0, max - 60).trimEnd()}\n… (cut to fit — narrow the search to see more)` : text;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Which of the four shapes the arguments ask for. */
|
|
88
|
+
export function modeOf(args = {}) {
|
|
89
|
+
const has = (v) => typeof v === 'string' && v.trim() !== '';
|
|
90
|
+
if (has(args.session_id) && Number.isFinite(args.around_seq)) return 'scroll';
|
|
91
|
+
if (has(args.session_id)) return 'read';
|
|
92
|
+
if (has(args.query)) return 'discover';
|
|
93
|
+
return 'browse';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What the permission prompt says the model wants. */
|
|
97
|
+
export function reasonFor(args = {}, workspace = null) {
|
|
98
|
+
const where = workspace ? ` in "${workspace}"` : '';
|
|
99
|
+
switch (modeOf(args)) {
|
|
100
|
+
case 'discover': return `Search your earlier chats${where} for "${String(args.query).trim()}".`;
|
|
101
|
+
case 'scroll': return `Read part of an earlier chat (${args.session_id}).`;
|
|
102
|
+
case 'read': return `Read the beginning and end of an earlier chat (${args.session_id}).`;
|
|
103
|
+
default: return workspace ? `List your chats in "${workspace}".` : 'List your most recent chats.';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {object} p
|
|
109
|
+
* @param {ReturnType<import('./recall.js').createRecall>} p.recall
|
|
110
|
+
* @param {Function} p.defineTool
|
|
111
|
+
* @param {() => object|undefined} p.getApproval resolves the dsh approval service lazily
|
|
112
|
+
* @param {Map<string, number>} p.grants chat id → time the grant lapses
|
|
113
|
+
* @param {object} p.log per-call record for the chat card: {start, update}
|
|
114
|
+
* @param {boolean} [p.askFirst=true]
|
|
115
|
+
* @param {() => number} [p.now]
|
|
116
|
+
* @param {number} [p.grantMs] a safety lapse, in case the idle event is missed
|
|
117
|
+
*/
|
|
118
|
+
export function createRecallTool({
|
|
119
|
+
recall, defineTool, getApproval, grants, log, askFirst = true, now = () => Date.now(), grantMs = 30 * 60_000,
|
|
120
|
+
resolveWorkspace = async () => null, listWorkspaces = async () => [],
|
|
121
|
+
}) {
|
|
122
|
+
async function permission(exec, args, workspace) {
|
|
123
|
+
if (!askFirst) return 'allowed-once';
|
|
124
|
+
const chat = exec?.agent?.id;
|
|
125
|
+
if (chat && (grants.get(chat) ?? 0) > now()) return 'allowed-once';
|
|
126
|
+
const approval = getApproval();
|
|
127
|
+
if (!approval || typeof approval.request !== 'function' || !exec?.agent) return 'unavailable';
|
|
128
|
+
const outcome = await approval.request({
|
|
129
|
+
agent: exec.agent,
|
|
130
|
+
toolName: RECALL_TOOL,
|
|
131
|
+
callId: exec.callId,
|
|
132
|
+
reason: reasonFor(args, workspace),
|
|
133
|
+
signal: exec.signal,
|
|
134
|
+
});
|
|
135
|
+
if (outcome === 'allowed-once' && chat) grants.set(chat, now() + grantMs);
|
|
136
|
+
return outcome;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function run(args, exec) {
|
|
140
|
+
const mode = modeOf(args);
|
|
141
|
+
const callId = exec?.callId;
|
|
142
|
+
const wanted = typeof args.workspace === 'string' ? args.workspace.trim() : '';
|
|
143
|
+
log.start(callId, { mode, query: args.query ?? null, sessionId: args.session_id ?? null, workspace: wanted || null });
|
|
144
|
+
// Which chats a workspace holds is metadata, not chat content, so it is
|
|
145
|
+
// resolved before asking — and the prompt can then say where it will look.
|
|
146
|
+
let workspace = null;
|
|
147
|
+
if (wanted && (mode === 'discover' || mode === 'browse')) {
|
|
148
|
+
try { workspace = await resolveWorkspace(wanted); } catch { workspace = null; }
|
|
149
|
+
if (!workspace) {
|
|
150
|
+
let names = [];
|
|
151
|
+
try { names = (await listWorkspaces()).map((w) => w.title); } catch { /* none to offer */ }
|
|
152
|
+
const text = `No workspace called "${wanted}".${names.length ? ` Workspaces: ${names.map((n) => `"${n}"`).join(', ')}.` : ''} Nothing was read.`;
|
|
153
|
+
log.update(callId, { status: 'error', error: text });
|
|
154
|
+
return text;
|
|
155
|
+
}
|
|
156
|
+
log.update(callId, { workspace: workspace.title });
|
|
157
|
+
}
|
|
158
|
+
log.update(callId, { status: 'awaiting-approval' });
|
|
159
|
+
let outcome;
|
|
160
|
+
try {
|
|
161
|
+
outcome = await permission(exec, args, workspace?.title ?? null);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
outcome = 'unavailable';
|
|
164
|
+
log.update(callId, { note: String(error?.message ?? error) });
|
|
165
|
+
}
|
|
166
|
+
if (outcome !== 'allowed-once') {
|
|
167
|
+
log.update(callId, { status: 'declined', outcome });
|
|
168
|
+
return OUTCOME_TEXT[outcome] ?? OUTCOME_TEXT.unavailable;
|
|
169
|
+
}
|
|
170
|
+
log.update(callId, { status: 'searching' });
|
|
171
|
+
const excludeId = exec?.agent?.id;
|
|
172
|
+
try {
|
|
173
|
+
let result;
|
|
174
|
+
const scope = workspace ? new Set(workspace.chats.map((c) => c.sessionId)) : undefined;
|
|
175
|
+
if (mode === 'discover') result = await recall.discover({ query: args.query, limit: args.limit, sort: args.sort, excludeId, scope });
|
|
176
|
+
else if (mode === 'scroll') result = await recall.scroll({ sessionId: args.session_id.trim(), aroundSeq: args.around_seq });
|
|
177
|
+
else if (mode === 'read') result = await recall.read({ sessionId: args.session_id.trim() });
|
|
178
|
+
else result = await recall.browse({ limit: args.limit, excludeId, scope });
|
|
179
|
+
if (workspace) result.workspace = workspace.title;
|
|
180
|
+
log.update(callId, { status: 'done', result });
|
|
181
|
+
return formatRecall(result, recall.options.totalChars);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const message = error instanceof RecallError ? error.message : `could not read past chats: ${error?.message ?? error}`;
|
|
184
|
+
log.update(callId, { status: 'error', error: message });
|
|
185
|
+
return `Recall failed: ${message}`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return defineTool({
|
|
190
|
+
name: RECALL_TOOL,
|
|
191
|
+
description: RECALL_DESCRIPTION,
|
|
192
|
+
parameters: {
|
|
193
|
+
query: { type: 'string', description: 'One or two distinctive keywords, matched as an exact phrase. Omit to list recent chats.' },
|
|
194
|
+
limit: { type: 'integer', description: 'How many chats to return (default 3, at most 10; for a listing, default 10).' },
|
|
195
|
+
sort: { type: 'string', enum: ['newest', 'oldest'], description: "'newest' for where something was left, 'oldest' for how it started; omit for best match." },
|
|
196
|
+
workspace: { type: 'string', description: "Only this workspace's chats: its title as the sidebar shows it, or its folder." },
|
|
197
|
+
session_id: { type: 'string', description: 'A chat from an earlier result, to read inside it.' },
|
|
198
|
+
around_seq: { type: 'integer', description: 'With session_id: the message number to read around (the #N shown in results).' },
|
|
199
|
+
},
|
|
200
|
+
output: TEXT_OUTPUT,
|
|
201
|
+
execute: async (args, exec) => ({ text: await run(args ?? {}, exec) }),
|
|
202
|
+
});
|
|
203
|
+
}
|
package/src/recall.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// Looking things up in earlier chats, through dsh's own session query service.
|
|
2
|
+
//
|
|
3
|
+
// Four shapes, as in Hermes Agent's session_search:
|
|
4
|
+
// discover a query → the chats that match best, the top one with context
|
|
5
|
+
// scroll a chat + a message → the messages around it
|
|
6
|
+
// read a chat alone → its opening and its end
|
|
7
|
+
// browse nothing → the most recent chats
|
|
8
|
+
//
|
|
9
|
+
// Everything returned is the chats' actual messages — no model call, no
|
|
10
|
+
// summary that could be wrong. Only the user's own top-level chats are
|
|
11
|
+
// searched: sub-agent sessions are the model talking to itself, and the chat
|
|
12
|
+
// asking is left out (it already has its own history). Only user and assistant
|
|
13
|
+
// messages count; tool calls and results are noise for "what did we decide".
|
|
14
|
+
//
|
|
15
|
+
// dsh's full-text index (SQLite FTS5) matches a query as a LITERAL PHRASE, so
|
|
16
|
+
// "graft sidebar" finds only chats where those words are adjacent. When a
|
|
17
|
+
// multi-word search comes back short, each distinctive word is searched on its
|
|
18
|
+
// own and chats are ranked by how many of the words they contain. If the index
|
|
19
|
+
// is switched off, a slower literal scan of recent chats stands in for it.
|
|
20
|
+
|
|
21
|
+
export const MESSAGE_TYPES = ['user/message', 'assistant/message'];
|
|
22
|
+
|
|
23
|
+
export const DEFAULTS = {
|
|
24
|
+
limit: 3,
|
|
25
|
+
maxLimit: 10,
|
|
26
|
+
browseLimit: 10,
|
|
27
|
+
/** Messages shown either side of a match. */
|
|
28
|
+
around: 3,
|
|
29
|
+
/** A single message is cut to this many characters. */
|
|
30
|
+
messageChars: 700,
|
|
31
|
+
/** The whole answer is cut to this many characters. */
|
|
32
|
+
totalChars: 9000,
|
|
33
|
+
/** How many recent chats the fallback scan reads, newest first. */
|
|
34
|
+
scanSessions: 150,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const STOPWORDS = new Set(('a an and are as at be but by did do does for from had has have how i if in into is it its '
|
|
38
|
+
+ 'last me my of on or our that the their them then there these this those to was we were what when where which '
|
|
39
|
+
+ 'who why will with you your about after again all also any can could just like more most not now only other '
|
|
40
|
+
+ 'over same should some such than too very would chat chats talked discussed decided said '
|
|
41
|
+
// The words of the question itself ("what did we decide last time…"), which
|
|
42
|
+
// match half of any history and say nothing about the topic.
|
|
43
|
+
+ 'decide decision time times remember recall conversation conversations earlier previous before ago '
|
|
44
|
+
+ 'thing things something stuff back leave left mentioned told asked').split(' '));
|
|
45
|
+
|
|
46
|
+
/** Distinctive words of a query, for the word-by-word pass. */
|
|
47
|
+
export function keywords(query) {
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const word of String(query).toLowerCase().split(/[^\p{L}\p{N}_.\-]+/u)) {
|
|
51
|
+
const w = word.replace(/^[.\-]+|[.\-]+$/g, '');
|
|
52
|
+
if (w.length < 3 || STOPWORDS.has(w) || seen.has(w)) continue;
|
|
53
|
+
seen.add(w);
|
|
54
|
+
out.push(w);
|
|
55
|
+
}
|
|
56
|
+
return out.slice(0, 5);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** dsh marks FTS matches with two private-use characters; show them as **bold**. */
|
|
60
|
+
export function cleanSnippet(snippet) {
|
|
61
|
+
return String(snippet ?? '').replace(/\uFDD0/g, '**').replace(/\uFDD1/g, '**').replace(/\s+/g, ' ').trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const roleOf = (type) => (type === 'user/message' ? 'user' : 'assistant');
|
|
65
|
+
const isRoot = (header) => header && !header.parentSession && header.origin !== 'subagent';
|
|
66
|
+
|
|
67
|
+
function cut(text, max) {
|
|
68
|
+
const t = String(text ?? '').trim();
|
|
69
|
+
return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A snippet around the first literal occurrence of `needle` in `text`. */
|
|
73
|
+
export function excerpt(text, needle, width = 120) {
|
|
74
|
+
const t = String(text ?? '').replace(/\s+/g, ' ');
|
|
75
|
+
const at = needle ? t.toLowerCase().indexOf(String(needle).toLowerCase()) : -1;
|
|
76
|
+
if (at < 0) return cut(t, width * 2);
|
|
77
|
+
const start = Math.max(0, at - width);
|
|
78
|
+
const end = Math.min(t.length, at + needle.length + width);
|
|
79
|
+
return `${start > 0 ? '…' : ''}${t.slice(start, at)}**${t.slice(at, at + needle.length)}**${t.slice(at + needle.length, end)}${end < t.length ? '…' : ''}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class RecallError extends Error {}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {object} p
|
|
86
|
+
* @param {() => object|undefined} p.getQuery resolves ctx.sessionQuery lazily
|
|
87
|
+
* @param {object} [p.options]
|
|
88
|
+
*/
|
|
89
|
+
export function createRecall({ getQuery, options = {} }) {
|
|
90
|
+
const o = { ...DEFAULTS, ...options };
|
|
91
|
+
|
|
92
|
+
const query = () => {
|
|
93
|
+
const q = getQuery();
|
|
94
|
+
if (!q || typeof q.listSessions !== 'function') {
|
|
95
|
+
throw new RecallError("dsh's session query service is not available in this deployment, so past chats cannot be read.");
|
|
96
|
+
}
|
|
97
|
+
return q;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
async function titlesFor(ids) {
|
|
101
|
+
const out = new Map();
|
|
102
|
+
if (!ids.length) return out;
|
|
103
|
+
try {
|
|
104
|
+
const rows = await query().readTitleSnapshots(ids);
|
|
105
|
+
for (const row of rows ?? []) {
|
|
106
|
+
if (row?.status === 'fulfilled' && row.value?.title?.title) out.set(row.sessionId, row.value.title.title);
|
|
107
|
+
}
|
|
108
|
+
} catch { /* untitled is fine */ }
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function messages(sessionId, filters = []) {
|
|
113
|
+
const docs = await query().filterEvents(sessionId, [{ kind: 'type', values: MESSAGE_TYPES }, ...filters]);
|
|
114
|
+
return [...(docs ?? [])]
|
|
115
|
+
.filter((d) => String(d.text ?? '').trim() !== '')
|
|
116
|
+
.sort((a, b) => a.seq - b.seq)
|
|
117
|
+
.map((d) => ({ seq: d.seq, role: roleOf(d.type), time: d.time, text: cut(d.text, o.messageChars) }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Up to `around` messages either side of `seq`. */
|
|
121
|
+
async function windowAround(sessionId, seq) {
|
|
122
|
+
const near = await messages(sessionId, [{ kind: 'seq', from: Math.max(0, seq - 60), to: seq + 60 }]);
|
|
123
|
+
let at = near.findIndex((m) => m.seq >= seq);
|
|
124
|
+
if (at < 0) at = near.length - 1;
|
|
125
|
+
return near.slice(Math.max(0, at - o.around), at + o.around + 1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sessionInfo(header, titles) {
|
|
129
|
+
return { sessionId: header.id, title: titles.get(header.id) ?? null, createdAt: header.createdAt, cwd: header.cwd ?? null };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** FTS for one phrase → Map(sessionId → hit). Throws `disabled` when the index is off. */
|
|
133
|
+
/** Session filters: top-level chats, and only the scope's when there is one. */
|
|
134
|
+
const scopeFilters = (scope) => [
|
|
135
|
+
{ kind: 'parent', values: [null] },
|
|
136
|
+
...(scope ? [{ kind: 'id', values: [...scope] }] : []),
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
async function ftsHits(phrase, excludeId, want, scope) {
|
|
140
|
+
const q = query();
|
|
141
|
+
if (typeof q.searchSessions !== 'function') throw Object.assign(new Error('no full-text search'), { disabled: true });
|
|
142
|
+
let page;
|
|
143
|
+
try {
|
|
144
|
+
page = await q.searchSessions({
|
|
145
|
+
query: phrase,
|
|
146
|
+
sessionFilters: scopeFilters(scope),
|
|
147
|
+
eventFilters: [{ kind: 'type', values: MESSAGE_TYPES }],
|
|
148
|
+
limit: Math.min(100, want + 5),
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error?.code === 'SESSION_QUERY_SEARCH_DISABLED') throw Object.assign(new Error('search disabled'), { disabled: true });
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
const hits = new Map();
|
|
155
|
+
for (const item of page?.items ?? []) {
|
|
156
|
+
if (!isRoot(item.header) || item.header.id === excludeId) continue;
|
|
157
|
+
hits.set(item.header.id, {
|
|
158
|
+
header: item.header,
|
|
159
|
+
match: { seq: item.bestMatch.seq, role: roleOf(item.bestMatch.type), time: item.bestMatch.time, snippet: cleanSnippet(item.bestMatch.snippet) },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return hits;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The literal scan used when the index is off: newest chats first, one match each. */
|
|
166
|
+
async function scanHits(phrase, excludeId, want, scope) {
|
|
167
|
+
const q = query();
|
|
168
|
+
const sessions = (await q.listSessions())
|
|
169
|
+
.map((r) => r.header)
|
|
170
|
+
.filter((h) => isRoot(h) && h.id !== excludeId && (!scope || scope.has(h.id)))
|
|
171
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
172
|
+
.slice(0, o.scanSessions);
|
|
173
|
+
const hits = new Map();
|
|
174
|
+
for (const header of sessions) {
|
|
175
|
+
if (hits.size >= want) break;
|
|
176
|
+
let docs;
|
|
177
|
+
try {
|
|
178
|
+
docs = await q.filterEvents(header.id, [{ kind: 'type', values: MESSAGE_TYPES }, { kind: 'text', text: phrase }]);
|
|
179
|
+
} catch { continue; } // one unreadable chat must not stop the scan
|
|
180
|
+
const first = [...(docs ?? [])].sort((a, b) => a.seq - b.seq)[0];
|
|
181
|
+
if (!first) continue;
|
|
182
|
+
hits.set(header.id, { header, match: { seq: first.seq, role: roleOf(first.type), time: first.time, snippet: excerpt(first.text, phrase) } });
|
|
183
|
+
}
|
|
184
|
+
return hits;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @param {object} p
|
|
189
|
+
* @param {Set<string>} [p.scope] only these chats (a workspace's), when given
|
|
190
|
+
*/
|
|
191
|
+
async function discover({ query: text, limit, sort, excludeId, scope }) {
|
|
192
|
+
const phrase = String(text ?? '').trim();
|
|
193
|
+
if (!phrase) throw new RecallError('query is empty');
|
|
194
|
+
const want = Math.max(1, Math.min(o.maxLimit, Number.isFinite(limit) ? Math.floor(limit) : o.limit));
|
|
195
|
+
let engine = 'fts';
|
|
196
|
+
if (scope && scope.size === 0) return { mode: 'discover', engine: 'fts', query: phrase, widened: false, results: [] };
|
|
197
|
+
let search = (p, n) => ftsHits(p, excludeId, n, scope);
|
|
198
|
+
let hits;
|
|
199
|
+
try {
|
|
200
|
+
hits = await search(phrase, want * 3);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (!error?.disabled) throw error;
|
|
203
|
+
engine = 'scan';
|
|
204
|
+
search = (p, n) => scanHits(p, excludeId, n, scope);
|
|
205
|
+
hits = await search(phrase, want * 3);
|
|
206
|
+
}
|
|
207
|
+
// Phrase first; then, if that is thin, each distinctive word on its own.
|
|
208
|
+
const scored = new Map([...hits].map(([id, hit]) => [id, { ...hit, score: 100, words: [phrase] }]));
|
|
209
|
+
const words = keywords(phrase);
|
|
210
|
+
let widened = false;
|
|
211
|
+
if (scored.size < want && words.length > 1) {
|
|
212
|
+
widened = true;
|
|
213
|
+
for (const word of words) {
|
|
214
|
+
for (const [id, hit] of await search(word, want * 3)) {
|
|
215
|
+
const prior = scored.get(id);
|
|
216
|
+
if (prior) {
|
|
217
|
+
if (prior.score < 100) { prior.score += 1; prior.words.push(word); }
|
|
218
|
+
} else {
|
|
219
|
+
scored.set(id, { ...hit, score: 1, words: [word] });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
let ranked = [...scored.values()].sort((a, b) => b.score - a.score || b.header.createdAt - a.header.createdAt);
|
|
225
|
+
ranked = ranked.slice(0, want);
|
|
226
|
+
if (sort === 'newest') ranked.sort((a, b) => b.header.createdAt - a.header.createdAt);
|
|
227
|
+
if (sort === 'oldest') ranked.sort((a, b) => a.header.createdAt - b.header.createdAt);
|
|
228
|
+
const titles = await titlesFor(ranked.map((r) => r.header.id));
|
|
229
|
+
const results = ranked.map((r) => ({
|
|
230
|
+
...sessionInfo(r.header, titles),
|
|
231
|
+
match: r.match,
|
|
232
|
+
matchedWords: r.score >= 100 ? null : r.words,
|
|
233
|
+
}));
|
|
234
|
+
if (results[0]) {
|
|
235
|
+
try { results[0].context = await windowAround(results[0].sessionId, results[0].match.seq); } catch { /* the snippet still stands */ }
|
|
236
|
+
}
|
|
237
|
+
return { mode: 'discover', engine, query: phrase, widened, results };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function headerOf(sessionId) {
|
|
241
|
+
const q = query();
|
|
242
|
+
const rows = await q.filterSessions([{ kind: 'id', values: [sessionId] }]);
|
|
243
|
+
const header = rows?.[0]?.header;
|
|
244
|
+
if (!header) throw new RecallError(`no chat with id ${sessionId}`);
|
|
245
|
+
return header;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function scroll({ sessionId, aroundSeq }) {
|
|
249
|
+
const header = await headerOf(sessionId);
|
|
250
|
+
const titles = await titlesFor([sessionId]);
|
|
251
|
+
return { mode: 'scroll', session: sessionInfo(header, titles), aroundSeq, messages: await windowAround(sessionId, aroundSeq) };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function read({ sessionId }) {
|
|
255
|
+
const header = await headerOf(sessionId);
|
|
256
|
+
const titles = await titlesFor([sessionId]);
|
|
257
|
+
const all = await messages(sessionId);
|
|
258
|
+
const head = all.slice(0, 4);
|
|
259
|
+
const tail = all.length > 10 ? all.slice(-6) : all.slice(4);
|
|
260
|
+
return {
|
|
261
|
+
mode: 'read',
|
|
262
|
+
session: sessionInfo(header, titles),
|
|
263
|
+
total: all.length,
|
|
264
|
+
head,
|
|
265
|
+
tail,
|
|
266
|
+
omitted: Math.max(0, all.length - head.length - tail.length),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function browse({ limit, excludeId, scope }) {
|
|
271
|
+
const want = Math.max(1, Math.min(30, Number.isFinite(limit) ? Math.floor(limit) : o.browseLimit));
|
|
272
|
+
const headers = (await query().listSessions())
|
|
273
|
+
.map((r) => r.header)
|
|
274
|
+
.filter((h) => isRoot(h) && h.id !== excludeId && (!scope || scope.has(h.id)))
|
|
275
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
276
|
+
.slice(0, want);
|
|
277
|
+
const titles = await titlesFor(headers.map((h) => h.id));
|
|
278
|
+
return { mode: 'browse', sessions: headers.map((h) => sessionInfo(h, titles)) };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { discover, scroll, read, browse, options: o };
|
|
282
|
+
}
|
package/src/routes.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Host routes for the browser half, and the per-call record the recall card reads.
|
|
2
|
+
//
|
|
3
|
+
// GET /plugin-data/dsh-memory-recall/state → both stores: entries, usage, limits
|
|
4
|
+
// POST /plugin-data/dsh-memory-recall/entry → an edit made in the Memory panel
|
|
5
|
+
// GET /plugin-data/dsh-memory-recall/recall?callId=… → what one session_search call did
|
|
6
|
+
// GET /plugin-data/dsh-memory-recall/chats?session=… → every chat, grouped by workspace, for the @ menu
|
|
7
|
+
//
|
|
8
|
+
// Edits from the panel are the user's own words, so they skip the injection
|
|
9
|
+
// scan the model's writes go through — but not the budget: an over-full store
|
|
10
|
+
// would be cut from nothing, silently, in the next prompt.
|
|
11
|
+
|
|
12
|
+
export const BASE = '/plugin-data/dsh-memory-recall';
|
|
13
|
+
export const STATE_PATH = `${BASE}/state`;
|
|
14
|
+
export const ENTRY_PATH = `${BASE}/entry`;
|
|
15
|
+
export const RECALL_PATH = `${BASE}/recall`;
|
|
16
|
+
export const CHATS_PATH = `${BASE}/chats`;
|
|
17
|
+
|
|
18
|
+
/** What the recall card needs from a result — titles and snippets, not whole chats. */
|
|
19
|
+
export function summarize(result) {
|
|
20
|
+
if (!result) return null;
|
|
21
|
+
const brief = (s) => ({ sessionId: s.sessionId, title: s.title, createdAt: s.createdAt, cwd: s.cwd });
|
|
22
|
+
switch (result.mode) {
|
|
23
|
+
case 'discover':
|
|
24
|
+
return {
|
|
25
|
+
mode: 'discover', engine: result.engine, query: result.query, widened: result.widened,
|
|
26
|
+
results: result.results.map((r) => ({ ...brief(r), snippet: r.match.snippet, matchedWords: r.matchedWords })),
|
|
27
|
+
};
|
|
28
|
+
case 'scroll': return { mode: 'scroll', session: brief(result.session), count: result.messages.length };
|
|
29
|
+
case 'read': return { mode: 'read', session: brief(result.session), count: result.total };
|
|
30
|
+
case 'browse': return { mode: 'browse', sessions: result.sessions.map(brief) };
|
|
31
|
+
default: return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A bounded map of recent session_search calls, by call id. */
|
|
36
|
+
export function createRecallLog(cap = 100, now = () => Date.now()) {
|
|
37
|
+
const calls = new Map();
|
|
38
|
+
return {
|
|
39
|
+
start(callId, info) {
|
|
40
|
+
if (!callId) return;
|
|
41
|
+
calls.set(callId, { ...info, status: 'starting', startedAt: now() });
|
|
42
|
+
while (calls.size > cap) calls.delete(calls.keys().next().value);
|
|
43
|
+
},
|
|
44
|
+
update(callId, patch) {
|
|
45
|
+
const entry = callId ? calls.get(callId) : undefined;
|
|
46
|
+
if (!entry) return;
|
|
47
|
+
const { result, ...rest } = patch;
|
|
48
|
+
Object.assign(entry, rest, result ? { summary: summarize(result) } : {}, { updatedAt: now() });
|
|
49
|
+
},
|
|
50
|
+
get: (callId) => calls.get(callId) ?? null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sendJson(res, status, payload) {
|
|
55
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
56
|
+
res.end(JSON.stringify(payload));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readBody(req) {
|
|
60
|
+
let text = '';
|
|
61
|
+
for await (const chunk of req ?? []) text += chunk;
|
|
62
|
+
if (!text) return {};
|
|
63
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Both stores as the panel shows them. Re-read from disk, so a hand edit shows at once. */
|
|
67
|
+
export async function stateOf(store, tooling = null) {
|
|
68
|
+
await store.load();
|
|
69
|
+
const one = (target) => ({ entries: store.entries[target], usage: store.usage(target) });
|
|
70
|
+
return { dir: store.dir, user: one('user'), memory: one('memory'), ...(tooling ? { tools: { ...tooling } } : {}) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The @ menu's view of the chat index: the asking chat left out (mentioning
|
|
75
|
+
* the chat you are in is pointless), and its workspace named so the menu can
|
|
76
|
+
* list that one first.
|
|
77
|
+
*/
|
|
78
|
+
export async function chatsFor(chatIndex, sessionId, cwdOf = async () => null) {
|
|
79
|
+
const index = await chatIndex.list();
|
|
80
|
+
const without = (chats) => chats.filter((c) => c.sessionId !== sessionId);
|
|
81
|
+
let current = sessionId ? chatIndex.workspaceOf(index, sessionId, null) : null;
|
|
82
|
+
if (!current && sessionId) current = chatIndex.workspaceOf(index, sessionId, await cwdOf(sessionId));
|
|
83
|
+
return {
|
|
84
|
+
currentWorkspaceId: current?.id ?? null,
|
|
85
|
+
workspaces: index.workspaces
|
|
86
|
+
.map((w) => ({ id: w.id, title: w.title, path: w.path, chats: without(w.chats) }))
|
|
87
|
+
.filter((w) => w.chats.length),
|
|
88
|
+
ungrouped: without(index.ungrouped),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {object} webServer ctx.webServer
|
|
94
|
+
* @param {object} p {store, log, chatIndex?, cwdOf?}
|
|
95
|
+
* @returns {() => void} disposer
|
|
96
|
+
*/
|
|
97
|
+
export function registerRoutes(webServer, { store, log, chatIndex, cwdOf, tooling }) {
|
|
98
|
+
const off = [];
|
|
99
|
+
off.push(webServer.register({
|
|
100
|
+
kind: 'exact',
|
|
101
|
+
path: STATE_PATH,
|
|
102
|
+
handler: async (req = {}, res) => {
|
|
103
|
+
if ((req.method || 'GET').toUpperCase() !== 'GET') return sendJson(res, 405, { error: 'method not allowed' });
|
|
104
|
+
try {
|
|
105
|
+
sendJson(res, 200, await stateOf(store, tooling));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
sendJson(res, 500, { error: `could not read memory: ${error?.message ?? error}` });
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
}));
|
|
111
|
+
off.push(webServer.register({
|
|
112
|
+
kind: 'exact',
|
|
113
|
+
path: ENTRY_PATH,
|
|
114
|
+
handler: async (req = {}, res) => {
|
|
115
|
+
if ((req.method || 'GET').toUpperCase() !== 'POST') return sendJson(res, 405, { error: 'method not allowed' });
|
|
116
|
+
const body = await readBody(req);
|
|
117
|
+
if (!body || typeof body !== 'object') return sendJson(res, 400, { error: 'the body must be a JSON object' });
|
|
118
|
+
const { target, action, content, old_text: oldText } = body;
|
|
119
|
+
let result;
|
|
120
|
+
if (action === 'add') result = await store.add(target, content, { scan: false });
|
|
121
|
+
else if (action === 'replace') result = await store.replace(target, oldText, content, { scan: false });
|
|
122
|
+
else if (action === 'remove') result = await store.remove(target, oldText);
|
|
123
|
+
else return sendJson(res, 400, { error: "action must be 'add', 'replace' or 'remove'" });
|
|
124
|
+
sendJson(res, result.success ? 200 : 400, { result, state: await stateOf(store, tooling) });
|
|
125
|
+
},
|
|
126
|
+
}));
|
|
127
|
+
off.push(webServer.register({
|
|
128
|
+
kind: 'exact',
|
|
129
|
+
path: RECALL_PATH,
|
|
130
|
+
handler: async (req = {}, res) => {
|
|
131
|
+
const callId = new URL(req.url ?? '/', 'http://x').searchParams.get('callId');
|
|
132
|
+
if (!callId) return sendJson(res, 400, { error: 'missing callId' });
|
|
133
|
+
const entry = log.get(callId);
|
|
134
|
+
sendJson(res, entry ? 200 : 404, entry ?? { error: 'no record of that call (dsh may have restarted since)' });
|
|
135
|
+
},
|
|
136
|
+
}));
|
|
137
|
+
if (chatIndex) {
|
|
138
|
+
off.push(webServer.register({
|
|
139
|
+
kind: 'exact',
|
|
140
|
+
path: CHATS_PATH,
|
|
141
|
+
handler: async (req = {}, res) => {
|
|
142
|
+
if ((req.method || 'GET').toUpperCase() !== 'GET') return sendJson(res, 405, { error: 'method not allowed' });
|
|
143
|
+
const sessionId = new URL(req.url ?? '/', 'http://x').searchParams.get('session');
|
|
144
|
+
try {
|
|
145
|
+
sendJson(res, 200, await chatsFor(chatIndex, sessionId, cwdOf));
|
|
146
|
+
} catch (error) {
|
|
147
|
+
sendJson(res, 503, { error: String(error?.message ?? error) });
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
return () => { for (const dispose of off) dispose(); };
|
|
153
|
+
}
|