@mojoaar/opencode-mimir 0.25.0 → 0.29.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/dist/args.d.ts CHANGED
@@ -8,5 +8,23 @@ export type IngestArgs = {
8
8
  dryRun: boolean;
9
9
  help: boolean;
10
10
  };
11
- /** Hand-rolled on purpose: three value flags do not justify a dependency. */
12
- export declare function parseArgs(argv: string[]): IngestArgs;
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: three value flags do not justify a dependency. */
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 args = {
5
- command: 'ingest',
6
- path: '.',
7
- all: false,
8
- dryRun: false,
9
- help: argv.length === 0,
10
- };
11
- for (let i = 0; i < argv.length; i++) {
12
- const token = argv[i];
13
- if (token === 'ingest' || token === 'import')
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 (token === '-h' || token === '--help') {
16
- args.help = true;
21
+ if (arg === '-h' || arg === '--help') {
22
+ help = true;
17
23
  continue;
18
24
  }
19
- if (token === '--all') {
20
- args.all = true;
25
+ if (arg === '--all') {
26
+ all = true;
21
27
  continue;
22
28
  }
23
- if (token === '--dry-run') {
24
- args.dryRun = true;
29
+ if (arg === '--dry-run') {
30
+ dryRun = true;
25
31
  continue;
26
32
  }
27
- if (token.startsWith('--')) {
28
- const body = token.slice(2);
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 ? argv[++i] : body.slice(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
- args.project = value;
43
+ project = value;
38
44
  if (name === 'url')
39
- args.url = value;
45
+ url = value;
40
46
  if (name === 'token')
41
- args.token = value;
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
- args.path = token;
58
+ if (command === 'search')
59
+ query = arg;
60
+ else if (command === 'ingest')
61
+ path = arg;
45
62
  }
46
- return args;
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 ingest — load a project's documentation into 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; also keeps two folders apart.
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
- process.exitCode = await runIngest(args, {
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: 'MIMIR_URL and MIMIR_TOKEN must be set' };
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/index.js CHANGED
@@ -113,7 +113,13 @@ export default Plugin.define({
113
113
  if (text.length < MIN_TRANSCRIPT)
114
114
  return;
115
115
  compacted.add(event.sessionID);
116
- const sent = await callTool('memory_learn', { repo: repo ?? undefined, transcript: text, hint: LEARN_HINT }, { ...config, timeoutMs: 4000 }).catch(() => null);
116
+ const sent = await callTool('memory_learn', {
117
+ repo: repo ?? undefined,
118
+ transcript: text,
119
+ hint: LEARN_HINT,
120
+ client: 'opencode',
121
+ session: event.sessionID,
122
+ }, { ...config, timeoutMs: 4000 }).catch(() => null);
117
123
  // Nothing is written to event.system here: this request's prompt becomes
118
124
  // the summary that replaces the conversation. A failure gives the session
119
125
  // its one chance back, so an outage does not use the compaction up.
@@ -129,7 +135,13 @@ export default Plugin.define({
129
135
  const text = snapshots.get(sessionID) ?? '';
130
136
  let notice = NOTHING_TO_SEND;
131
137
  if (text.length >= MIN_TRANSCRIPT) {
132
- const sent = await callTool('memory_learn', { repo: repo ?? undefined, transcript: text, hint: LEARN_NOW_HINT }, { ...config, timeoutMs: 4000 }).catch(() => null);
138
+ const sent = await callTool('memory_learn', {
139
+ repo: repo ?? undefined,
140
+ transcript: text,
141
+ hint: LEARN_NOW_HINT,
142
+ client: 'opencode',
143
+ session: sessionID,
144
+ }, { ...config, timeoutMs: 4000 }).catch(() => null);
133
145
  const queued = sent?.ok ? sent.result.queued : undefined;
134
146
  if (!sent?.ok)
135
147
  notice = UNREACHABLE;
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 ToolExecutor = (input: any, context: any) => Promise<{
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 result = await callTool('memory_digest', { budget_tokens: input?.budget_tokens ?? 800, repo: repo ?? undefined }, { timeoutMs: 5000 });
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'],
@@ -80,12 +86,25 @@ function rememberTool(repo) {
80
86
  required: ['path', 'body'],
81
87
  additionalProperties: false,
82
88
  },
83
- async execute(input) {
84
- const result = await callTool('memory_write', { path: input.path, body: input.body, kind: input.kind, repo: repo ?? undefined }, { timeoutMs: 8000 });
89
+ // `client` and `session` are provenance, so history can say who wrote it.
90
+ async execute(input, context) {
91
+ const args = input;
92
+ const result = await callTool('memory_write', {
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,
97
+ repo: repo ?? undefined,
98
+ client: 'opencode',
99
+ session: context?.sessionID,
100
+ }, { timeoutMs: 8000 });
85
101
  if (!result.ok)
86
102
  return { content: `Could not store that (${result.error}).` };
87
103
  const written = result.result;
88
- return { content: `${written.created ? 'Stored' : 'Updated'} ${written.path ?? input.path}.` };
104
+ const note = written.projectCreated ? NEW_PROJECT : '';
105
+ return {
106
+ content: `${written.created ? 'Stored' : 'Updated'} ${written.path ?? String(args.path ?? '')}.${note}`,
107
+ };
89
108
  },
90
109
  };
91
110
  }
@@ -103,7 +122,8 @@ function searchTool(repo) {
103
122
  additionalProperties: false,
104
123
  },
105
124
  async execute(input) {
106
- const result = await callTool('memory_search', { query: input?.query, path_prefix: input?.path_prefix, repo: repo ?? undefined }, { timeoutMs: 8000 });
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 });
107
127
  if (!result.ok)
108
128
  return { content: `Mimir is unavailable (${result.error}). Carry on without it.` };
109
129
  const hits = result.result;
@@ -117,6 +137,31 @@ function searchTool(repo) {
117
137
  },
118
138
  };
119
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
+ }
120
165
  function learnTool(repo) {
121
166
  return {
122
167
  name: 'mimir_learn',
@@ -130,15 +175,26 @@ function learnTool(repo) {
130
175
  required: ['transcript'],
131
176
  additionalProperties: false,
132
177
  },
133
- async execute(input) {
134
- const result = await callTool('memory_learn', { transcript: input?.transcript, hint: input?.hint, repo: repo ?? undefined }, { timeoutMs: 8000 });
178
+ async execute(input, context) {
179
+ const args = input;
180
+ const result = await callTool('memory_learn', {
181
+ transcript: String(args.transcript ?? ''),
182
+ hint: typeof args.hint === 'string' ? args.hint : undefined,
183
+ repo: repo ?? undefined,
184
+ client: 'opencode',
185
+ session: context?.sessionID,
186
+ }, { timeoutMs: 8000 });
135
187
  if (!result.ok)
136
188
  return { content: `Could not hand that over (${result.error}).` };
137
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.
138
196
  return {
139
- content: queued.queued
140
- ? 'Queued. The model writes the memories in the background.'
141
- : '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.',
142
198
  };
143
199
  },
144
200
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mojoaar/opencode-mimir",
3
- "version": "0.25.0",
3
+ "version": "0.29.0",
4
4
  "description": "Mimir long-term memory for OpenCode sessions",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/args.ts CHANGED
@@ -9,47 +9,85 @@ export type IngestArgs = {
9
9
  help: boolean
10
10
  }
11
11
 
12
- const VALUE_FLAGS = new Set(['project', 'url', 'token'])
13
-
14
- /** Hand-rolled on purpose: three value flags do not justify a dependency. */
15
- export function parseArgs(argv: string[]): IngestArgs {
16
- const args: IngestArgs = {
17
- command: 'ingest',
18
- path: '.',
19
- all: false,
20
- dryRun: false,
21
- help: argv.length === 0,
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 < argv.length; i++) {
25
- const token = argv[i] as string
26
- if (token === 'ingest' || token === 'import') continue
27
- if (token === '-h' || token === '--help') {
28
- args.help = true
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 (token === '--all') {
32
- args.all = true
60
+ if (arg === '--all') {
61
+ all = true
33
62
  continue
34
63
  }
35
- if (token === '--dry-run') {
36
- args.dryRun = true
64
+ if (arg === '--dry-run') {
65
+ dryRun = true
37
66
  continue
38
67
  }
39
- if (token.startsWith('--')) {
40
- const body = token.slice(2)
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 ? argv[++i] : body.slice(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') args.project = value
47
- if (name === 'url') args.url = value
48
- if (name === 'token') args.token = value
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
- args.path = token
86
+ if (command === 'search') query = arg
87
+ else if (command === 'ingest') path = arg
52
88
  }
53
89
 
54
- return args
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 ingest — load a project's documentation into 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; also keeps two folders apart.
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: IngestArgs
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
- process.exitCode = await runIngest(args, {
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: 'MIMIR_URL and MIMIR_TOKEN must be set' }
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/index.ts CHANGED
@@ -124,7 +124,13 @@ export default Plugin.define({
124
124
 
125
125
  const sent = await callTool(
126
126
  'memory_learn',
127
- { repo: repo ?? undefined, transcript: text, hint: LEARN_HINT },
127
+ {
128
+ repo: repo ?? undefined,
129
+ transcript: text,
130
+ hint: LEARN_HINT,
131
+ client: 'opencode',
132
+ session: event.sessionID,
133
+ },
128
134
  { ...config, timeoutMs: 4000 },
129
135
  ).catch(() => null)
130
136
 
@@ -145,7 +151,13 @@ export default Plugin.define({
145
151
  if (text.length >= MIN_TRANSCRIPT) {
146
152
  const sent = await callTool(
147
153
  'memory_learn',
148
- { repo: repo ?? undefined, transcript: text, hint: LEARN_NOW_HINT },
154
+ {
155
+ repo: repo ?? undefined,
156
+ transcript: text,
157
+ hint: LEARN_NOW_HINT,
158
+ client: 'opencode',
159
+ session: sessionID,
160
+ },
149
161
  { ...config, timeoutMs: 4000 },
150
162
  ).catch(() => null)
151
163
  const queued = sent?.ok ? (sent.result as { queued?: boolean }).queued : undefined
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 ToolExecutor = (input: any, context: any) => Promise<{ content: string }>
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: input?.budget_tokens ?? 800, repo: repo ?? undefined },
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'],
@@ -114,15 +123,28 @@ function rememberTool(repo: string | null): ToolDefinition {
114
123
  required: ['path', 'body'],
115
124
  additionalProperties: false,
116
125
  },
117
- async execute(input) {
126
+ // `client` and `session` are provenance, so history can say who wrote it.
127
+ async execute(input, context) {
128
+ const args = input as ToolInput
118
129
  const result = await callTool(
119
130
  'memory_write',
120
- { path: input.path, body: input.body, kind: input.kind, repo: repo ?? undefined },
131
+ {
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,
136
+ repo: repo ?? undefined,
137
+ client: 'opencode',
138
+ session: context?.sessionID,
139
+ },
121
140
  { timeoutMs: 8000 },
122
141
  )
123
142
  if (!result.ok) return { content: `Could not store that (${result.error}).` }
124
- const written = result.result as { path?: string; created?: boolean }
125
- return { content: `${written.created ? 'Stored' : 'Updated'} ${written.path ?? input.path}.` }
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
+ }
126
148
  },
127
149
  }
128
150
  }
@@ -142,9 +164,10 @@ function searchTool(repo: string | null): ToolDefinition {
142
164
  additionalProperties: false,
143
165
  },
144
166
  async execute(input) {
167
+ const args = input as ToolInput
145
168
  const result = await callTool(
146
169
  'memory_search',
147
- { query: input?.query, path_prefix: input?.path_prefix, repo: repo ?? undefined },
170
+ { query: typeof args.query === 'string' ? args.query : '', path_prefix: typeof args.path_prefix === 'string' ? args.path_prefix : undefined, repo: repo ?? undefined },
148
171
  { timeoutMs: 8000 },
149
172
  )
150
173
  if (!result.ok) return { content: `Mimir is unavailable (${result.error}). Carry on without it.` }
@@ -159,6 +182,37 @@ function searchTool(repo: string | null): ToolDefinition {
159
182
  }
160
183
  }
161
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
+
162
216
  function learnTool(repo: string | null): ToolDefinition {
163
217
  return {
164
218
  name: 'mimir_learn',
@@ -173,18 +227,30 @@ function learnTool(repo: string | null): ToolDefinition {
173
227
  required: ['transcript'],
174
228
  additionalProperties: false,
175
229
  },
176
- async execute(input) {
230
+ async execute(input, context) {
231
+ const args = input as ToolInput
177
232
  const result = await callTool(
178
233
  'memory_learn',
179
- { transcript: input?.transcript, hint: input?.hint, repo: repo ?? undefined },
234
+ {
235
+ transcript: String(args.transcript ?? ''),
236
+ hint: typeof args.hint === 'string' ? args.hint : undefined,
237
+ repo: repo ?? undefined,
238
+ client: 'opencode',
239
+ session: context?.sessionID,
240
+ },
180
241
  { timeoutMs: 8000 },
181
242
  )
182
243
  if (!result.ok) return { content: `Could not hand that over (${result.error}).` }
183
- 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.
184
251
  return {
185
- content: queued.queued
186
- ? 'Queued. The model writes the memories in the background.'
187
- : '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.',
188
254
  }
189
255
  },
190
256
  }