@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 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/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'],
@@ -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: input.path,
87
- body: input.body,
88
- kind: input.kind,
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
- 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
+ };
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 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 });
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: input?.transcript,
144
- hint: input?.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mojoaar/opencode-mimir",
3
- "version": "0.26.0",
3
+ "version": "0.29.1",
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/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'],
@@ -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: input.path,
123
- body: input.body,
124
- kind: input.kind,
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
- 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
+ }
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: 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 },
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: input?.transcript,
189
- hint: input?.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: queued.queued
200
- ? 'Queued. The model writes the memories in the background.'
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
  }