@mojoaar/opencode-mimir 0.0.0-stage → 0.15.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/README.md +86 -2
- package/dist/args.d.ts +12 -0
- package/dist/args.js +47 -0
- package/dist/batch.d.ts +12 -0
- package/dist/batch.js +16 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +7 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +127 -0
- package/dist/client.d.ts +20 -0
- package/dist/client.js +62 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +33 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +44 -0
- package/dist/select.d.ts +25 -0
- package/dist/select.js +100 -0
- package/dist/tools.d.ts +36 -0
- package/dist/tools.js +86 -0
- package/package.json +34 -4
- package/src/args.ts +55 -0
- package/src/batch.ts +29 -0
- package/src/bin.ts +8 -0
- package/src/cli.ts +152 -0
- package/src/client.ts +76 -0
- package/src/config.ts +46 -0
- package/src/index.ts +53 -0
- package/src/select.ts +113 -0
- package/src/tools.ts +123 -0
package/README.md
CHANGED
|
@@ -1,3 +1,87 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mojoaar/opencode-mimir
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Mimir memory for [OpenCode](https://opencode.ai) v2: two tools and an automatic
|
|
4
|
+
digest, no ceremony.
|
|
5
|
+
|
|
6
|
+
Install straight from the repository this package lives in:
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
opencode plugin add 'github:mojoaar/mimir#main::path:packages/opencode-plugin'
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or point at it in `opencode.json` (or `~/.config/opencode/opencode.jsonc`):
|
|
13
|
+
|
|
14
|
+
```jsonc
|
|
15
|
+
{
|
|
16
|
+
"plugins": ["github:mojoaar/mimir#main::path:packages/opencode-plugin"]
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The options form is the same entry with your settings attached:
|
|
21
|
+
|
|
22
|
+
```jsonc
|
|
23
|
+
{
|
|
24
|
+
"plugins": [
|
|
25
|
+
{
|
|
26
|
+
"package": "github:mojoaar/mimir#main::path:packages/opencode-plugin",
|
|
27
|
+
"options": {
|
|
28
|
+
"url": "https://mimir.example.com",
|
|
29
|
+
"tokenFile": "~/.config/opencode/.secrets/mimir-token",
|
|
30
|
+
"tools": "auto"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`url`, `token` and `tokenFile` fall back to `MIMIR_URL` / `MIMIR_TOKEN`, and
|
|
38
|
+
`MIMIR_REPO` overrides the repository read from git. The token file is the same
|
|
39
|
+
one the MCP header can use.
|
|
40
|
+
|
|
41
|
+
## What you get
|
|
42
|
+
|
|
43
|
+
| Tool | What it does |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `mimir_digest` | Returns what this project already knows: paths, titles, and a summary of the newest memories. |
|
|
46
|
+
| `mimir_remember` | Stores one durable fact at a path like `deploy/rollback`. Writing the same path again replaces it. |
|
|
47
|
+
|
|
48
|
+
`tools` decides whether they are added: `auto` (default) leaves them out when an
|
|
49
|
+
MCP server already points at the same `/mcp` endpoint, `always` adds them
|
|
50
|
+
anyway, `never` adds none. With only the plugin installed, `auto` gives you
|
|
51
|
+
both.
|
|
52
|
+
|
|
53
|
+
Both tools fail soft: if Mimir is down or unconfigured, the tool says so and the
|
|
54
|
+
session carries on.
|
|
55
|
+
|
|
56
|
+
## The digest arrives on its own
|
|
57
|
+
|
|
58
|
+
The plugin registers a `context` hook that puts the project's digest at the top
|
|
59
|
+
of the system prompt before the first turn, once per session. It asks for 800
|
|
60
|
+
tokens with a 1.5 second timeout, and does nothing at all when Mimir is
|
|
61
|
+
unreachable, slow, or unconfigured — a session never waits on memory.
|
|
62
|
+
|
|
63
|
+
## Import an existing project
|
|
64
|
+
|
|
65
|
+
Until the package is published, run the CLI from a checkout:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
node packages/opencode-plugin/dist/bin.js ingest .
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Walks the folder and loads its **documentation and configuration** — READMEs,
|
|
72
|
+
`docs/`, markdown, compose files, `package.json` — under the `import/` path
|
|
73
|
+
prefix. Source code is skipped by default; the agent already has it.
|
|
74
|
+
|
|
75
|
+
| Flag | What it does |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `--project <name>` | Name the project yourself. Required for a folder that is not a git repository. |
|
|
78
|
+
| `--all` | Import every text file, not just documentation. |
|
|
79
|
+
| `--dry-run` | Print what would be sent, send nothing. |
|
|
80
|
+
| `--url`, `--token` | Override `MIMIR_URL` / `MIMIR_TOKEN`. |
|
|
81
|
+
|
|
82
|
+
In a git repository the import lands in the project your agents already write
|
|
83
|
+
to (the normalised remote). With `--project` it lands in a project keyed by
|
|
84
|
+
name, so the same folder on another machine is a different project.
|
|
85
|
+
|
|
86
|
+
Exit codes: `0` everything imported, `1` something was skipped or a batch
|
|
87
|
+
failed, `2` bad arguments or no such directory.
|
package/dist/args.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type IngestArgs = {
|
|
2
|
+
command: 'ingest';
|
|
3
|
+
path: string;
|
|
4
|
+
project?: string;
|
|
5
|
+
url?: string;
|
|
6
|
+
token?: string;
|
|
7
|
+
all: boolean;
|
|
8
|
+
dryRun: boolean;
|
|
9
|
+
help: boolean;
|
|
10
|
+
};
|
|
11
|
+
/** Hand-rolled on purpose: three value flags do not justify a dependency. */
|
|
12
|
+
export declare function parseArgs(argv: string[]): IngestArgs;
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const VALUE_FLAGS = new Set(['project', 'url', 'token']);
|
|
2
|
+
/** Hand-rolled on purpose: three value flags do not justify a dependency. */
|
|
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')
|
|
14
|
+
continue;
|
|
15
|
+
if (token === '-h' || token === '--help') {
|
|
16
|
+
args.help = true;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (token === '--all') {
|
|
20
|
+
args.all = true;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (token === '--dry-run') {
|
|
24
|
+
args.dryRun = true;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (token.startsWith('--')) {
|
|
28
|
+
const body = token.slice(2);
|
|
29
|
+
const equals = body.indexOf('=');
|
|
30
|
+
const name = equals === -1 ? body : body.slice(0, equals);
|
|
31
|
+
if (!VALUE_FLAGS.has(name))
|
|
32
|
+
throw new Error(`unknown flag --${name}`);
|
|
33
|
+
const value = equals === -1 ? argv[++i] : body.slice(equals + 1);
|
|
34
|
+
if (value === undefined || value.startsWith('--'))
|
|
35
|
+
throw new Error(`--${name} needs a value`);
|
|
36
|
+
if (name === 'project')
|
|
37
|
+
args.project = value;
|
|
38
|
+
if (name === 'url')
|
|
39
|
+
args.url = value;
|
|
40
|
+
if (name === 'token')
|
|
41
|
+
args.token = value;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
args.path = token;
|
|
45
|
+
}
|
|
46
|
+
return args;
|
|
47
|
+
}
|
package/dist/batch.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batches are what the server will accept in one call: 100 files and 2 MB.
|
|
3
|
+
* Greedy and order-preserving, so a big repository becomes several requests
|
|
4
|
+
* instead of one rejected one.
|
|
5
|
+
*/
|
|
6
|
+
export type Batch<T> = {
|
|
7
|
+
files: T[];
|
|
8
|
+
bytes: number;
|
|
9
|
+
};
|
|
10
|
+
export declare function batchFiles<T extends {
|
|
11
|
+
bytes: number;
|
|
12
|
+
}>(files: T[], maxFiles?: number, maxBytes?: number): Batch<T>[];
|
package/dist/batch.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function batchFiles(files, maxFiles = 100, maxBytes = 2 * 1024 * 1024) {
|
|
2
|
+
const batches = [];
|
|
3
|
+
let current = { files: [], bytes: 0 };
|
|
4
|
+
for (const file of files) {
|
|
5
|
+
const wouldOverflow = current.files.length > 0 && (current.files.length >= maxFiles || current.bytes + file.bytes > maxBytes);
|
|
6
|
+
if (wouldOverflow) {
|
|
7
|
+
batches.push(current);
|
|
8
|
+
current = { files: [], bytes: 0 };
|
|
9
|
+
}
|
|
10
|
+
current.files.push(file);
|
|
11
|
+
current.bytes += file.bytes;
|
|
12
|
+
}
|
|
13
|
+
if (current.files.length > 0)
|
|
14
|
+
batches.push(current);
|
|
15
|
+
return batches;
|
|
16
|
+
}
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The published entrypoint. Kept separate from cli.ts so the command can be
|
|
3
|
+
// imported by tests without running: npm installs a bin as a symlink, so a
|
|
4
|
+
// `process.argv[1]` check here would never match `.bin/mimir` and the command
|
|
5
|
+
// would exit 0 doing nothing.
|
|
6
|
+
import { main } from './cli.js';
|
|
7
|
+
void main(process.argv.slice(2));
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { type IngestArgs } from './args.js';
|
|
3
|
+
export type IngestIo = {
|
|
4
|
+
out: (line: string) => void;
|
|
5
|
+
err: (line: string) => void;
|
|
6
|
+
cwd: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Returns the exit code instead of calling process.exit, so the whole command
|
|
10
|
+
* is testable and a caller can decide what a partial import means.
|
|
11
|
+
*/
|
|
12
|
+
export declare function runIngest(args: IngestArgs, io: IngestIo): Promise<number>;
|
|
13
|
+
export declare function main(argv: string[]): Promise<void>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { parseArgs } from './args.js';
|
|
5
|
+
import { batchFiles } from './batch.js';
|
|
6
|
+
import { callTool, detectRepo } from './client.js';
|
|
7
|
+
import { selectFiles } from './select.js';
|
|
8
|
+
const USAGE = `mimir ingest — load a project's documentation into Mimir
|
|
9
|
+
|
|
10
|
+
mimir ingest [path] [options]
|
|
11
|
+
|
|
12
|
+
--project <name> name the project yourself. Required for a folder that is
|
|
13
|
+
not a git repository; also keeps two folders apart.
|
|
14
|
+
--url <url> Mimir base URL (default: MIMIR_URL)
|
|
15
|
+
--token <token> API key (default: MIMIR_TOKEN)
|
|
16
|
+
--all import every text file, not just documentation
|
|
17
|
+
--dry-run print what would be sent and send nothing
|
|
18
|
+
|
|
19
|
+
A git checkout imports into the project your agents already write to. Imports
|
|
20
|
+
land under import/, where memory_search finds them.`;
|
|
21
|
+
const MAX_BODY_BYTES = 262_144;
|
|
22
|
+
async function isDirectory(path) {
|
|
23
|
+
try {
|
|
24
|
+
return (await stat(path)).isDirectory();
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns the exit code instead of calling process.exit, so the whole command
|
|
32
|
+
* is testable and a caller can decide what a partial import means.
|
|
33
|
+
*/
|
|
34
|
+
export async function runIngest(args, io) {
|
|
35
|
+
if (args.help) {
|
|
36
|
+
io.out(USAGE);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
const root = resolve(io.cwd, args.path);
|
|
40
|
+
if (!(await isDirectory(root))) {
|
|
41
|
+
io.err(`no such directory: ${args.path}`);
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
const repo = args.project ? null : detectRepo(root);
|
|
45
|
+
if (!args.project && !repo) {
|
|
46
|
+
// Falling back to the folder name would merge two different `docs/` folders
|
|
47
|
+
// into one project and let the second silently overwrite the first.
|
|
48
|
+
io.err(`not a git repository: pass --project <name> to name this folder's project`);
|
|
49
|
+
return 2;
|
|
50
|
+
}
|
|
51
|
+
const key = args.project ? { project: args.project } : { repo: repo };
|
|
52
|
+
const label = args.project ?? repo;
|
|
53
|
+
const walk = await selectFiles(root, { all: args.all });
|
|
54
|
+
const skipped = [...walk.skipped];
|
|
55
|
+
// Read before deciding: the server caps the decoded body, and a Latin-1 file
|
|
56
|
+
// can pass the byte check on disk and still exceed it once decoded.
|
|
57
|
+
const prepared = [];
|
|
58
|
+
for (const file of walk.files) {
|
|
59
|
+
try {
|
|
60
|
+
const body = await readFile(join(root, file.path), 'utf8');
|
|
61
|
+
const bytes = Buffer.byteLength(body, 'utf8');
|
|
62
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
63
|
+
skipped.push({ path: file.path, reason: `too large (${bytes} bytes)` });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
prepared.push({ path: file.path, body, bytes });
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
skipped.push({ path: file.path, reason: error.message });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const batches = batchFiles(prepared);
|
|
73
|
+
const totalBytes = prepared.reduce((sum, file) => sum + file.bytes, 0);
|
|
74
|
+
if (args.dryRun) {
|
|
75
|
+
io.out(`would import ${prepared.length} files (${totalBytes} bytes) into ${label} as import/<path>`);
|
|
76
|
+
for (const file of skipped)
|
|
77
|
+
io.err(`skipped ${file.path}: ${file.reason}`);
|
|
78
|
+
if (walk.truncated) {
|
|
79
|
+
io.err(`the folder tree was larger than the walk limit; the list above is incomplete`);
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
let imported = 0;
|
|
85
|
+
let failed = 0;
|
|
86
|
+
for (const [index, batch] of batches.entries()) {
|
|
87
|
+
const result = await callTool('memory_ingest', {
|
|
88
|
+
...key,
|
|
89
|
+
path_prefix: 'import',
|
|
90
|
+
files: batch.files.map((file) => ({ path: file.path, body: file.body })),
|
|
91
|
+
}, { url: args.url, token: args.token, timeoutMs: 60_000 });
|
|
92
|
+
if (!result.ok) {
|
|
93
|
+
failed++;
|
|
94
|
+
io.err(`batch ${index + 1} failed: ${result.error}`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const outcome = result.result;
|
|
98
|
+
imported += outcome.written ?? batch.files.length;
|
|
99
|
+
for (const entry of outcome.skipped ?? []) {
|
|
100
|
+
skipped.push({ path: entry.path, reason: entry.reason });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const file of skipped)
|
|
104
|
+
io.err(`skipped ${file.path}: ${file.reason}`);
|
|
105
|
+
io.out(`imported ${imported} files, skipped ${skipped.length}`);
|
|
106
|
+
if (walk.truncated) {
|
|
107
|
+
io.err(`the folder tree was larger than the walk limit; not everything was considered`);
|
|
108
|
+
}
|
|
109
|
+
return failed > 0 || skipped.length > 0 || walk.truncated ? 1 : 0;
|
|
110
|
+
}
|
|
111
|
+
export async function main(argv) {
|
|
112
|
+
let args;
|
|
113
|
+
try {
|
|
114
|
+
args = parseArgs(argv);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
console.error(error.message);
|
|
118
|
+
console.error(USAGE);
|
|
119
|
+
process.exitCode = 2;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
process.exitCode = await runIngest(args, {
|
|
123
|
+
out: (line) => console.log(line),
|
|
124
|
+
err: (line) => console.error(line),
|
|
125
|
+
cwd: process.cwd(),
|
|
126
|
+
});
|
|
127
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type CallResult = {
|
|
2
|
+
ok: true;
|
|
3
|
+
result: unknown;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
error: string;
|
|
7
|
+
};
|
|
8
|
+
export type CallOptions = {
|
|
9
|
+
url?: string;
|
|
10
|
+
token?: string;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
fetch?: typeof fetch;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Calls one Mimir MCP tool. Never throws: a plugin must not break a session
|
|
16
|
+
* because the memory server is down.
|
|
17
|
+
*/
|
|
18
|
+
export declare function callTool(name: string, args: Record<string, unknown>, options?: CallOptions): Promise<CallResult>;
|
|
19
|
+
/** A repo key for X-Mimir-Repo, from MIMIR_REPO or `git remote get-url origin`. */
|
|
20
|
+
export declare function detectRepo(directory: string): string | null;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
/**
|
|
3
|
+
* Calls one Mimir MCP tool. Never throws: a plugin must not break a session
|
|
4
|
+
* because the memory server is down.
|
|
5
|
+
*/
|
|
6
|
+
export async function callTool(name, args, options = {}) {
|
|
7
|
+
const url = (options.url ?? process.env.MIMIR_URL ?? '').replace(/\/+$/, '');
|
|
8
|
+
const token = options.token ?? process.env.MIMIR_TOKEN ?? '';
|
|
9
|
+
if (!url || !token)
|
|
10
|
+
return { ok: false, error: 'MIMIR_URL and MIMIR_TOKEN must be set' };
|
|
11
|
+
try {
|
|
12
|
+
const response = await (options.fetch ?? fetch)(`${url}/mcp`, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: {
|
|
15
|
+
'content-type': 'application/json',
|
|
16
|
+
accept: 'application/json, text/event-stream',
|
|
17
|
+
authorization: `Bearer ${token}`,
|
|
18
|
+
},
|
|
19
|
+
body: JSON.stringify({
|
|
20
|
+
jsonrpc: '2.0',
|
|
21
|
+
id: 1,
|
|
22
|
+
method: 'tools/call',
|
|
23
|
+
params: { name, arguments: args },
|
|
24
|
+
}),
|
|
25
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 3000),
|
|
26
|
+
});
|
|
27
|
+
if (!response.ok)
|
|
28
|
+
return { ok: false, error: `mimir responded ${response.status}` };
|
|
29
|
+
const body = (await response.json());
|
|
30
|
+
if (body.error)
|
|
31
|
+
return { ok: false, error: body.error.message ?? 'mimir returned an error' };
|
|
32
|
+
const text = body.result?.content?.[0]?.text;
|
|
33
|
+
if (body.result?.isError)
|
|
34
|
+
return { ok: false, error: text ?? 'mimir returned an error' };
|
|
35
|
+
if (!text)
|
|
36
|
+
return { ok: true, result: body.result ?? null };
|
|
37
|
+
try {
|
|
38
|
+
return { ok: true, result: JSON.parse(text) };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return { ok: true, result: text };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
return { ok: false, error: error instanceof Error ? error.message : 'request failed' };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** A repo key for X-Mimir-Repo, from MIMIR_REPO or `git remote get-url origin`. */
|
|
49
|
+
export function detectRepo(directory) {
|
|
50
|
+
if (process.env.MIMIR_REPO)
|
|
51
|
+
return process.env.MIMIR_REPO;
|
|
52
|
+
try {
|
|
53
|
+
const remote = execFileSync('git', ['-C', directory, 'remote', 'get-url', 'origin'], {
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
56
|
+
}).trim();
|
|
57
|
+
return remote || null;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type ToolPolicy = 'auto' | 'always' | 'never';
|
|
2
|
+
export type MimirConfig = {
|
|
3
|
+
url: string;
|
|
4
|
+
token: string;
|
|
5
|
+
tools: ToolPolicy;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Options win over the environment, which wins over the token file the MCP
|
|
9
|
+
* header already points at. Anything missing means "stay quiet": a plugin
|
|
10
|
+
* must never break a session it cannot reach Mimir from.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveConfig(options: Record<string, unknown>, env?: Record<string, string | undefined>, readFile?: (path: string) => string): MimirConfig | null;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const DEFAULT_TOKEN_FILE = join(homedir(), '.config/opencode/.secrets/mimir-token');
|
|
5
|
+
/**
|
|
6
|
+
* Options win over the environment, which wins over the token file the MCP
|
|
7
|
+
* header already points at. Anything missing means "stay quiet": a plugin
|
|
8
|
+
* must never break a session it cannot reach Mimir from.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveConfig(options, env = process.env, readFile = (path) => readFileSync(path, 'utf8')) {
|
|
11
|
+
const url = normaliseUrl(text(options.url) ?? text(env.MIMIR_URL));
|
|
12
|
+
const token = text(options.token) ?? text(env.MIMIR_TOKEN) ?? readTokenFile(text(options.tokenFile), readFile);
|
|
13
|
+
if (!url || !token)
|
|
14
|
+
return null;
|
|
15
|
+
const policy = options.tools;
|
|
16
|
+
return { url, token, tools: policy === 'always' || policy === 'never' ? policy : 'auto' };
|
|
17
|
+
}
|
|
18
|
+
function text(value) {
|
|
19
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
20
|
+
}
|
|
21
|
+
function readTokenFile(path, readFile) {
|
|
22
|
+
try {
|
|
23
|
+
// readFileSync does not expand `~`, and every example we print uses one.
|
|
24
|
+
return text(readFile((path ?? DEFAULT_TOKEN_FILE).replace(/^~(?=$|\/)/, homedir())));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** A base URL, because callers append `/mcp` themselves. */
|
|
31
|
+
function normaliseUrl(value) {
|
|
32
|
+
return text(value?.replace(/\/+$/, '').replace(/\/mcp$/, ''));
|
|
33
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Plugin } from '@opencode/plugin';
|
|
2
|
+
export declare const DIGEST_HEADER = "## What this project already knows (Mimir)\n\n";
|
|
3
|
+
/**
|
|
4
|
+
* One injection point and two tools. The hook is what puts the project's
|
|
5
|
+
* digest in front of the model before the first turn, so a session starts
|
|
6
|
+
* informed without spending a tool call. Everything here is fail-soft: a slow
|
|
7
|
+
* or missing Mimir must never hold up a session.
|
|
8
|
+
*/
|
|
9
|
+
declare const _default: Plugin.Plugin;
|
|
10
|
+
export default _default;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Plugin } from '@opencode/plugin';
|
|
2
|
+
import { callTool, detectRepo } from './client.js';
|
|
3
|
+
import { resolveConfig } from './config.js';
|
|
4
|
+
import { registerTools } from './tools.js';
|
|
5
|
+
export const DIGEST_HEADER = '## What this project already knows (Mimir)\n\n';
|
|
6
|
+
/**
|
|
7
|
+
* One injection point and two tools. The hook is what puts the project's
|
|
8
|
+
* digest in front of the model before the first turn, so a session starts
|
|
9
|
+
* informed without spending a tool call. Everything here is fail-soft: a slow
|
|
10
|
+
* or missing Mimir must never hold up a session.
|
|
11
|
+
*/
|
|
12
|
+
export default Plugin.define({
|
|
13
|
+
id: 'mimir',
|
|
14
|
+
async setup(ctx) {
|
|
15
|
+
const config = resolveConfig(ctx.options);
|
|
16
|
+
if (!config)
|
|
17
|
+
return;
|
|
18
|
+
// A folder without a git remote still needs a stable key, or every write
|
|
19
|
+
// lands in a fresh project. The server maps absolute paths to `local:<hash>`.
|
|
20
|
+
const repo = detectRepo(ctx.location.directory) ?? ctx.location.directory;
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
// The hook runs before every model dispatch, so a session is fetched once
|
|
23
|
+
// and then left alone.
|
|
24
|
+
const session = await ctx.session.hook('context', async (event) => {
|
|
25
|
+
if (seen.has(event.sessionID))
|
|
26
|
+
return;
|
|
27
|
+
// One attempt per session: a failed digest must not be re-paid before
|
|
28
|
+
// every model dispatch, which is how often this hook runs.
|
|
29
|
+
seen.add(event.sessionID);
|
|
30
|
+
const result = await callTool('memory_digest', { repo: repo ?? undefined, budget_tokens: 800 }, { ...config, timeoutMs: 1500 }).catch(() => null);
|
|
31
|
+
if (!result?.ok)
|
|
32
|
+
return;
|
|
33
|
+
const text = result.result.text?.trim();
|
|
34
|
+
if (!text)
|
|
35
|
+
return;
|
|
36
|
+
event.system.push({ type: 'text', text: `${DIGEST_HEADER}${text}` });
|
|
37
|
+
});
|
|
38
|
+
const tools = await registerTools(ctx, config, repo);
|
|
39
|
+
return async () => {
|
|
40
|
+
await tools.dispose();
|
|
41
|
+
await session.dispose();
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
});
|
package/dist/select.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an import is for: the knowledge a reader would have to dig out of the
|
|
3
|
+
* repository, not a second copy of the code the agent already has on disk.
|
|
4
|
+
*/
|
|
5
|
+
export declare const IGNORED_DIRS: Set<string>;
|
|
6
|
+
export declare const IGNORED_EXTENSIONS: Set<string>;
|
|
7
|
+
export type WalkEntry = {
|
|
8
|
+
path: string;
|
|
9
|
+
bytes: number;
|
|
10
|
+
};
|
|
11
|
+
export type WalkResult = {
|
|
12
|
+
files: WalkEntry[];
|
|
13
|
+
skipped: {
|
|
14
|
+
path: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
}[];
|
|
17
|
+
/** True when the walk stopped early, so the caller must not report success. */
|
|
18
|
+
truncated: boolean;
|
|
19
|
+
};
|
|
20
|
+
export type WalkOptions = {
|
|
21
|
+
all?: boolean;
|
|
22
|
+
maxFileBytes?: number;
|
|
23
|
+
maxEntries?: number;
|
|
24
|
+
};
|
|
25
|
+
export declare function selectFiles(root: string, options?: WalkOptions): Promise<WalkResult>;
|
package/dist/select.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { posix, join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* What an import is for: the knowledge a reader would have to dig out of the
|
|
5
|
+
* repository, not a second copy of the code the agent already has on disk.
|
|
6
|
+
*/
|
|
7
|
+
export const IGNORED_DIRS = new Set([
|
|
8
|
+
'node_modules',
|
|
9
|
+
'.git',
|
|
10
|
+
'dist',
|
|
11
|
+
'build',
|
|
12
|
+
'.next',
|
|
13
|
+
'coverage',
|
|
14
|
+
'.venv',
|
|
15
|
+
'venv',
|
|
16
|
+
'__pycache__',
|
|
17
|
+
'.idea',
|
|
18
|
+
'.vscode',
|
|
19
|
+
'vendor',
|
|
20
|
+
'target',
|
|
21
|
+
'tmp',
|
|
22
|
+
]);
|
|
23
|
+
export const IGNORED_EXTENSIONS = new Set([
|
|
24
|
+
'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'pdf', 'zip', 'gz', 'tar', 'bz2', '7z', 'rar',
|
|
25
|
+
'exe', 'dll', 'so', 'dylib', 'o', 'a', 'class', 'jar', 'wasm',
|
|
26
|
+
'mp3', 'mp4', 'mov', 'avi', 'woff', 'woff2', 'ttf', 'eot',
|
|
27
|
+
]);
|
|
28
|
+
const CONFIG_FILES = new Set([
|
|
29
|
+
'package.json',
|
|
30
|
+
'tsconfig.json',
|
|
31
|
+
'docker-compose.yml',
|
|
32
|
+
'docker-compose.yaml',
|
|
33
|
+
'compose.yml',
|
|
34
|
+
'compose.yaml',
|
|
35
|
+
'Dockerfile',
|
|
36
|
+
'Makefile',
|
|
37
|
+
'pyproject.toml',
|
|
38
|
+
'go.mod',
|
|
39
|
+
'Cargo.toml',
|
|
40
|
+
'.env.example',
|
|
41
|
+
'renovate.json',
|
|
42
|
+
'turbo.json',
|
|
43
|
+
]);
|
|
44
|
+
const PROSE = /\.(md|mdx)$/i;
|
|
45
|
+
const ROOT_PROSE = /^(readme|changelog|contributing|license)/i;
|
|
46
|
+
// A guard against a symlinked home directory, not a product limit: a real
|
|
47
|
+
// repository of 250 000 files is still walked, and hitting the cap is reported.
|
|
48
|
+
const MAX_ENTRIES = 250_000;
|
|
49
|
+
function depth(path) {
|
|
50
|
+
return path.split('/').length - 1;
|
|
51
|
+
}
|
|
52
|
+
function wanted(path) {
|
|
53
|
+
const base = posix.basename(path);
|
|
54
|
+
if (ROOT_PROSE.test(base) && depth(path) <= 1)
|
|
55
|
+
return true;
|
|
56
|
+
if (path.startsWith('docs/') || path.startsWith('doc/'))
|
|
57
|
+
return PROSE.test(base);
|
|
58
|
+
if (PROSE.test(base) && depth(path) <= 3)
|
|
59
|
+
return true;
|
|
60
|
+
return CONFIG_FILES.has(base) && depth(path) <= 2;
|
|
61
|
+
}
|
|
62
|
+
export async function selectFiles(root, options = {}) {
|
|
63
|
+
const maxFileBytes = options.maxFileBytes ?? 262_144;
|
|
64
|
+
const maxEntries = options.maxEntries ?? MAX_ENTRIES;
|
|
65
|
+
const files = [];
|
|
66
|
+
const skipped = [];
|
|
67
|
+
let seen = 0;
|
|
68
|
+
let truncated = false;
|
|
69
|
+
async function walk(relative) {
|
|
70
|
+
const entries = await readdir(join(root, relative), { withFileTypes: true });
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (++seen > maxEntries) {
|
|
73
|
+
truncated = true;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const path = relative ? `${relative}/${entry.name}` : entry.name;
|
|
77
|
+
if (entry.isDirectory()) {
|
|
78
|
+
if (IGNORED_DIRS.has(entry.name))
|
|
79
|
+
continue;
|
|
80
|
+
await walk(path);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!entry.isFile())
|
|
84
|
+
continue;
|
|
85
|
+
const extension = posix.extname(entry.name).slice(1).toLowerCase();
|
|
86
|
+
if (IGNORED_EXTENSIONS.has(extension))
|
|
87
|
+
continue;
|
|
88
|
+
if (options.all !== true && !wanted(path))
|
|
89
|
+
continue;
|
|
90
|
+
const { size } = await stat(join(root, path));
|
|
91
|
+
if (size > maxFileBytes) {
|
|
92
|
+
skipped.push({ path, reason: `too large (${size} bytes)` });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
files.push({ path, bytes: size });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
await walk('');
|
|
99
|
+
return { files, skipped, truncated };
|
|
100
|
+
}
|