@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.
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ })