@mojoaar/opencode-mimir 0.0.0-stage → 0.14.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/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { MimirConfig } from './config.js';
|
|
2
|
+
export type Registration = {
|
|
3
|
+
dispose: () => Promise<void>;
|
|
4
|
+
};
|
|
5
|
+
type ToolExecutor = (input: any, context: any) => Promise<{
|
|
6
|
+
content: string;
|
|
7
|
+
}>;
|
|
8
|
+
type ToolDefinition = {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
input: Record<string, unknown>;
|
|
12
|
+
execute: ToolExecutor;
|
|
13
|
+
};
|
|
14
|
+
type ToolsContext = {
|
|
15
|
+
tool: {
|
|
16
|
+
transform: (callback: (editor: {
|
|
17
|
+
add: (tool: ToolDefinition) => void;
|
|
18
|
+
}) => void) => Promise<Registration>;
|
|
19
|
+
};
|
|
20
|
+
mcp: {
|
|
21
|
+
transform: (callback: (editor: {
|
|
22
|
+
list: () => readonly [string, {
|
|
23
|
+
type?: string;
|
|
24
|
+
url?: string;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
}][];
|
|
27
|
+
}) => void) => Promise<Registration>;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The two tools exist for someone running the plugin without the MCP server.
|
|
32
|
+
* When an MCP server already answers at the same endpoint they would only
|
|
33
|
+
* duplicate its tools, so `auto` leaves them out.
|
|
34
|
+
*/
|
|
35
|
+
export declare function registerTools(ctx: ToolsContext, config: MimirConfig, repo: string | null): Promise<Registration>;
|
|
36
|
+
export {};
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { callTool } from './client.js';
|
|
2
|
+
/**
|
|
3
|
+
* The two tools exist for someone running the plugin without the MCP server.
|
|
4
|
+
* When an MCP server already answers at the same endpoint they would only
|
|
5
|
+
* duplicate its tools, so `auto` leaves them out.
|
|
6
|
+
*/
|
|
7
|
+
export async function registerTools(ctx, config, repo) {
|
|
8
|
+
if (config.tools === 'never')
|
|
9
|
+
return IDLE;
|
|
10
|
+
if (config.tools === 'auto' && (await servedByMcp(ctx, config.url)))
|
|
11
|
+
return IDLE;
|
|
12
|
+
return ctx.tool.transform((editor) => {
|
|
13
|
+
editor.add(digestTool(repo));
|
|
14
|
+
editor.add(rememberTool(repo));
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
const IDLE = { dispose: async () => { } };
|
|
18
|
+
async function servedByMcp(ctx, url) {
|
|
19
|
+
let found = false;
|
|
20
|
+
try {
|
|
21
|
+
const registration = await ctx.mcp.transform((editor) => {
|
|
22
|
+
found = editor
|
|
23
|
+
.list()
|
|
24
|
+
.some(([, server]) => server.type === 'remote' && !server.disabled && sameEndpoint(server.url, `${url}/mcp`));
|
|
25
|
+
});
|
|
26
|
+
await registration.dispose();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// A broken MCP entry must not cost the session its tools.
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return found;
|
|
33
|
+
}
|
|
34
|
+
function sameEndpoint(actual, expected) {
|
|
35
|
+
return strip(actual) === strip(expected);
|
|
36
|
+
}
|
|
37
|
+
function strip(value) {
|
|
38
|
+
return (value ?? '').replace(/\/+$/, '');
|
|
39
|
+
}
|
|
40
|
+
function digestTool(repo) {
|
|
41
|
+
return {
|
|
42
|
+
name: 'mimir_digest',
|
|
43
|
+
description: 'Load what this project already knows from Mimir. Call once at the start of a session, before searching for anything.',
|
|
44
|
+
input: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
budget_tokens: {
|
|
48
|
+
type: 'integer',
|
|
49
|
+
minimum: 1,
|
|
50
|
+
description: 'Rough size cap, default 800',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
},
|
|
55
|
+
async execute(input) {
|
|
56
|
+
const result = await callTool('memory_digest', { budget_tokens: input?.budget_tokens ?? 800, repo: repo ?? undefined }, { timeoutMs: 5000 });
|
|
57
|
+
if (!result.ok)
|
|
58
|
+
return { content: `Mimir is unavailable (${result.error}). Carry on without it.` };
|
|
59
|
+
const digest = result.result;
|
|
60
|
+
return { content: digest.text?.trim() || 'Mimir has nothing stored for this project yet.' };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function rememberTool(repo) {
|
|
65
|
+
return {
|
|
66
|
+
name: 'mimir_remember',
|
|
67
|
+
description: 'Store one durable fact in Mimir. Use a path like "deploy/rollback" or "auth/token-rotation"; rewriting the same path replaces what was there.',
|
|
68
|
+
input: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
path: { type: 'string', description: 'Slash-separated memory path, e.g. deploy/rollback' },
|
|
72
|
+
body: { type: 'string', description: 'What to remember, written so it reads without the conversation' },
|
|
73
|
+
kind: { type: 'string', enum: ['note', 'fact', 'decision', 'gotcha', 'snippet'] },
|
|
74
|
+
},
|
|
75
|
+
required: ['path', 'body'],
|
|
76
|
+
additionalProperties: false,
|
|
77
|
+
},
|
|
78
|
+
async execute(input) {
|
|
79
|
+
const result = await callTool('memory_write', { path: input.path, body: input.body, kind: input.kind, repo: repo ?? undefined }, { timeoutMs: 8000 });
|
|
80
|
+
if (!result.ok)
|
|
81
|
+
return { content: `Could not store that (${result.error}).` };
|
|
82
|
+
const written = result.result;
|
|
83
|
+
return { content: `${written.created ? 'Stored' : 'Updated'} ${written.path ?? input.path}.` };
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mojoaar/opencode-mimir",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.14.0",
|
|
4
|
+
"description": "Mimir long-term memory for OpenCode sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"mimir": "dist/bin.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"dist",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"opencode",
|
|
19
|
+
"opencode-plugin",
|
|
20
|
+
"mcp",
|
|
21
|
+
"memory"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.json"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@opencode/plugin": ">=2.1.4"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@opencode/plugin": "^2.1.4",
|
|
34
|
+
"typescript": "^5.9.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/args.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
|
|
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
|
+
}
|
|
23
|
+
|
|
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
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
if (token === '--all') {
|
|
32
|
+
args.all = true
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if (token === '--dry-run') {
|
|
36
|
+
args.dryRun = true
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (token.startsWith('--')) {
|
|
40
|
+
const body = token.slice(2)
|
|
41
|
+
const equals = body.indexOf('=')
|
|
42
|
+
const name = equals === -1 ? body : body.slice(0, equals)
|
|
43
|
+
if (!VALUE_FLAGS.has(name)) throw new Error(`unknown flag --${name}`)
|
|
44
|
+
const value = equals === -1 ? argv[++i] : body.slice(equals + 1)
|
|
45
|
+
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
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
args.path = token
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return args
|
|
55
|
+
}
|
package/src/batch.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
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> = { files: T[]; bytes: number }
|
|
7
|
+
|
|
8
|
+
export function batchFiles<T extends { bytes: number }>(
|
|
9
|
+
files: T[],
|
|
10
|
+
maxFiles = 100,
|
|
11
|
+
maxBytes = 2 * 1024 * 1024,
|
|
12
|
+
): Batch<T>[] {
|
|
13
|
+
const batches: Batch<T>[] = []
|
|
14
|
+
let current: Batch<T> = { files: [], bytes: 0 }
|
|
15
|
+
|
|
16
|
+
for (const file of files) {
|
|
17
|
+
const wouldOverflow =
|
|
18
|
+
current.files.length > 0 && (current.files.length >= maxFiles || current.bytes + file.bytes > maxBytes)
|
|
19
|
+
if (wouldOverflow) {
|
|
20
|
+
batches.push(current)
|
|
21
|
+
current = { files: [], bytes: 0 }
|
|
22
|
+
}
|
|
23
|
+
current.files.push(file)
|
|
24
|
+
current.bytes += file.bytes
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (current.files.length > 0) batches.push(current)
|
|
28
|
+
return batches
|
|
29
|
+
}
|
package/src/bin.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
|
|
8
|
+
void main(process.argv.slice(2))
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
3
|
+
import { basename, join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { parseArgs, type IngestArgs } from './args.js'
|
|
6
|
+
import { batchFiles } from './batch.js'
|
|
7
|
+
import { callTool, detectRepo } from './client.js'
|
|
8
|
+
import { selectFiles } from './select.js'
|
|
9
|
+
|
|
10
|
+
const USAGE = `mimir ingest — load a project's documentation into Mimir
|
|
11
|
+
|
|
12
|
+
mimir ingest [path] [options]
|
|
13
|
+
|
|
14
|
+
--project <name> name the project yourself. Required for a folder that is
|
|
15
|
+
not a git repository; also keeps two folders apart.
|
|
16
|
+
--url <url> Mimir base URL (default: MIMIR_URL)
|
|
17
|
+
--token <token> API key (default: MIMIR_TOKEN)
|
|
18
|
+
--all import every text file, not just documentation
|
|
19
|
+
--dry-run print what would be sent and send nothing
|
|
20
|
+
|
|
21
|
+
A git checkout imports into the project your agents already write to. Imports
|
|
22
|
+
land under import/, where memory_search finds them.`
|
|
23
|
+
|
|
24
|
+
const MAX_BODY_BYTES = 262_144
|
|
25
|
+
|
|
26
|
+
export type IngestIo = {
|
|
27
|
+
out: (line: string) => void
|
|
28
|
+
err: (line: string) => void
|
|
29
|
+
cwd: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Prepared = { path: string; body: string; bytes: number }
|
|
33
|
+
type Skip = { path: string; reason: string }
|
|
34
|
+
|
|
35
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
36
|
+
try {
|
|
37
|
+
return (await stat(path)).isDirectory()
|
|
38
|
+
} catch {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns the exit code instead of calling process.exit, so the whole command
|
|
45
|
+
* is testable and a caller can decide what a partial import means.
|
|
46
|
+
*/
|
|
47
|
+
export async function runIngest(args: IngestArgs, io: IngestIo): Promise<number> {
|
|
48
|
+
if (args.help) {
|
|
49
|
+
io.out(USAGE)
|
|
50
|
+
return 0
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const root = resolve(io.cwd, args.path)
|
|
54
|
+
if (!(await isDirectory(root))) {
|
|
55
|
+
io.err(`no such directory: ${args.path}`)
|
|
56
|
+
return 2
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const repo = args.project ? null : detectRepo(root)
|
|
60
|
+
if (!args.project && !repo) {
|
|
61
|
+
// Falling back to the folder name would merge two different `docs/` folders
|
|
62
|
+
// into one project and let the second silently overwrite the first.
|
|
63
|
+
io.err(`not a git repository: pass --project <name> to name this folder's project`)
|
|
64
|
+
return 2
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const key = args.project ? { project: args.project } : { repo: repo as string }
|
|
68
|
+
const label = args.project ?? (repo as string)
|
|
69
|
+
|
|
70
|
+
const walk = await selectFiles(root, { all: args.all })
|
|
71
|
+
const skipped: Skip[] = [...walk.skipped]
|
|
72
|
+
|
|
73
|
+
// Read before deciding: the server caps the decoded body, and a Latin-1 file
|
|
74
|
+
// can pass the byte check on disk and still exceed it once decoded.
|
|
75
|
+
const prepared: Prepared[] = []
|
|
76
|
+
for (const file of walk.files) {
|
|
77
|
+
try {
|
|
78
|
+
const body = await readFile(join(root, file.path), 'utf8')
|
|
79
|
+
const bytes = Buffer.byteLength(body, 'utf8')
|
|
80
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
81
|
+
skipped.push({ path: file.path, reason: `too large (${bytes} bytes)` })
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
prepared.push({ path: file.path, body, bytes })
|
|
85
|
+
} catch (error) {
|
|
86
|
+
skipped.push({ path: file.path, reason: (error as Error).message })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const batches = batchFiles(prepared)
|
|
91
|
+
const totalBytes = prepared.reduce((sum, file) => sum + file.bytes, 0)
|
|
92
|
+
|
|
93
|
+
if (args.dryRun) {
|
|
94
|
+
io.out(`would import ${prepared.length} files (${totalBytes} bytes) into ${label} as import/<path>`)
|
|
95
|
+
for (const file of skipped) io.err(`skipped ${file.path}: ${file.reason}`)
|
|
96
|
+
if (walk.truncated) {
|
|
97
|
+
io.err(`the folder tree was larger than the walk limit; the list above is incomplete`)
|
|
98
|
+
return 1
|
|
99
|
+
}
|
|
100
|
+
return 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let imported = 0
|
|
104
|
+
let failed = 0
|
|
105
|
+
|
|
106
|
+
for (const [index, batch] of batches.entries()) {
|
|
107
|
+
const result = await callTool(
|
|
108
|
+
'memory_ingest',
|
|
109
|
+
{
|
|
110
|
+
...key,
|
|
111
|
+
path_prefix: 'import',
|
|
112
|
+
files: batch.files.map((file) => ({ path: file.path, body: file.body })),
|
|
113
|
+
},
|
|
114
|
+
{ url: args.url, token: args.token, timeoutMs: 60_000 },
|
|
115
|
+
)
|
|
116
|
+
if (!result.ok) {
|
|
117
|
+
failed++
|
|
118
|
+
io.err(`batch ${index + 1} failed: ${result.error}`)
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const outcome = result.result as { written?: number; skipped?: Skip[] }
|
|
123
|
+
imported += outcome.written ?? batch.files.length
|
|
124
|
+
for (const entry of outcome.skipped ?? []) {
|
|
125
|
+
skipped.push({ path: entry.path, reason: entry.reason })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const file of skipped) io.err(`skipped ${file.path}: ${file.reason}`)
|
|
130
|
+
io.out(`imported ${imported} files, skipped ${skipped.length}`)
|
|
131
|
+
if (walk.truncated) {
|
|
132
|
+
io.err(`the folder tree was larger than the walk limit; not everything was considered`)
|
|
133
|
+
}
|
|
134
|
+
return failed > 0 || skipped.length > 0 || walk.truncated ? 1 : 0
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function main(argv: string[]): Promise<void> {
|
|
138
|
+
let args: IngestArgs
|
|
139
|
+
try {
|
|
140
|
+
args = parseArgs(argv)
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error((error as Error).message)
|
|
143
|
+
console.error(USAGE)
|
|
144
|
+
process.exitCode = 2
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
process.exitCode = await runIngest(args, {
|
|
148
|
+
out: (line) => console.log(line),
|
|
149
|
+
err: (line) => console.error(line),
|
|
150
|
+
cwd: process.cwd(),
|
|
151
|
+
})
|
|
152
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
export type CallResult = { ok: true; result: unknown } | { ok: false; error: string }
|
|
3
|
+
|
|
4
|
+
export type CallOptions = {
|
|
5
|
+
url?: string
|
|
6
|
+
token?: string
|
|
7
|
+
timeoutMs?: number
|
|
8
|
+
fetch?: typeof fetch
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Calls one Mimir MCP tool. Never throws: a plugin must not break a session
|
|
13
|
+
* because the memory server is down.
|
|
14
|
+
*/
|
|
15
|
+
export async function callTool(
|
|
16
|
+
name: string,
|
|
17
|
+
args: Record<string, unknown>,
|
|
18
|
+
options: CallOptions = {},
|
|
19
|
+
): Promise<CallResult> {
|
|
20
|
+
const url = (options.url ?? process.env.MIMIR_URL ?? '').replace(/\/+$/, '')
|
|
21
|
+
const token = options.token ?? process.env.MIMIR_TOKEN ?? ''
|
|
22
|
+
if (!url || !token) return { ok: false, error: 'MIMIR_URL and MIMIR_TOKEN must be set' }
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const response = await (options.fetch ?? fetch)(`${url}/mcp`, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: {
|
|
28
|
+
'content-type': 'application/json',
|
|
29
|
+
accept: 'application/json, text/event-stream',
|
|
30
|
+
authorization: `Bearer ${token}`,
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify({
|
|
33
|
+
jsonrpc: '2.0',
|
|
34
|
+
id: 1,
|
|
35
|
+
method: 'tools/call',
|
|
36
|
+
params: { name, arguments: args },
|
|
37
|
+
}),
|
|
38
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 3000),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
if (!response.ok) return { ok: false, error: `mimir responded ${response.status}` }
|
|
42
|
+
|
|
43
|
+
const body = (await response.json()) as {
|
|
44
|
+
error?: { message?: string }
|
|
45
|
+
result?: { content?: Array<{ text?: string }>; isError?: boolean }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (body.error) return { ok: false, error: body.error.message ?? 'mimir returned an error' }
|
|
49
|
+
|
|
50
|
+
const text = body.result?.content?.[0]?.text
|
|
51
|
+
if (body.result?.isError) return { ok: false, error: text ?? 'mimir returned an error' }
|
|
52
|
+
if (!text) return { ok: true, result: body.result ?? null }
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
return { ok: true, result: JSON.parse(text) }
|
|
56
|
+
} catch {
|
|
57
|
+
return { ok: true, result: text }
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return { ok: false, error: error instanceof Error ? error.message : 'request failed' }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A repo key for X-Mimir-Repo, from MIMIR_REPO or `git remote get-url origin`. */
|
|
65
|
+
export function detectRepo(directory: string): string | null {
|
|
66
|
+
if (process.env.MIMIR_REPO) return process.env.MIMIR_REPO
|
|
67
|
+
try {
|
|
68
|
+
const remote = execFileSync('git', ['-C', directory, 'remote', 'get-url', 'origin'], {
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
71
|
+
}).trim()
|
|
72
|
+
return remote || null
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export type ToolPolicy = 'auto' | 'always' | 'never'
|
|
6
|
+
|
|
7
|
+
export type MimirConfig = { url: string; token: string; tools: ToolPolicy }
|
|
8
|
+
|
|
9
|
+
const DEFAULT_TOKEN_FILE = join(homedir(), '.config/opencode/.secrets/mimir-token')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Options win over the environment, which wins over the token file the MCP
|
|
13
|
+
* header already points at. Anything missing means "stay quiet": a plugin
|
|
14
|
+
* must never break a session it cannot reach Mimir from.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveConfig(
|
|
17
|
+
options: Record<string, unknown>,
|
|
18
|
+
env: Record<string, string | undefined> = process.env,
|
|
19
|
+
readFile: (path: string) => string = (path) => readFileSync(path, 'utf8'),
|
|
20
|
+
): MimirConfig | null {
|
|
21
|
+
const url = normaliseUrl(text(options.url) ?? text(env.MIMIR_URL))
|
|
22
|
+
const token =
|
|
23
|
+
text(options.token) ?? text(env.MIMIR_TOKEN) ?? readTokenFile(text(options.tokenFile), readFile)
|
|
24
|
+
if (!url || !token) return null
|
|
25
|
+
|
|
26
|
+
const policy = options.tools
|
|
27
|
+
return { url, token, tools: policy === 'always' || policy === 'never' ? policy : 'auto' }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function text(value: unknown): string | undefined {
|
|
31
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readTokenFile(path: string | undefined, readFile: (path: string) => string): string | undefined {
|
|
35
|
+
try {
|
|
36
|
+
// readFileSync does not expand `~`, and every example we print uses one.
|
|
37
|
+
return text(readFile((path ?? DEFAULT_TOKEN_FILE).replace(/^~(?=$|\/)/, homedir())))
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A base URL, because callers append `/mcp` themselves. */
|
|
44
|
+
function normaliseUrl(value: string | undefined): string | undefined {
|
|
45
|
+
return text(value?.replace(/\/+$/, '').replace(/\/mcp$/, ''))
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
|
|
6
|
+
export const DIGEST_HEADER = '## What this project already knows (Mimir)\n\n'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One injection point and two tools. The hook is what puts the project's
|
|
10
|
+
* digest in front of the model before the first turn, so a session starts
|
|
11
|
+
* informed without spending a tool call. Everything here is fail-soft: a slow
|
|
12
|
+
* or missing Mimir must never hold up a session.
|
|
13
|
+
*/
|
|
14
|
+
export default Plugin.define({
|
|
15
|
+
id: 'mimir',
|
|
16
|
+
async setup(ctx) {
|
|
17
|
+
const config = resolveConfig(ctx.options)
|
|
18
|
+
if (!config) return
|
|
19
|
+
|
|
20
|
+
// A folder without a git remote still needs a stable key, or every write
|
|
21
|
+
// lands in a fresh project. The server maps absolute paths to `local:<hash>`.
|
|
22
|
+
const repo = detectRepo(ctx.location.directory) ?? ctx.location.directory
|
|
23
|
+
const seen = new Set<string>()
|
|
24
|
+
|
|
25
|
+
// The hook runs before every model dispatch, so a session is fetched once
|
|
26
|
+
// and then left alone.
|
|
27
|
+
const session = await ctx.session.hook('context', async (event) => {
|
|
28
|
+
if (seen.has(event.sessionID)) return
|
|
29
|
+
// One attempt per session: a failed digest must not be re-paid before
|
|
30
|
+
// every model dispatch, which is how often this hook runs.
|
|
31
|
+
seen.add(event.sessionID)
|
|
32
|
+
|
|
33
|
+
const result = await callTool(
|
|
34
|
+
'memory_digest',
|
|
35
|
+
{ repo: repo ?? undefined, budget_tokens: 800 },
|
|
36
|
+
{ ...config, timeoutMs: 1500 },
|
|
37
|
+
).catch(() => null)
|
|
38
|
+
|
|
39
|
+
if (!result?.ok) return
|
|
40
|
+
const text = (result.result as { text?: string }).text?.trim()
|
|
41
|
+
if (!text) return
|
|
42
|
+
|
|
43
|
+
event.system.push({ type: 'text', text: `${DIGEST_HEADER}${text}` })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const tools = await registerTools(ctx, config, repo)
|
|
47
|
+
|
|
48
|
+
return async () => {
|
|
49
|
+
await tools.dispose()
|
|
50
|
+
await session.dispose()
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
})
|