memonaut-pi 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 +661 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +118 -0
- package/dist/index.js.map +1 -0
- package/dist/queries.d.ts +42 -0
- package/dist/queries.d.ts.map +1 -0
- package/dist/queries.js +218 -0
- package/dist/queries.js.map +1 -0
- package/package.json +72 -0
- package/skills/memonaut/SKILL.md +102 -0
- package/src/index.ts +157 -0
- package/src/queries.ts +323 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {silenceSqliteWarning} from 'memonaut';
|
|
2
|
+
import {Type} from 'typebox';
|
|
3
|
+
import type {
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ToolDefinition,
|
|
6
|
+
} from '@earendil-works/pi-coding-agent';
|
|
7
|
+
import {runSearch, runSql, runThread, type ToolOutcome} from './queries.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* memonaut as a pi extension: three read-only tools over the transcript index.
|
|
11
|
+
*
|
|
12
|
+
* Scope is GLOBAL by design. An agent asked "what did we decide about X" usually
|
|
13
|
+
* means a decision taken in another repo on another day, so restricting to the
|
|
14
|
+
* current project by default would quietly make the tool useless for its main
|
|
15
|
+
* job. The filters are how you narrow, and the descriptions teach that.
|
|
16
|
+
*
|
|
17
|
+
* Transcripts matched by the config's `private` globs are never returned. That
|
|
18
|
+
* list exists precisely to draw this line, and no tool parameter can cross it.
|
|
19
|
+
*/
|
|
20
|
+
silenceSqliteWarning();
|
|
21
|
+
|
|
22
|
+
function result(outcome: ToolOutcome) {
|
|
23
|
+
return {
|
|
24
|
+
content: [{type: 'text' as const, text: outcome.text}],
|
|
25
|
+
details: outcome.details,
|
|
26
|
+
...(outcome.isError ? {isError: true} : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default function extension(pi: ExtensionAPI): void {
|
|
31
|
+
pi.registerTool({
|
|
32
|
+
name: 'recall_search',
|
|
33
|
+
label: 'Recall Search',
|
|
34
|
+
description:
|
|
35
|
+
'Search PAST conversation transcripts (previous sessions with this user, across every project) ' +
|
|
36
|
+
'for what was said, decided, tried or rejected. Use recall_search when the user refers to ' +
|
|
37
|
+
'something from an earlier session ("what did we decide about X", "we discussed this before", ' +
|
|
38
|
+
'"the approach we tried last month"), when you need context that is not in the current ' +
|
|
39
|
+
'conversation, or before redoing analysis that may already exist. It searches ALL projects by ' +
|
|
40
|
+
'default: pass project or cwd to narrow, since to bound the time range. The query is an FTS5 ' +
|
|
41
|
+
'expression: bare words are ANDed, "quoted phrases" match in order, OR and NOT work, NEAR(a b, 5) ' +
|
|
42
|
+
'matches within a window, and a trailing * is a prefix. Results are grouped per conversation; ' +
|
|
43
|
+
'when a match falls in history shared by several forked conversations, every fork that inherited ' +
|
|
44
|
+
'it is listed, newest activity first, so you can pick the right continuation. This searches ' +
|
|
45
|
+
'transcripts only, never the codebase: use grep for code.',
|
|
46
|
+
promptSnippet:
|
|
47
|
+
'Search past conversation transcripts across all projects for earlier decisions and context',
|
|
48
|
+
promptGuidelines: [
|
|
49
|
+
'Use recall_search when the user refers to an earlier session or a past decision, rather than guessing or asking them to repeat themselves.',
|
|
50
|
+
'recall_search covers all projects by default; pass project or since to narrow rather than assuming the current repo.',
|
|
51
|
+
],
|
|
52
|
+
parameters: Type.Object({
|
|
53
|
+
query: Type.String({
|
|
54
|
+
description:
|
|
55
|
+
'FTS5 query. Bare words are ANDed; use "quotes" for a phrase, OR, NOT, NEAR(a b, 5), prefix*.',
|
|
56
|
+
}),
|
|
57
|
+
project: Type.Optional(
|
|
58
|
+
Type.Array(Type.String(), {
|
|
59
|
+
description:
|
|
60
|
+
'Narrow to project names (the last path segment of the working directory), e.g. ["wherever"].',
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
cwd: Type.Optional(
|
|
64
|
+
Type.Array(Type.String(), {
|
|
65
|
+
description:
|
|
66
|
+
'Narrow to working-directory globs, e.g. ["~/dev/**/wherever"].',
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
role: Type.Optional(
|
|
70
|
+
Type.Array(Type.String(), {
|
|
71
|
+
description:
|
|
72
|
+
'Narrow by message role: user, assistant, toolResult, bashExecution.',
|
|
73
|
+
}),
|
|
74
|
+
),
|
|
75
|
+
tool: Type.Optional(
|
|
76
|
+
Type.Array(Type.String(), {
|
|
77
|
+
description: 'Narrow to messages involving these tool names.',
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
since: Type.Optional(
|
|
81
|
+
Type.String({description: 'ISO date lower bound, e.g. "2026-07-01".'}),
|
|
82
|
+
),
|
|
83
|
+
until: Type.Optional(Type.String({description: 'ISO date upper bound.'})),
|
|
84
|
+
limit: Type.Optional(
|
|
85
|
+
Type.Number({
|
|
86
|
+
description: 'Conversations to return, 1-50 (default 8).',
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
threads: Type.Optional(
|
|
90
|
+
Type.Number({
|
|
91
|
+
description: 'Forked threads listed per result, 1-25 (default 3).',
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
}),
|
|
95
|
+
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
96
|
+
return result(
|
|
97
|
+
runSearch(params as unknown as Parameters<typeof runSearch>[0]),
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
} as unknown as ToolDefinition);
|
|
101
|
+
|
|
102
|
+
pi.registerTool({
|
|
103
|
+
name: 'recall_thread',
|
|
104
|
+
label: 'Recall Thread',
|
|
105
|
+
description:
|
|
106
|
+
'Read a past conversation in order, given the thread number reported by recall_search. Use it ' +
|
|
107
|
+
'after a search when the snippet is not enough and you need what was actually said around it. ' +
|
|
108
|
+
'Paginate with from/limit; entries marked "inherited" came from a conversation this one was ' +
|
|
109
|
+
'forked out of. Tool output is stored truncated or omitted, so this shows the discussion, not ' +
|
|
110
|
+
'full command output.',
|
|
111
|
+
promptSnippet: 'Read a past conversation found via recall_search',
|
|
112
|
+
promptGuidelines: [
|
|
113
|
+
'Use recall_thread to read the surrounding discussion after recall_search returns a promising snippet, instead of guessing from the snippet alone.',
|
|
114
|
+
],
|
|
115
|
+
parameters: Type.Object({
|
|
116
|
+
ref: Type.String({
|
|
117
|
+
description:
|
|
118
|
+
'Thread number from recall_search (a session uuid prefix or name also works).',
|
|
119
|
+
}),
|
|
120
|
+
from: Type.Optional(
|
|
121
|
+
Type.Number({description: 'Entry index to start at (default 0).'}),
|
|
122
|
+
),
|
|
123
|
+
limit: Type.Optional(
|
|
124
|
+
Type.Number({description: 'Entries to return, 1-200 (default 30).'}),
|
|
125
|
+
),
|
|
126
|
+
}),
|
|
127
|
+
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
128
|
+
return result(
|
|
129
|
+
runThread(params as unknown as Parameters<typeof runThread>[0]),
|
|
130
|
+
);
|
|
131
|
+
},
|
|
132
|
+
} as unknown as ToolDefinition);
|
|
133
|
+
|
|
134
|
+
pi.registerTool({
|
|
135
|
+
name: 'recall_sql',
|
|
136
|
+
label: 'Recall SQL',
|
|
137
|
+
description:
|
|
138
|
+
'Run a read-only SQL query against the transcript index. Use recall_sql for questions that are ' +
|
|
139
|
+
'aggregates rather than searches ("which projects did I work on in July", "how many sessions ' +
|
|
140
|
+
'mention this tool", "when did this conversation start"). Tables: file(id, path, cwd, project, ' +
|
|
141
|
+
'name, parent_id, lineage_id, private, started, last_activity, entry_count), entry(id, lineage_id, ' +
|
|
142
|
+
'entry_key, role, tool, ts), membership(entry_id, file_id, seq), chunk(entry_id, kind, text), and ' +
|
|
143
|
+
'the FTS index chunk_fts. Prefer recall_search for anything that is really a text search.',
|
|
144
|
+
promptSnippet: 'Run a read-only SQL aggregate over the conversation index',
|
|
145
|
+
promptGuidelines: [
|
|
146
|
+
'Use recall_sql for counting and grouping questions about past sessions, and recall_search for finding what was said.',
|
|
147
|
+
],
|
|
148
|
+
parameters: Type.Object({
|
|
149
|
+
sql: Type.String({
|
|
150
|
+
description: 'A single read-only SELECT. Always include a LIMIT.',
|
|
151
|
+
}),
|
|
152
|
+
}),
|
|
153
|
+
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
154
|
+
return result(runSql(String((params as {sql?: string}).sql ?? '')));
|
|
155
|
+
},
|
|
156
|
+
} as unknown as ToolDefinition);
|
|
157
|
+
}
|
package/src/queries.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import {
|
|
2
|
+
indexStats,
|
|
3
|
+
loadConfig,
|
|
4
|
+
openDb,
|
|
5
|
+
readThread,
|
|
6
|
+
relativeTime,
|
|
7
|
+
resolveThread,
|
|
8
|
+
search,
|
|
9
|
+
syncIfStale,
|
|
10
|
+
tildify,
|
|
11
|
+
type ChunkKind,
|
|
12
|
+
type Config,
|
|
13
|
+
type SearchHit,
|
|
14
|
+
} from 'memonaut';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Agent-facing query layer.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately free of any pi import so it can be tested (and reused) on its
|
|
20
|
+
* own. The pi extension in `index.ts` is a thin wrapper that maps tool
|
|
21
|
+
* parameters onto these functions.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface ToolOutcome {
|
|
25
|
+
text: string;
|
|
26
|
+
details?: Record<string, unknown>;
|
|
27
|
+
isError?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ToolOptions {
|
|
31
|
+
/** Hard cap on returned characters, so one call cannot eat the context. */
|
|
32
|
+
maxChars?: number;
|
|
33
|
+
/** Skip the incremental catch-up if the index was synced this recently. */
|
|
34
|
+
syncTtlMs?: number;
|
|
35
|
+
config?: Config;
|
|
36
|
+
now?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DEFAULT_MAX_CHARS = 6000;
|
|
40
|
+
|
|
41
|
+
function options(
|
|
42
|
+
opts: ToolOptions,
|
|
43
|
+
): Required<Pick<ToolOptions, 'maxChars' | 'syncTtlMs'>> & {config: Config} {
|
|
44
|
+
const config = opts.config ?? loadConfig();
|
|
45
|
+
return {
|
|
46
|
+
maxChars:
|
|
47
|
+
opts.maxChars ??
|
|
48
|
+
Number(process.env.MEMONAUT_TOOL_MAX_CHARS ?? DEFAULT_MAX_CHARS),
|
|
49
|
+
syncTtlMs:
|
|
50
|
+
opts.syncTtlMs ?? Number(process.env.MEMONAUT_TOOL_SYNC_TTL_MS ?? 15_000),
|
|
51
|
+
config,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function missingIndex(config: Config): ToolOutcome {
|
|
56
|
+
return {
|
|
57
|
+
isError: true,
|
|
58
|
+
text:
|
|
59
|
+
`No transcript index exists yet at ${tildify(config.dbPath)}.\n` +
|
|
60
|
+
'Tell the user to run `recall index` once (it takes well under a minute), ' +
|
|
61
|
+
'or run it yourself with bash if they have already agreed to that.',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Strip FTS5 highlight markers down to something readable in a tool result. */
|
|
66
|
+
function cleanSnippet(snippet: string): string {
|
|
67
|
+
return snippet
|
|
68
|
+
.replace(/\s+/g, ' ')
|
|
69
|
+
.replace(/\u0001/g, '<<')
|
|
70
|
+
.replace(/\u0002/g, '>>')
|
|
71
|
+
.trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function truncate(lines: string[], maxChars: number, tail: string): string {
|
|
75
|
+
const out: string[] = [];
|
|
76
|
+
let used = 0;
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
if (used + line.length + 1 > maxChars) {
|
|
79
|
+
out.push(`… output truncated. ${tail}`);
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
out.push(line);
|
|
83
|
+
used += line.length + 1;
|
|
84
|
+
}
|
|
85
|
+
return out.join('\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface SearchParams {
|
|
89
|
+
query: string;
|
|
90
|
+
project?: string[];
|
|
91
|
+
cwd?: string[];
|
|
92
|
+
role?: string[];
|
|
93
|
+
tool?: string[];
|
|
94
|
+
kind?: string[];
|
|
95
|
+
since?: string;
|
|
96
|
+
until?: string;
|
|
97
|
+
limit?: number;
|
|
98
|
+
threads?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderHit(hit: SearchHit, position: number, now: number): string[] {
|
|
102
|
+
const primary = hit.threads[0];
|
|
103
|
+
const lines: string[] = [];
|
|
104
|
+
const when = (hit.ts ?? '').slice(0, 10);
|
|
105
|
+
lines.push(
|
|
106
|
+
`[${position}] ${primary?.project ?? '?'} · ${hit.role}${hit.tool ? `:${hit.tool}` : ''} · ${when} · thread ${primary?.fileId ?? '?'}`,
|
|
107
|
+
);
|
|
108
|
+
lines.push(` ${cleanSnippet(hit.snippet)}`);
|
|
109
|
+
if (hit.threadTotal > 1) {
|
|
110
|
+
// The entry lives in shared history: every fork that inherited it is a
|
|
111
|
+
// real, separate continuation, and which one to resume is the agent's
|
|
112
|
+
// decision to surface, not ours to silently make.
|
|
113
|
+
const shown = hit.threads
|
|
114
|
+
.map(
|
|
115
|
+
(t) =>
|
|
116
|
+
`${t.fileId} (last ${relativeTime(t.lastActivity, now)}, +${t.after} after)`,
|
|
117
|
+
)
|
|
118
|
+
.join(', ');
|
|
119
|
+
const hidden = hit.threadTotal - hit.threads.length;
|
|
120
|
+
lines.push(
|
|
121
|
+
` shared history: ${hit.threadTotal} threads carry this message: ${shown}` +
|
|
122
|
+
(hidden > 0 ? `, +${hidden} more` : ''),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (hit.otherHits > 0)
|
|
126
|
+
lines.push(
|
|
127
|
+
` ${hit.otherHits} further match(es) in the same conversation`,
|
|
128
|
+
);
|
|
129
|
+
return lines;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function runSearch(
|
|
133
|
+
params: SearchParams,
|
|
134
|
+
opts: ToolOptions = {},
|
|
135
|
+
): ToolOutcome {
|
|
136
|
+
const {config, maxChars, syncTtlMs} = options(opts);
|
|
137
|
+
const now = opts.now ?? Date.now();
|
|
138
|
+
try {
|
|
139
|
+
syncIfStale(config, syncTtlMs);
|
|
140
|
+
} catch {
|
|
141
|
+
return missingIndex(config);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const db = openDb(config.dbPath, {readOnly: true});
|
|
145
|
+
try {
|
|
146
|
+
const limit = Math.min(Math.max(params.limit ?? 8, 1), 50);
|
|
147
|
+
const outcome = search(
|
|
148
|
+
db,
|
|
149
|
+
{
|
|
150
|
+
text: params.query,
|
|
151
|
+
project: params.project,
|
|
152
|
+
cwd: params.cwd,
|
|
153
|
+
role: params.role,
|
|
154
|
+
tool: params.tool,
|
|
155
|
+
kind: params.kind as ChunkKind[] | undefined,
|
|
156
|
+
since: params.since,
|
|
157
|
+
until: params.until,
|
|
158
|
+
limit,
|
|
159
|
+
threadLimit: Math.min(Math.max(params.threads ?? 3, 1), 25),
|
|
160
|
+
// Private transcripts are never exposed here. That is the entire
|
|
161
|
+
// point of the config's `private` list: things the user is willing
|
|
162
|
+
// to index for themselves but not to hand to an agent.
|
|
163
|
+
includePrivate: false,
|
|
164
|
+
},
|
|
165
|
+
now,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (outcome.hits.length === 0) {
|
|
169
|
+
const stats = indexStats(db);
|
|
170
|
+
return {
|
|
171
|
+
text:
|
|
172
|
+
`No matches for: ${outcome.usedQuery}\n` +
|
|
173
|
+
`Searched ${stats.files} conversations across ${stats.projects} projects.\n` +
|
|
174
|
+
'Try fewer words, a quoted phrase, OR between alternatives, or drop the filters.',
|
|
175
|
+
details: {hits: 0, usedQuery: outcome.usedQuery},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const scope = params.project?.length
|
|
180
|
+
? `project ${params.project.join(', ')}`
|
|
181
|
+
: params.cwd?.length
|
|
182
|
+
? `cwd ${params.cwd.join(', ')}`
|
|
183
|
+
: 'all projects';
|
|
184
|
+
const header = `${outcome.hits.length} result(s) · query ${outcome.usedQuery} · scope: ${scope}`;
|
|
185
|
+
const lines = [header, ''];
|
|
186
|
+
outcome.hits.forEach((hit, i) => lines.push(...renderHit(hit, i + 1, now)));
|
|
187
|
+
lines.push('');
|
|
188
|
+
lines.push('Use recall_thread with a thread number to read one in full.');
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
text: truncate(
|
|
192
|
+
lines,
|
|
193
|
+
maxChars,
|
|
194
|
+
'Narrow with project/since or lower limit.',
|
|
195
|
+
),
|
|
196
|
+
details: {
|
|
197
|
+
usedQuery: outcome.usedQuery,
|
|
198
|
+
quotedFallback: outcome.quotedFallback,
|
|
199
|
+
hits: outcome.hits.map((h) => ({
|
|
200
|
+
role: h.role,
|
|
201
|
+
ts: h.ts,
|
|
202
|
+
threadTotal: h.threadTotal,
|
|
203
|
+
threads: h.threads.map((t) => ({
|
|
204
|
+
id: t.fileId,
|
|
205
|
+
project: t.project,
|
|
206
|
+
lastActivity: t.lastActivity,
|
|
207
|
+
})),
|
|
208
|
+
})),
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
} finally {
|
|
212
|
+
db.close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface ThreadParams {
|
|
217
|
+
ref: string;
|
|
218
|
+
from?: number;
|
|
219
|
+
limit?: number;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function runThread(
|
|
223
|
+
params: ThreadParams,
|
|
224
|
+
opts: ToolOptions = {},
|
|
225
|
+
): ToolOutcome {
|
|
226
|
+
const {config, maxChars} = options(opts);
|
|
227
|
+
let db;
|
|
228
|
+
try {
|
|
229
|
+
db = openDb(config.dbPath, {readOnly: true});
|
|
230
|
+
} catch {
|
|
231
|
+
return missingIndex(config);
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const thread = resolveThread(db, String(params.ref));
|
|
235
|
+
if (!thread)
|
|
236
|
+
return {isError: true, text: `No conversation matches "${params.ref}".`};
|
|
237
|
+
if (thread.private) {
|
|
238
|
+
// Resolvable by id, but explicitly withheld: say so rather than
|
|
239
|
+
// pretending it does not exist, so the user can lift it if they meant to.
|
|
240
|
+
return {
|
|
241
|
+
isError: true,
|
|
242
|
+
text: `Conversation ${params.ref} is marked private in the memonaut config and is not readable by tools.`,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const from = Math.max(params.from ?? 0, 0);
|
|
247
|
+
const limit = Math.min(Math.max(params.limit ?? 30, 1), 200);
|
|
248
|
+
const entries = readThread(db, Number(thread.id), from, limit);
|
|
249
|
+
|
|
250
|
+
const lines = [
|
|
251
|
+
`thread ${thread.id} · ${thread.name ?? thread.project ?? '?'} · ${thread.entry_count} entries · last activity ${thread.last_activity ?? '?'}`,
|
|
252
|
+
`cwd: ${thread.cwd ?? '?'}`,
|
|
253
|
+
'',
|
|
254
|
+
];
|
|
255
|
+
for (const entry of entries) {
|
|
256
|
+
const label = `${entry.seq} ${entry.role}${entry.tool ? `:${entry.tool}` : ''}${entry.shared ? ' (inherited from an earlier conversation)' : ''}`;
|
|
257
|
+
lines.push(label);
|
|
258
|
+
for (const text of entry.texts)
|
|
259
|
+
lines.push(
|
|
260
|
+
` [${text.kind}] ${text.text.replace(/\s+/g, ' ').slice(0, 1200)}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
const end = from + entries.length;
|
|
264
|
+
if (end < Number(thread.entry_count)) {
|
|
265
|
+
lines.push('');
|
|
266
|
+
lines.push(
|
|
267
|
+
`(showing ${from}-${end} of ${thread.entry_count}; call again with from=${end} to continue)`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
text: truncate(
|
|
273
|
+
lines,
|
|
274
|
+
maxChars,
|
|
275
|
+
`Call again with from=${from + Math.floor(limit / 2)} and a smaller limit.`,
|
|
276
|
+
),
|
|
277
|
+
details: {
|
|
278
|
+
threadId: thread.id,
|
|
279
|
+
from,
|
|
280
|
+
count: entries.length,
|
|
281
|
+
total: thread.entry_count,
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
} finally {
|
|
285
|
+
db?.close();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function runSql(sql: string, opts: ToolOptions = {}): ToolOutcome {
|
|
290
|
+
const {config, maxChars} = options(opts);
|
|
291
|
+
let db;
|
|
292
|
+
try {
|
|
293
|
+
db = openDb(config.dbPath, {readOnly: true});
|
|
294
|
+
} catch {
|
|
295
|
+
return missingIndex(config);
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const rows = db.prepare(sql).all() as unknown as Array<
|
|
299
|
+
Record<string, unknown>
|
|
300
|
+
>;
|
|
301
|
+
if (rows.length === 0) return {text: '(no rows)', details: {rows: 0}};
|
|
302
|
+
const lines = rows.slice(0, 200).map((row) => JSON.stringify(row));
|
|
303
|
+
return {
|
|
304
|
+
text: truncate(
|
|
305
|
+
[`${rows.length} row(s):`, ...lines],
|
|
306
|
+
maxChars,
|
|
307
|
+
'Add a LIMIT or aggregate instead.',
|
|
308
|
+
),
|
|
309
|
+
details: {rows: rows.length},
|
|
310
|
+
};
|
|
311
|
+
} catch (err) {
|
|
312
|
+
return {
|
|
313
|
+
isError: true,
|
|
314
|
+
text:
|
|
315
|
+
`SQL failed: ${(err as Error).message}\n` +
|
|
316
|
+
'Tables: file(id, path, cwd, project, name, parent_id, lineage_id, private, started, last_activity, entry_count), ' +
|
|
317
|
+
'entry(id, lineage_id, entry_key, role, tool, ts), membership(entry_id, file_id, seq), chunk(entry_id, kind, text). ' +
|
|
318
|
+
'The connection is read-only.',
|
|
319
|
+
};
|
|
320
|
+
} finally {
|
|
321
|
+
db?.close();
|
|
322
|
+
}
|
|
323
|
+
}
|