@mojoaar/opencode-mimir 0.26.0 → 0.29.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/dist/args.d.ts +20 -2
- package/dist/args.js +47 -26
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +113 -7
- package/dist/client.d.ts +15 -0
- package/dist/client.js +45 -1
- package/dist/tools.d.ts +4 -1
- package/dist/tools.js +53 -11
- package/package.json +1 -1
- package/src/args.ts +66 -28
- package/src/cli.ts +118 -9
- package/src/client.ts +50 -1
- package/src/tools.ts +66 -14
package/dist/args.d.ts
CHANGED
|
@@ -8,5 +8,23 @@ export type IngestArgs = {
|
|
|
8
8
|
dryRun: boolean;
|
|
9
9
|
help: boolean;
|
|
10
10
|
};
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
export type SearchArgs = {
|
|
12
|
+
command: 'search';
|
|
13
|
+
query: string;
|
|
14
|
+
project?: string;
|
|
15
|
+
limit?: number;
|
|
16
|
+
url?: string;
|
|
17
|
+
token?: string;
|
|
18
|
+
help: boolean;
|
|
19
|
+
};
|
|
20
|
+
export type ExportArgs = {
|
|
21
|
+
command: 'export';
|
|
22
|
+
project?: string;
|
|
23
|
+
out?: string;
|
|
24
|
+
url?: string;
|
|
25
|
+
token?: string;
|
|
26
|
+
help: boolean;
|
|
27
|
+
};
|
|
28
|
+
export type Args = IngestArgs | SearchArgs | ExportArgs;
|
|
29
|
+
/** Hand-rolled on purpose: five value flags do not justify a dependency. */
|
|
30
|
+
export declare function parseArgs(argv: string[]): Args;
|
package/dist/args.js
CHANGED
|
@@ -1,47 +1,68 @@
|
|
|
1
|
-
const VALUE_FLAGS = new Set(['project', 'url', 'token']);
|
|
2
|
-
/** Hand-rolled on purpose:
|
|
1
|
+
const VALUE_FLAGS = new Set(['project', 'url', 'token', 'limit', 'out']);
|
|
2
|
+
/** Hand-rolled on purpose: five value flags do not justify a dependency. */
|
|
3
3
|
export function parseArgs(argv) {
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
const first = argv[0];
|
|
5
|
+
const command = first === 'search' ? 'search' : first === 'export' ? 'export' : 'ingest';
|
|
6
|
+
const rest = command === 'ingest' ? argv : argv.slice(1);
|
|
7
|
+
let path = '.';
|
|
8
|
+
let query = '';
|
|
9
|
+
let all = false;
|
|
10
|
+
let dryRun = false;
|
|
11
|
+
let help = argv.length === 0;
|
|
12
|
+
let project;
|
|
13
|
+
let url;
|
|
14
|
+
let token;
|
|
15
|
+
let out;
|
|
16
|
+
let limit;
|
|
17
|
+
for (let i = 0; i < rest.length; i++) {
|
|
18
|
+
const arg = rest[i];
|
|
19
|
+
if (arg === 'ingest' || arg === 'import' || arg === 'search' || arg === 'export')
|
|
14
20
|
continue;
|
|
15
|
-
if (
|
|
16
|
-
|
|
21
|
+
if (arg === '-h' || arg === '--help') {
|
|
22
|
+
help = true;
|
|
17
23
|
continue;
|
|
18
24
|
}
|
|
19
|
-
if (
|
|
20
|
-
|
|
25
|
+
if (arg === '--all') {
|
|
26
|
+
all = true;
|
|
21
27
|
continue;
|
|
22
28
|
}
|
|
23
|
-
if (
|
|
24
|
-
|
|
29
|
+
if (arg === '--dry-run') {
|
|
30
|
+
dryRun = true;
|
|
25
31
|
continue;
|
|
26
32
|
}
|
|
27
|
-
if (
|
|
28
|
-
const body =
|
|
33
|
+
if (arg.startsWith('--')) {
|
|
34
|
+
const body = arg.slice(2);
|
|
29
35
|
const equals = body.indexOf('=');
|
|
30
36
|
const name = equals === -1 ? body : body.slice(0, equals);
|
|
31
37
|
if (!VALUE_FLAGS.has(name))
|
|
32
38
|
throw new Error(`unknown flag --${name}`);
|
|
33
|
-
const value = equals === -1 ?
|
|
39
|
+
const value = equals === -1 ? rest[++i] : body.slice(equals + 1);
|
|
34
40
|
if (value === undefined || value.startsWith('--'))
|
|
35
41
|
throw new Error(`--${name} needs a value`);
|
|
36
42
|
if (name === 'project')
|
|
37
|
-
|
|
43
|
+
project = value;
|
|
38
44
|
if (name === 'url')
|
|
39
|
-
|
|
45
|
+
url = value;
|
|
40
46
|
if (name === 'token')
|
|
41
|
-
|
|
47
|
+
token = value;
|
|
48
|
+
if (name === 'out')
|
|
49
|
+
out = value;
|
|
50
|
+
if (name === 'limit') {
|
|
51
|
+
const size = Number(value);
|
|
52
|
+
if (!Number.isInteger(size) || size < 1)
|
|
53
|
+
throw new Error('--limit needs a whole number');
|
|
54
|
+
limit = size;
|
|
55
|
+
}
|
|
42
56
|
continue;
|
|
43
57
|
}
|
|
44
|
-
|
|
58
|
+
if (command === 'search')
|
|
59
|
+
query = arg;
|
|
60
|
+
else if (command === 'ingest')
|
|
61
|
+
path = arg;
|
|
45
62
|
}
|
|
46
|
-
|
|
63
|
+
if (command === 'search')
|
|
64
|
+
return { command, query, project, limit, url, token, help };
|
|
65
|
+
if (command === 'export')
|
|
66
|
+
return { command, project, out, url, token, help };
|
|
67
|
+
return { command, path, project, url, token, all, dryRun, help };
|
|
47
68
|
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { type IngestArgs } from './args.js';
|
|
2
|
+
import { type ExportArgs, type IngestArgs, type SearchArgs } from './args.js';
|
|
3
3
|
export type IngestIo = {
|
|
4
4
|
out: (line: string) => void;
|
|
5
5
|
err: (line: string) => void;
|
|
@@ -10,4 +10,6 @@ export type IngestIo = {
|
|
|
10
10
|
* is testable and a caller can decide what a partial import means.
|
|
11
11
|
*/
|
|
12
12
|
export declare function runIngest(args: IngestArgs, io: IngestIo): Promise<number>;
|
|
13
|
+
export declare function runSearch(args: SearchArgs, io: IngestIo): Promise<number>;
|
|
14
|
+
export declare function runExport(args: ExportArgs, io: IngestIo): Promise<number>;
|
|
13
15
|
export declare function main(argv: string[]): Promise<void>;
|
package/dist/cli.js
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { readFile, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { parseArgs } from './args.js';
|
|
5
5
|
import { batchFiles } from './batch.js';
|
|
6
|
-
import { callTool, detectRepo } from './client.js';
|
|
6
|
+
import { callRoute, callTool, detectRepo } from './client.js';
|
|
7
7
|
import { selectFiles } from './select.js';
|
|
8
|
-
const USAGE = `mimir
|
|
8
|
+
const USAGE = `mimir — long-term memory for an agent
|
|
9
9
|
|
|
10
|
-
mimir ingest [path] [options]
|
|
10
|
+
mimir ingest [path] [options] load a project's documentation
|
|
11
|
+
mimir search <query> [options] search every project you can read
|
|
12
|
+
mimir export [options] write a project out as one markdown file
|
|
11
13
|
|
|
12
14
|
--project <name> name the project yourself. Required for a folder that is
|
|
13
|
-
not a git repository
|
|
15
|
+
not a git repository, and for export.
|
|
14
16
|
--url <url> Mimir base URL (default: MIMIR_URL)
|
|
15
17
|
--token <token> API key (default: MIMIR_TOKEN)
|
|
16
18
|
--all import every text file, not just documentation
|
|
17
19
|
--dry-run print what would be sent and send nothing
|
|
20
|
+
--limit <n> search: how many hits to ask for
|
|
21
|
+
--out <file> export: where to write it (default: <project>.md)
|
|
18
22
|
|
|
19
23
|
A git checkout imports into the project your agents already write to. Imports
|
|
20
24
|
land under import/, where memory_search finds them.`;
|
|
@@ -108,6 +112,102 @@ export async function runIngest(args, io) {
|
|
|
108
112
|
}
|
|
109
113
|
return failed > 0 || skipped.length > 0 || walk.truncated ? 1 : 0;
|
|
110
114
|
}
|
|
115
|
+
/** One place for a readable failure: the message, then exit code 1. */
|
|
116
|
+
function failed(io, message) {
|
|
117
|
+
io.err(message);
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
async function projectSlugs(args) {
|
|
121
|
+
const listed = await callTool('project_list', {}, { url: args.url, token: args.token, timeoutMs: 5000 });
|
|
122
|
+
if (!listed.ok || !Array.isArray(listed.result))
|
|
123
|
+
return [];
|
|
124
|
+
return listed.result
|
|
125
|
+
.map((row) => {
|
|
126
|
+
const slug = row?.slug;
|
|
127
|
+
return typeof slug === 'string' ? slug : null;
|
|
128
|
+
})
|
|
129
|
+
.filter((slug) => slug !== null);
|
|
130
|
+
}
|
|
131
|
+
/** A bare "not found" is useless when the answer is one flag away. */
|
|
132
|
+
async function askWhichProject(io, args) {
|
|
133
|
+
const available = await projectSlugs(args);
|
|
134
|
+
io.err('Pick one of:');
|
|
135
|
+
if (available.length === 0)
|
|
136
|
+
io.err(' (no projects yet, so write one memory first)');
|
|
137
|
+
for (const slug of available)
|
|
138
|
+
io.err(` ${slug}`);
|
|
139
|
+
io.err('then pass --project <name>');
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
export async function runSearch(args, io) {
|
|
143
|
+
if (args.help) {
|
|
144
|
+
io.out(USAGE);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
if (args.query.trim() === '') {
|
|
148
|
+
return failed(io, 'pass a query, for example: mimir search "rotate the keys"');
|
|
149
|
+
}
|
|
150
|
+
const response = await callRoute('/api/v1/search', {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
body: {
|
|
153
|
+
query: args.query,
|
|
154
|
+
...(args.project ? { project: args.project } : {}),
|
|
155
|
+
...(args.limit ? { limit: args.limit } : {}),
|
|
156
|
+
},
|
|
157
|
+
url: args.url,
|
|
158
|
+
token: args.token,
|
|
159
|
+
});
|
|
160
|
+
if (!response.ok)
|
|
161
|
+
return failed(io, response.error);
|
|
162
|
+
let hits;
|
|
163
|
+
try {
|
|
164
|
+
const parsed = JSON.parse(response.body);
|
|
165
|
+
hits = Array.isArray(parsed.results) ? parsed.results : [];
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return failed(io, 'mimir answered something that was not search results');
|
|
169
|
+
}
|
|
170
|
+
if (hits.length === 0) {
|
|
171
|
+
io.out('Nothing matches that in Mimir.');
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
for (const hit of hits) {
|
|
175
|
+
const path = typeof hit.path === 'string' ? hit.path : '';
|
|
176
|
+
const title = typeof hit.title === 'string' ? hit.title : '';
|
|
177
|
+
// Without --project the same path can exist in several projects, so the
|
|
178
|
+
// line has to say which one it came from.
|
|
179
|
+
const where = args.project
|
|
180
|
+
? path
|
|
181
|
+
: `${typeof hit.projectSlug === 'string' ? hit.projectSlug : '?'}: ${path}`;
|
|
182
|
+
io.out(title ? `${where} - ${title}` : where);
|
|
183
|
+
}
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
export async function runExport(args, io) {
|
|
187
|
+
if (args.help) {
|
|
188
|
+
io.out(USAGE);
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
if (!args.project) {
|
|
192
|
+
io.err('export needs a project: the file is named after it');
|
|
193
|
+
return askWhichProject(io, args);
|
|
194
|
+
}
|
|
195
|
+
const response = await callRoute(`/api/v1/projects/${encodeURIComponent(args.project)}/export`, {
|
|
196
|
+
url: args.url,
|
|
197
|
+
token: args.token,
|
|
198
|
+
});
|
|
199
|
+
if (!response.ok)
|
|
200
|
+
return failed(io, response.error);
|
|
201
|
+
const target = args.out ?? `${args.project}.md`;
|
|
202
|
+
try {
|
|
203
|
+
await writeFile(resolve(io.cwd, target), response.body, 'utf8');
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
return failed(io, error.message);
|
|
207
|
+
}
|
|
208
|
+
io.out(`${Buffer.byteLength(response.body, 'utf8')} bytes written to ${target}`);
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
111
211
|
export async function main(argv) {
|
|
112
212
|
let args;
|
|
113
213
|
try {
|
|
@@ -119,9 +219,15 @@ export async function main(argv) {
|
|
|
119
219
|
process.exitCode = 2;
|
|
120
220
|
return;
|
|
121
221
|
}
|
|
122
|
-
|
|
222
|
+
const io = {
|
|
123
223
|
out: (line) => console.log(line),
|
|
124
224
|
err: (line) => console.error(line),
|
|
125
225
|
cwd: process.cwd(),
|
|
126
|
-
}
|
|
226
|
+
};
|
|
227
|
+
if (args.command === 'search')
|
|
228
|
+
process.exitCode = await runSearch(args, io);
|
|
229
|
+
else if (args.command === 'export')
|
|
230
|
+
process.exitCode = await runExport(args, io);
|
|
231
|
+
else
|
|
232
|
+
process.exitCode = await runIngest(args, io);
|
|
127
233
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -5,6 +5,13 @@ export type CallResult = {
|
|
|
5
5
|
ok: false;
|
|
6
6
|
error: string;
|
|
7
7
|
};
|
|
8
|
+
export type RouteResult = {
|
|
9
|
+
ok: true;
|
|
10
|
+
body: string;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
error: string;
|
|
14
|
+
};
|
|
8
15
|
export type CallOptions = {
|
|
9
16
|
url?: string;
|
|
10
17
|
token?: string;
|
|
@@ -18,3 +25,11 @@ export type CallOptions = {
|
|
|
18
25
|
export declare function callTool(name: string, args: Record<string, unknown>, options?: CallOptions): Promise<CallResult>;
|
|
19
26
|
/** A repo key for X-Mimir-Repo, from MIMIR_REPO or `git remote get-url origin`. */
|
|
20
27
|
export declare function detectRepo(directory: string): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Calls one of the REST routes, for the CLI commands that need a whole file
|
|
30
|
+
* rather than a tool result. Same fail-soft contract as callTool.
|
|
31
|
+
*/
|
|
32
|
+
export declare function callRoute(path: string, options?: CallOptions & {
|
|
33
|
+
method?: 'GET' | 'POST';
|
|
34
|
+
body?: unknown;
|
|
35
|
+
}): Promise<RouteResult>;
|
package/dist/client.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
+
function missingConfig(url, token) {
|
|
3
|
+
const missing = [url ? null : 'MIMIR_URL', token ? null : 'MIMIR_TOKEN'].filter(Boolean);
|
|
4
|
+
return `set ${missing.join(' and ')}`;
|
|
5
|
+
}
|
|
2
6
|
/**
|
|
3
7
|
* Calls one Mimir MCP tool. Never throws: a plugin must not break a session
|
|
4
8
|
* because the memory server is down.
|
|
@@ -7,7 +11,7 @@ export async function callTool(name, args, options = {}) {
|
|
|
7
11
|
const url = (options.url ?? process.env.MIMIR_URL ?? '').replace(/\/+$/, '');
|
|
8
12
|
const token = options.token ?? process.env.MIMIR_TOKEN ?? '';
|
|
9
13
|
if (!url || !token)
|
|
10
|
-
return { ok: false, error:
|
|
14
|
+
return { ok: false, error: missingConfig(url, token) };
|
|
11
15
|
try {
|
|
12
16
|
const response = await (options.fetch ?? fetch)(`${url}/mcp`, {
|
|
13
17
|
method: 'POST',
|
|
@@ -60,3 +64,43 @@ export function detectRepo(directory) {
|
|
|
60
64
|
return null;
|
|
61
65
|
}
|
|
62
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Calls one of the REST routes, for the CLI commands that need a whole file
|
|
69
|
+
* rather than a tool result. Same fail-soft contract as callTool.
|
|
70
|
+
*/
|
|
71
|
+
export async function callRoute(path, options = {}) {
|
|
72
|
+
const url = (options.url ?? process.env.MIMIR_URL ?? '').replace(/\/+$/, '');
|
|
73
|
+
const token = options.token ?? process.env.MIMIR_TOKEN ?? '';
|
|
74
|
+
if (!url || !token)
|
|
75
|
+
return { ok: false, error: missingConfig(url, token) };
|
|
76
|
+
try {
|
|
77
|
+
const response = await (options.fetch ?? fetch)(`${url}${path}`, {
|
|
78
|
+
method: options.method ?? 'GET',
|
|
79
|
+
headers: {
|
|
80
|
+
...(options.body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
81
|
+
authorization: `Bearer ${token}`,
|
|
82
|
+
},
|
|
83
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
84
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
|
|
85
|
+
});
|
|
86
|
+
const text = await response.text();
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
// The API answers { error, code }; a proxy may answer HTML. Prefer the
|
|
89
|
+
// sentence when there is one, never a stack trace.
|
|
90
|
+
let message = `mimir responded ${response.status}`;
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(text);
|
|
93
|
+
if (typeof parsed.error === 'string')
|
|
94
|
+
message = parsed.error;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// keep the status line
|
|
98
|
+
}
|
|
99
|
+
return { ok: false, error: message };
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, body: text };
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
return { ok: false, error: error instanceof Error ? error.message : 'request failed' };
|
|
105
|
+
}
|
|
106
|
+
}
|
package/dist/tools.d.ts
CHANGED
|
@@ -2,7 +2,10 @@ import type { MimirConfig } from './config.js';
|
|
|
2
2
|
export type Registration = {
|
|
3
3
|
dispose: () => Promise<void>;
|
|
4
4
|
};
|
|
5
|
-
type
|
|
5
|
+
type ToolContext = {
|
|
6
|
+
sessionID?: string;
|
|
7
|
+
} | undefined;
|
|
8
|
+
type ToolExecutor = (input: unknown, context: ToolContext) => Promise<{
|
|
6
9
|
content: string;
|
|
7
10
|
}>;
|
|
8
11
|
type ToolDefinition = {
|
package/dist/tools.js
CHANGED
|
@@ -13,6 +13,7 @@ export async function registerTools(ctx, config, repo) {
|
|
|
13
13
|
editor.add(digestTool(repo));
|
|
14
14
|
editor.add(rememberTool(repo));
|
|
15
15
|
editor.add(searchTool(repo));
|
|
16
|
+
editor.add(readTool(repo));
|
|
16
17
|
editor.add(learnTool(repo));
|
|
17
18
|
});
|
|
18
19
|
}
|
|
@@ -55,7 +56,8 @@ function digestTool(repo) {
|
|
|
55
56
|
additionalProperties: false,
|
|
56
57
|
},
|
|
57
58
|
async execute(input) {
|
|
58
|
-
const
|
|
59
|
+
const args = input;
|
|
60
|
+
const result = await callTool('memory_digest', { budget_tokens: Number(args.budget_tokens ?? 800), repo: repo ?? undefined }, { timeoutMs: 5000 });
|
|
59
61
|
if (!result.ok)
|
|
60
62
|
return { content: `Mimir is unavailable (${result.error}). Carry on without it.` };
|
|
61
63
|
const digest = result.result;
|
|
@@ -63,6 +65,9 @@ function digestTool(repo) {
|
|
|
63
65
|
},
|
|
64
66
|
};
|
|
65
67
|
}
|
|
68
|
+
// A project created from a repo a teammate already knows about splits the
|
|
69
|
+
// knowledge in two until someone asks the owner for a grant.
|
|
70
|
+
const NEW_PROJECT = ' Started a new project. If a teammate already uses this repository, request access at /app/requests on your Mimir install.';
|
|
66
71
|
function rememberTool(repo) {
|
|
67
72
|
return {
|
|
68
73
|
name: 'mimir_remember',
|
|
@@ -72,6 +77,7 @@ function rememberTool(repo) {
|
|
|
72
77
|
properties: {
|
|
73
78
|
path: { type: 'string', description: 'Slash-separated memory path, e.g. deploy/rollback' },
|
|
74
79
|
body: { type: 'string', description: 'What to remember, written so it reads without the conversation' },
|
|
80
|
+
title: { type: 'string', description: 'One line. Defaults to the first line of the body.' },
|
|
75
81
|
kind: {
|
|
76
82
|
type: 'string',
|
|
77
83
|
enum: ['note', 'fact', 'decision', 'gotcha', 'procedure', 'snippet', 'import'],
|
|
@@ -82,10 +88,12 @@ function rememberTool(repo) {
|
|
|
82
88
|
},
|
|
83
89
|
// `client` and `session` are provenance, so history can say who wrote it.
|
|
84
90
|
async execute(input, context) {
|
|
91
|
+
const args = input;
|
|
85
92
|
const result = await callTool('memory_write', {
|
|
86
|
-
path:
|
|
87
|
-
body:
|
|
88
|
-
|
|
93
|
+
path: String(args.path ?? ''),
|
|
94
|
+
body: String(args.body ?? ''),
|
|
95
|
+
title: typeof args.title === 'string' ? args.title : undefined,
|
|
96
|
+
kind: typeof args.kind === 'string' ? args.kind : undefined,
|
|
89
97
|
repo: repo ?? undefined,
|
|
90
98
|
client: 'opencode',
|
|
91
99
|
session: context?.sessionID,
|
|
@@ -93,7 +101,10 @@ function rememberTool(repo) {
|
|
|
93
101
|
if (!result.ok)
|
|
94
102
|
return { content: `Could not store that (${result.error}).` };
|
|
95
103
|
const written = result.result;
|
|
96
|
-
|
|
104
|
+
const note = written.projectCreated ? NEW_PROJECT : '';
|
|
105
|
+
return {
|
|
106
|
+
content: `${written.created ? 'Stored' : 'Updated'} ${written.path ?? String(args.path ?? '')}.${note}`,
|
|
107
|
+
};
|
|
97
108
|
},
|
|
98
109
|
};
|
|
99
110
|
}
|
|
@@ -111,7 +122,8 @@ function searchTool(repo) {
|
|
|
111
122
|
additionalProperties: false,
|
|
112
123
|
},
|
|
113
124
|
async execute(input) {
|
|
114
|
-
const
|
|
125
|
+
const args = input;
|
|
126
|
+
const result = await callTool('memory_search', { query: typeof args.query === 'string' ? args.query : '', path_prefix: typeof args.path_prefix === 'string' ? args.path_prefix : undefined, repo: repo ?? undefined }, { timeoutMs: 8000 });
|
|
115
127
|
if (!result.ok)
|
|
116
128
|
return { content: `Mimir is unavailable (${result.error}). Carry on without it.` };
|
|
117
129
|
const hits = result.result;
|
|
@@ -125,6 +137,31 @@ function searchTool(repo) {
|
|
|
125
137
|
},
|
|
126
138
|
};
|
|
127
139
|
}
|
|
140
|
+
/** The date is what tells a reader whether the body still holds. */
|
|
141
|
+
function renderMemory(memory) {
|
|
142
|
+
const day = typeof memory.updatedAt === 'string' ? memory.updatedAt.slice(0, 10) : '';
|
|
143
|
+
const head = day ? `${memory.path ?? ''} · updated ${day}` : (memory.path ?? '');
|
|
144
|
+
return `${head}\n# ${memory.title ?? ''}\n\n${memory.body ?? ''}`;
|
|
145
|
+
}
|
|
146
|
+
function readTool(repo) {
|
|
147
|
+
return {
|
|
148
|
+
name: 'mimir_read',
|
|
149
|
+
description: 'Read one memory in full by its path. Search returns paths and summaries; this is the body behind one.',
|
|
150
|
+
input: {
|
|
151
|
+
type: 'object',
|
|
152
|
+
properties: { path: { type: 'string', description: 'e.g. deploy/rollback' } },
|
|
153
|
+
required: ['path'],
|
|
154
|
+
additionalProperties: false,
|
|
155
|
+
},
|
|
156
|
+
async execute(input) {
|
|
157
|
+
const args = input;
|
|
158
|
+
const result = await callTool('memory_read', { path: String(args.path ?? ''), repo: repo ?? undefined }, { timeoutMs: 8000 });
|
|
159
|
+
if (!result.ok)
|
|
160
|
+
return { content: 'Nothing is stored at that path.' };
|
|
161
|
+
return { content: renderMemory(result.result) };
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
128
165
|
function learnTool(repo) {
|
|
129
166
|
return {
|
|
130
167
|
name: 'mimir_learn',
|
|
@@ -139,9 +176,10 @@ function learnTool(repo) {
|
|
|
139
176
|
additionalProperties: false,
|
|
140
177
|
},
|
|
141
178
|
async execute(input, context) {
|
|
179
|
+
const args = input;
|
|
142
180
|
const result = await callTool('memory_learn', {
|
|
143
|
-
transcript:
|
|
144
|
-
hint:
|
|
181
|
+
transcript: String(args.transcript ?? ''),
|
|
182
|
+
hint: typeof args.hint === 'string' ? args.hint : undefined,
|
|
145
183
|
repo: repo ?? undefined,
|
|
146
184
|
client: 'opencode',
|
|
147
185
|
session: context?.sessionID,
|
|
@@ -149,10 +187,14 @@ function learnTool(repo) {
|
|
|
149
187
|
if (!result.ok)
|
|
150
188
|
return { content: `Could not hand that over (${result.error}).` };
|
|
151
189
|
const queued = result.result;
|
|
190
|
+
if (queued.queued) {
|
|
191
|
+
return {
|
|
192
|
+
content: `Queued. The model writes the memories in the background.${queued.projectCreated ? NEW_PROJECT : ''}`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// A refusal is a fact the agent should act on, not a failure to retry.
|
|
152
196
|
return {
|
|
153
|
-
content: queued.queued
|
|
154
|
-
? 'Queued. The model writes the memories in the background.'
|
|
155
|
-
: 'Mimir took no job: its queue is not running.',
|
|
197
|
+
content: typeof queued.reason === 'string' ? queued.reason : 'Mimir took no job: its queue is not running.',
|
|
156
198
|
};
|
|
157
199
|
},
|
|
158
200
|
};
|
package/package.json
CHANGED
package/src/args.ts
CHANGED
|
@@ -9,47 +9,85 @@ export type IngestArgs = {
|
|
|
9
9
|
help: boolean
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
12
|
+
export type SearchArgs = {
|
|
13
|
+
command: 'search'
|
|
14
|
+
query: string
|
|
15
|
+
project?: string
|
|
16
|
+
limit?: number
|
|
17
|
+
url?: string
|
|
18
|
+
token?: string
|
|
19
|
+
help: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ExportArgs = {
|
|
23
|
+
command: 'export'
|
|
24
|
+
project?: string
|
|
25
|
+
out?: string
|
|
26
|
+
url?: string
|
|
27
|
+
token?: string
|
|
28
|
+
help: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Args = IngestArgs | SearchArgs | ExportArgs
|
|
32
|
+
|
|
33
|
+
const VALUE_FLAGS = new Set(['project', 'url', 'token', 'limit', 'out'])
|
|
34
|
+
|
|
35
|
+
/** Hand-rolled on purpose: five value flags do not justify a dependency. */
|
|
36
|
+
export function parseArgs(argv: string[]): Args {
|
|
37
|
+
const first = argv[0]
|
|
38
|
+
const command: Args['command'] =
|
|
39
|
+
first === 'search' ? 'search' : first === 'export' ? 'export' : 'ingest'
|
|
40
|
+
const rest = command === 'ingest' ? argv : argv.slice(1)
|
|
41
|
+
|
|
42
|
+
let path = '.'
|
|
43
|
+
let query = ''
|
|
44
|
+
let all = false
|
|
45
|
+
let dryRun = false
|
|
46
|
+
let help = argv.length === 0
|
|
47
|
+
let project: string | undefined
|
|
48
|
+
let url: string | undefined
|
|
49
|
+
let token: string | undefined
|
|
50
|
+
let out: string | undefined
|
|
51
|
+
let limit: number | undefined
|
|
23
52
|
|
|
24
|
-
for (let i = 0; i <
|
|
25
|
-
const
|
|
26
|
-
if (
|
|
27
|
-
if (
|
|
28
|
-
|
|
53
|
+
for (let i = 0; i < rest.length; i++) {
|
|
54
|
+
const arg = rest[i] as string
|
|
55
|
+
if (arg === 'ingest' || arg === 'import' || arg === 'search' || arg === 'export') continue
|
|
56
|
+
if (arg === '-h' || arg === '--help') {
|
|
57
|
+
help = true
|
|
29
58
|
continue
|
|
30
59
|
}
|
|
31
|
-
if (
|
|
32
|
-
|
|
60
|
+
if (arg === '--all') {
|
|
61
|
+
all = true
|
|
33
62
|
continue
|
|
34
63
|
}
|
|
35
|
-
if (
|
|
36
|
-
|
|
64
|
+
if (arg === '--dry-run') {
|
|
65
|
+
dryRun = true
|
|
37
66
|
continue
|
|
38
67
|
}
|
|
39
|
-
if (
|
|
40
|
-
const body =
|
|
68
|
+
if (arg.startsWith('--')) {
|
|
69
|
+
const body = arg.slice(2)
|
|
41
70
|
const equals = body.indexOf('=')
|
|
42
71
|
const name = equals === -1 ? body : body.slice(0, equals)
|
|
43
72
|
if (!VALUE_FLAGS.has(name)) throw new Error(`unknown flag --${name}`)
|
|
44
|
-
const value = equals === -1 ?
|
|
73
|
+
const value = equals === -1 ? rest[++i] : body.slice(equals + 1)
|
|
45
74
|
if (value === undefined || value.startsWith('--')) throw new Error(`--${name} needs a value`)
|
|
46
|
-
if (name === 'project')
|
|
47
|
-
if (name === 'url')
|
|
48
|
-
if (name === 'token')
|
|
75
|
+
if (name === 'project') project = value
|
|
76
|
+
if (name === 'url') url = value
|
|
77
|
+
if (name === 'token') token = value
|
|
78
|
+
if (name === 'out') out = value
|
|
79
|
+
if (name === 'limit') {
|
|
80
|
+
const size = Number(value)
|
|
81
|
+
if (!Number.isInteger(size) || size < 1) throw new Error('--limit needs a whole number')
|
|
82
|
+
limit = size
|
|
83
|
+
}
|
|
49
84
|
continue
|
|
50
85
|
}
|
|
51
|
-
|
|
86
|
+
if (command === 'search') query = arg
|
|
87
|
+
else if (command === 'ingest') path = arg
|
|
52
88
|
}
|
|
53
89
|
|
|
54
|
-
return
|
|
90
|
+
if (command === 'search') return { command, query, project, limit, url, token, help }
|
|
91
|
+
if (command === 'export') return { command, project, out, url, token, help }
|
|
92
|
+
return { command, path, project, url, token, all, dryRun, help }
|
|
55
93
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFile, stat } from 'node:fs/promises'
|
|
2
|
+
import { readFile, stat, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { basename, join, resolve } from 'node:path'
|
|
4
4
|
|
|
5
|
-
import { parseArgs, type IngestArgs } from './args.js'
|
|
5
|
+
import { parseArgs, type Args, type ExportArgs, type IngestArgs, type SearchArgs } from './args.js'
|
|
6
6
|
import { batchFiles } from './batch.js'
|
|
7
|
-
import { callTool, detectRepo } from './client.js'
|
|
7
|
+
import { callRoute, callTool, detectRepo } from './client.js'
|
|
8
8
|
import { selectFiles } from './select.js'
|
|
9
9
|
|
|
10
|
-
const USAGE = `mimir
|
|
10
|
+
const USAGE = `mimir — long-term memory for an agent
|
|
11
11
|
|
|
12
|
-
mimir ingest [path] [options]
|
|
12
|
+
mimir ingest [path] [options] load a project's documentation
|
|
13
|
+
mimir search <query> [options] search every project you can read
|
|
14
|
+
mimir export [options] write a project out as one markdown file
|
|
13
15
|
|
|
14
16
|
--project <name> name the project yourself. Required for a folder that is
|
|
15
|
-
not a git repository
|
|
17
|
+
not a git repository, and for export.
|
|
16
18
|
--url <url> Mimir base URL (default: MIMIR_URL)
|
|
17
19
|
--token <token> API key (default: MIMIR_TOKEN)
|
|
18
20
|
--all import every text file, not just documentation
|
|
19
21
|
--dry-run print what would be sent and send nothing
|
|
22
|
+
--limit <n> search: how many hits to ask for
|
|
23
|
+
--out <file> export: where to write it (default: <project>.md)
|
|
20
24
|
|
|
21
25
|
A git checkout imports into the project your agents already write to. Imports
|
|
22
26
|
land under import/, where memory_search finds them.`
|
|
@@ -134,8 +138,108 @@ export async function runIngest(args: IngestArgs, io: IngestIo): Promise<number>
|
|
|
134
138
|
return failed > 0 || skipped.length > 0 || walk.truncated ? 1 : 0
|
|
135
139
|
}
|
|
136
140
|
|
|
141
|
+
/** One place for a readable failure: the message, then exit code 1. */
|
|
142
|
+
function failed(io: IngestIo, message: string): number {
|
|
143
|
+
io.err(message)
|
|
144
|
+
return 1
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function projectSlugs(args: { url?: string; token?: string }): Promise<string[]> {
|
|
148
|
+
const listed = await callTool('project_list', {}, { url: args.url, token: args.token, timeoutMs: 5000 })
|
|
149
|
+
if (!listed.ok || !Array.isArray(listed.result)) return []
|
|
150
|
+
return listed.result
|
|
151
|
+
.map((row) => {
|
|
152
|
+
const slug = (row as { slug?: unknown } | null)?.slug
|
|
153
|
+
return typeof slug === 'string' ? slug : null
|
|
154
|
+
})
|
|
155
|
+
.filter((slug): slug is string => slug !== null)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A bare "not found" is useless when the answer is one flag away. */
|
|
159
|
+
async function askWhichProject(io: IngestIo, args: { url?: string; token?: string }): Promise<number> {
|
|
160
|
+
const available = await projectSlugs(args)
|
|
161
|
+
io.err('Pick one of:')
|
|
162
|
+
if (available.length === 0) io.err(' (no projects yet, so write one memory first)')
|
|
163
|
+
for (const slug of available) io.err(` ${slug}`)
|
|
164
|
+
io.err('then pass --project <name>')
|
|
165
|
+
return 1
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function runSearch(args: SearchArgs, io: IngestIo): Promise<number> {
|
|
169
|
+
if (args.help) {
|
|
170
|
+
io.out(USAGE)
|
|
171
|
+
return 0
|
|
172
|
+
}
|
|
173
|
+
if (args.query.trim() === '') {
|
|
174
|
+
return failed(io, 'pass a query, for example: mimir search "rotate the keys"')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const response = await callRoute('/api/v1/search', {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
body: {
|
|
180
|
+
query: args.query,
|
|
181
|
+
...(args.project ? { project: args.project } : {}),
|
|
182
|
+
...(args.limit ? { limit: args.limit } : {}),
|
|
183
|
+
},
|
|
184
|
+
url: args.url,
|
|
185
|
+
token: args.token,
|
|
186
|
+
})
|
|
187
|
+
if (!response.ok) return failed(io, response.error)
|
|
188
|
+
|
|
189
|
+
let hits: Array<{ path?: unknown; title?: unknown; projectSlug?: unknown }>
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(response.body) as { results?: unknown }
|
|
192
|
+
hits = Array.isArray(parsed.results) ? (parsed.results as typeof hits) : []
|
|
193
|
+
} catch {
|
|
194
|
+
return failed(io, 'mimir answered something that was not search results')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (hits.length === 0) {
|
|
198
|
+
io.out('Nothing matches that in Mimir.')
|
|
199
|
+
return 0
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const hit of hits) {
|
|
203
|
+
const path = typeof hit.path === 'string' ? hit.path : ''
|
|
204
|
+
const title = typeof hit.title === 'string' ? hit.title : ''
|
|
205
|
+
// Without --project the same path can exist in several projects, so the
|
|
206
|
+
// line has to say which one it came from.
|
|
207
|
+
const where = args.project
|
|
208
|
+
? path
|
|
209
|
+
: `${typeof hit.projectSlug === 'string' ? hit.projectSlug : '?'}: ${path}`
|
|
210
|
+
io.out(title ? `${where} - ${title}` : where)
|
|
211
|
+
}
|
|
212
|
+
return 0
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function runExport(args: ExportArgs, io: IngestIo): Promise<number> {
|
|
216
|
+
if (args.help) {
|
|
217
|
+
io.out(USAGE)
|
|
218
|
+
return 0
|
|
219
|
+
}
|
|
220
|
+
if (!args.project) {
|
|
221
|
+
io.err('export needs a project: the file is named after it')
|
|
222
|
+
return askWhichProject(io, args)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const response = await callRoute(`/api/v1/projects/${encodeURIComponent(args.project)}/export`, {
|
|
226
|
+
url: args.url,
|
|
227
|
+
token: args.token,
|
|
228
|
+
})
|
|
229
|
+
if (!response.ok) return failed(io, response.error)
|
|
230
|
+
|
|
231
|
+
const target = args.out ?? `${args.project}.md`
|
|
232
|
+
try {
|
|
233
|
+
await writeFile(resolve(io.cwd, target), response.body, 'utf8')
|
|
234
|
+
} catch (error) {
|
|
235
|
+
return failed(io, (error as Error).message)
|
|
236
|
+
}
|
|
237
|
+
io.out(`${Buffer.byteLength(response.body, 'utf8')} bytes written to ${target}`)
|
|
238
|
+
return 0
|
|
239
|
+
}
|
|
240
|
+
|
|
137
241
|
export async function main(argv: string[]): Promise<void> {
|
|
138
|
-
let args:
|
|
242
|
+
let args: Args
|
|
139
243
|
try {
|
|
140
244
|
args = parseArgs(argv)
|
|
141
245
|
} catch (error) {
|
|
@@ -144,9 +248,14 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
144
248
|
process.exitCode = 2
|
|
145
249
|
return
|
|
146
250
|
}
|
|
147
|
-
|
|
251
|
+
|
|
252
|
+
const io: IngestIo = {
|
|
148
253
|
out: (line) => console.log(line),
|
|
149
254
|
err: (line) => console.error(line),
|
|
150
255
|
cwd: process.cwd(),
|
|
151
|
-
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (args.command === 'search') process.exitCode = await runSearch(args, io)
|
|
259
|
+
else if (args.command === 'export') process.exitCode = await runExport(args, io)
|
|
260
|
+
else process.exitCode = await runIngest(args as IngestArgs, io)
|
|
152
261
|
}
|
package/src/client.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process'
|
|
2
|
+
function missingConfig(url: string, token: string): string {
|
|
3
|
+
const missing = [url ? null : 'MIMIR_URL', token ? null : 'MIMIR_TOKEN'].filter(Boolean)
|
|
4
|
+
return `set ${missing.join(' and ')}`
|
|
5
|
+
}
|
|
6
|
+
|
|
2
7
|
export type CallResult = { ok: true; result: unknown } | { ok: false; error: string }
|
|
3
8
|
|
|
9
|
+
export type RouteResult = { ok: true; body: string } | { ok: false; error: string }
|
|
10
|
+
|
|
4
11
|
export type CallOptions = {
|
|
5
12
|
url?: string
|
|
6
13
|
token?: string
|
|
@@ -19,7 +26,7 @@ export async function callTool(
|
|
|
19
26
|
): Promise<CallResult> {
|
|
20
27
|
const url = (options.url ?? process.env.MIMIR_URL ?? '').replace(/\/+$/, '')
|
|
21
28
|
const token = options.token ?? process.env.MIMIR_TOKEN ?? ''
|
|
22
|
-
if (!url || !token) return { ok: false, error:
|
|
29
|
+
if (!url || !token) return { ok: false, error: missingConfig(url, token) }
|
|
23
30
|
|
|
24
31
|
try {
|
|
25
32
|
const response = await (options.fetch ?? fetch)(`${url}/mcp`, {
|
|
@@ -74,3 +81,45 @@ export function detectRepo(directory: string): string | null {
|
|
|
74
81
|
return null
|
|
75
82
|
}
|
|
76
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Calls one of the REST routes, for the CLI commands that need a whole file
|
|
87
|
+
* rather than a tool result. Same fail-soft contract as callTool.
|
|
88
|
+
*/
|
|
89
|
+
export async function callRoute(
|
|
90
|
+
path: string,
|
|
91
|
+
options: CallOptions & { method?: 'GET' | 'POST'; body?: unknown } = {},
|
|
92
|
+
): Promise<RouteResult> {
|
|
93
|
+
const url = (options.url ?? process.env.MIMIR_URL ?? '').replace(/\/+$/, '')
|
|
94
|
+
const token = options.token ?? process.env.MIMIR_TOKEN ?? ''
|
|
95
|
+
if (!url || !token) return { ok: false, error: missingConfig(url, token) }
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const response = await (options.fetch ?? fetch)(`${url}${path}`, {
|
|
99
|
+
method: options.method ?? 'GET',
|
|
100
|
+
headers: {
|
|
101
|
+
...(options.body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
102
|
+
authorization: `Bearer ${token}`,
|
|
103
|
+
},
|
|
104
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
105
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const text = await response.text()
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
// The API answers { error, code }; a proxy may answer HTML. Prefer the
|
|
111
|
+
// sentence when there is one, never a stack trace.
|
|
112
|
+
let message = `mimir responded ${response.status}`
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(text) as { error?: unknown }
|
|
115
|
+
if (typeof parsed.error === 'string') message = parsed.error
|
|
116
|
+
} catch {
|
|
117
|
+
// keep the status line
|
|
118
|
+
}
|
|
119
|
+
return { ok: false, error: message }
|
|
120
|
+
}
|
|
121
|
+
return { ok: true, body: text }
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return { ok: false, error: error instanceof Error ? error.message : 'request failed' }
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -3,7 +3,9 @@ import type { MimirConfig } from './config.js'
|
|
|
3
3
|
|
|
4
4
|
export type Registration = { dispose: () => Promise<void> }
|
|
5
5
|
|
|
6
|
-
type
|
|
6
|
+
type ToolInput = Record<string, unknown>
|
|
7
|
+
type ToolContext = { sessionID?: string } | undefined
|
|
8
|
+
type ToolExecutor = (input: unknown, context: ToolContext) => Promise<{ content: string }>
|
|
7
9
|
type ToolDefinition = {
|
|
8
10
|
name: string
|
|
9
11
|
description: string
|
|
@@ -37,6 +39,7 @@ export async function registerTools(
|
|
|
37
39
|
editor.add(digestTool(repo))
|
|
38
40
|
editor.add(rememberTool(repo))
|
|
39
41
|
editor.add(searchTool(repo))
|
|
42
|
+
editor.add(readTool(repo))
|
|
40
43
|
editor.add(learnTool(repo))
|
|
41
44
|
})
|
|
42
45
|
}
|
|
@@ -84,9 +87,10 @@ function digestTool(repo: string | null): ToolDefinition {
|
|
|
84
87
|
additionalProperties: false,
|
|
85
88
|
},
|
|
86
89
|
async execute(input) {
|
|
90
|
+
const args = input as ToolInput
|
|
87
91
|
const result = await callTool(
|
|
88
92
|
'memory_digest',
|
|
89
|
-
{ budget_tokens:
|
|
93
|
+
{ budget_tokens: Number(args.budget_tokens ?? 800), repo: repo ?? undefined },
|
|
90
94
|
{ timeoutMs: 5000 },
|
|
91
95
|
)
|
|
92
96
|
if (!result.ok) return { content: `Mimir is unavailable (${result.error}). Carry on without it.` }
|
|
@@ -96,6 +100,10 @@ function digestTool(repo: string | null): ToolDefinition {
|
|
|
96
100
|
}
|
|
97
101
|
}
|
|
98
102
|
|
|
103
|
+
// A project created from a repo a teammate already knows about splits the
|
|
104
|
+
// knowledge in two until someone asks the owner for a grant.
|
|
105
|
+
const NEW_PROJECT = ' Started a new project. If a teammate already uses this repository, request access at /app/requests on your Mimir install.'
|
|
106
|
+
|
|
99
107
|
function rememberTool(repo: string | null): ToolDefinition {
|
|
100
108
|
return {
|
|
101
109
|
name: 'mimir_remember',
|
|
@@ -106,6 +114,7 @@ function rememberTool(repo: string | null): ToolDefinition {
|
|
|
106
114
|
properties: {
|
|
107
115
|
path: { type: 'string', description: 'Slash-separated memory path, e.g. deploy/rollback' },
|
|
108
116
|
body: { type: 'string', description: 'What to remember, written so it reads without the conversation' },
|
|
117
|
+
title: { type: 'string', description: 'One line. Defaults to the first line of the body.' },
|
|
109
118
|
kind: {
|
|
110
119
|
type: 'string',
|
|
111
120
|
enum: ['note', 'fact', 'decision', 'gotcha', 'procedure', 'snippet', 'import'],
|
|
@@ -116,12 +125,14 @@ function rememberTool(repo: string | null): ToolDefinition {
|
|
|
116
125
|
},
|
|
117
126
|
// `client` and `session` are provenance, so history can say who wrote it.
|
|
118
127
|
async execute(input, context) {
|
|
128
|
+
const args = input as ToolInput
|
|
119
129
|
const result = await callTool(
|
|
120
130
|
'memory_write',
|
|
121
131
|
{
|
|
122
|
-
path:
|
|
123
|
-
body:
|
|
124
|
-
|
|
132
|
+
path: String(args.path ?? ''),
|
|
133
|
+
body: String(args.body ?? ''),
|
|
134
|
+
title: typeof args.title === 'string' ? args.title : undefined,
|
|
135
|
+
kind: typeof args.kind === 'string' ? args.kind : undefined,
|
|
125
136
|
repo: repo ?? undefined,
|
|
126
137
|
client: 'opencode',
|
|
127
138
|
session: context?.sessionID,
|
|
@@ -129,8 +140,11 @@ function rememberTool(repo: string | null): ToolDefinition {
|
|
|
129
140
|
{ timeoutMs: 8000 },
|
|
130
141
|
)
|
|
131
142
|
if (!result.ok) return { content: `Could not store that (${result.error}).` }
|
|
132
|
-
const written = result.result as { path?: string; created?: boolean }
|
|
133
|
-
|
|
143
|
+
const written = result.result as { path?: string; created?: boolean; projectCreated?: boolean }
|
|
144
|
+
const note = written.projectCreated ? NEW_PROJECT : ''
|
|
145
|
+
return {
|
|
146
|
+
content: `${written.created ? 'Stored' : 'Updated'} ${written.path ?? String(args.path ?? '')}.${note}`,
|
|
147
|
+
}
|
|
134
148
|
},
|
|
135
149
|
}
|
|
136
150
|
}
|
|
@@ -150,9 +164,10 @@ function searchTool(repo: string | null): ToolDefinition {
|
|
|
150
164
|
additionalProperties: false,
|
|
151
165
|
},
|
|
152
166
|
async execute(input) {
|
|
167
|
+
const args = input as ToolInput
|
|
153
168
|
const result = await callTool(
|
|
154
169
|
'memory_search',
|
|
155
|
-
{ query:
|
|
170
|
+
{ query: typeof args.query === 'string' ? args.query : '', path_prefix: typeof args.path_prefix === 'string' ? args.path_prefix : undefined, repo: repo ?? undefined },
|
|
156
171
|
{ timeoutMs: 8000 },
|
|
157
172
|
)
|
|
158
173
|
if (!result.ok) return { content: `Mimir is unavailable (${result.error}). Carry on without it.` }
|
|
@@ -167,6 +182,37 @@ function searchTool(repo: string | null): ToolDefinition {
|
|
|
167
182
|
}
|
|
168
183
|
}
|
|
169
184
|
|
|
185
|
+
/** The date is what tells a reader whether the body still holds. */
|
|
186
|
+
function renderMemory(memory: { path?: string; title?: string; body?: string; updatedAt?: string }): string {
|
|
187
|
+
const day = typeof memory.updatedAt === 'string' ? memory.updatedAt.slice(0, 10) : ''
|
|
188
|
+
const head = day ? `${memory.path ?? ''} · updated ${day}` : (memory.path ?? '')
|
|
189
|
+
return `${head}\n# ${memory.title ?? ''}\n\n${memory.body ?? ''}`
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function readTool(repo: string | null): ToolDefinition {
|
|
193
|
+
return {
|
|
194
|
+
name: 'mimir_read',
|
|
195
|
+
description:
|
|
196
|
+
'Read one memory in full by its path. Search returns paths and summaries; this is the body behind one.',
|
|
197
|
+
input: {
|
|
198
|
+
type: 'object',
|
|
199
|
+
properties: { path: { type: 'string', description: 'e.g. deploy/rollback' } },
|
|
200
|
+
required: ['path'],
|
|
201
|
+
additionalProperties: false,
|
|
202
|
+
},
|
|
203
|
+
async execute(input) {
|
|
204
|
+
const args = input as ToolInput
|
|
205
|
+
const result = await callTool(
|
|
206
|
+
'memory_read',
|
|
207
|
+
{ path: String(args.path ?? ''), repo: repo ?? undefined },
|
|
208
|
+
{ timeoutMs: 8000 },
|
|
209
|
+
)
|
|
210
|
+
if (!result.ok) return { content: 'Nothing is stored at that path.' }
|
|
211
|
+
return { content: renderMemory(result.result as { path?: string; title?: string; body?: string; updatedAt?: string }) }
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
170
216
|
function learnTool(repo: string | null): ToolDefinition {
|
|
171
217
|
return {
|
|
172
218
|
name: 'mimir_learn',
|
|
@@ -182,11 +228,12 @@ function learnTool(repo: string | null): ToolDefinition {
|
|
|
182
228
|
additionalProperties: false,
|
|
183
229
|
},
|
|
184
230
|
async execute(input, context) {
|
|
231
|
+
const args = input as ToolInput
|
|
185
232
|
const result = await callTool(
|
|
186
233
|
'memory_learn',
|
|
187
234
|
{
|
|
188
|
-
transcript:
|
|
189
|
-
hint:
|
|
235
|
+
transcript: String(args.transcript ?? ''),
|
|
236
|
+
hint: typeof args.hint === 'string' ? args.hint : undefined,
|
|
190
237
|
repo: repo ?? undefined,
|
|
191
238
|
client: 'opencode',
|
|
192
239
|
session: context?.sessionID,
|
|
@@ -194,11 +241,16 @@ function learnTool(repo: string | null): ToolDefinition {
|
|
|
194
241
|
{ timeoutMs: 8000 },
|
|
195
242
|
)
|
|
196
243
|
if (!result.ok) return { content: `Could not hand that over (${result.error}).` }
|
|
197
|
-
const queued = result.result as { queued?: boolean }
|
|
244
|
+
const queued = result.result as { queued?: boolean; reason?: string; projectCreated?: boolean }
|
|
245
|
+
if (queued.queued) {
|
|
246
|
+
return {
|
|
247
|
+
content: `Queued. The model writes the memories in the background.${queued.projectCreated ? NEW_PROJECT : ''}`,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// A refusal is a fact the agent should act on, not a failure to retry.
|
|
198
251
|
return {
|
|
199
|
-
content:
|
|
200
|
-
?
|
|
201
|
-
: 'Mimir took no job: its queue is not running.',
|
|
252
|
+
content:
|
|
253
|
+
typeof queued.reason === 'string' ? queued.reason : 'Mimir took no job: its queue is not running.',
|
|
202
254
|
}
|
|
203
255
|
},
|
|
204
256
|
}
|