@mojoaar/opencode-mimir 0.23.1 → 0.25.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 CHANGED
@@ -44,6 +44,11 @@ one the MCP header can use.
44
44
  |---|---|
45
45
  | `mimir_digest` | Returns what this project already knows: paths, titles, and a summary of the newest memories. |
46
46
  | `mimir_remember` | Stores one durable fact at a path like `deploy/rollback`. Writing the same path again replaces it. |
47
+ | `mimir_search` | Hybrid search over this project's memories. |
48
+ | `mimir_learn` | Hands a transcript over; the project's own model writes at most three memories from it. |
49
+
50
+ When OpenCode compacts the conversation it also hands those messages to Mimir,
51
+ which distils at most three memories from them.
47
52
 
48
53
  `tools` decides whether they are added: `auto` (default) leaves them out when an
49
54
  MCP server already points at the same `/mcp` endpoint, `always` adds them
package/dist/index.d.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { Plugin } from '@opencode/plugin';
2
2
  export declare const DIGEST_HEADER = "## What this project already knows (Mimir)\n\n";
3
3
  export declare const NUDGE_HEADER = "## Memory check (Mimir)\n\n";
4
+ export declare const MAX_TRANSCRIPT = 32000;
5
+ /** A compaction of nothing is not worth a model call. */
6
+ export declare const MIN_TRANSCRIPT = 500;
7
+ /** Message text is shaped differently by each provider, so read what we can and skip the rest. */
8
+ export declare function transcript(messages: unknown[]): string;
4
9
  /**
5
10
  * One injection point and two tools. The hook is what puts the project's
6
11
  * digest in front of the model before the first turn, so a session starts
package/dist/index.js CHANGED
@@ -14,6 +14,44 @@ const NUDGE_EVERY = 20;
14
14
  const NUDGE = 'If this session settled a decision, a convention or a gotcha that is not in Mimir yet, ' +
15
15
  'call memory_write now at one clear path, or hand the conversation over with mimir_learn, ' +
16
16
  'before the detail is compacted away.';
17
+ export const MAX_TRANSCRIPT = 32_000;
18
+ /** A compaction of nothing is not worth a model call. */
19
+ export const MIN_TRANSCRIPT = 500;
20
+ const LEARN_HINT = 'This session was just compacted. Keep what was decided, what broke and any convention, and skip what Mimir already has.';
21
+ const LEARN_NOW_HINT = 'The user asked for this session to be kept now. Keep what was decided, what broke and any convention, and skip what Mimir already has.';
22
+ const NOTHING_TO_SEND = 'Nothing worth handing over yet.';
23
+ const SENT = 'Mimir is writing what this session settled.';
24
+ const QUEUE_OFF = 'Mimir took it, but its queue is not running.';
25
+ const UNREACHABLE = 'Mimir is unreachable, so nothing was sent.';
26
+ /** Message text is shaped differently by each provider, so read what we can and skip the rest. */
27
+ export function transcript(messages) {
28
+ const lines = [];
29
+ for (const message of messages) {
30
+ if (typeof message !== 'object' || message === null)
31
+ continue;
32
+ const record = message;
33
+ const role = typeof record.role === 'string' ? record.role : 'message';
34
+ const body = textOf(record.content) || textOf(record.parts);
35
+ if (body)
36
+ lines.push(`${role}: ${body}`);
37
+ }
38
+ return lines.join('\n\n').slice(-MAX_TRANSCRIPT);
39
+ }
40
+ function textOf(value) {
41
+ if (typeof value === 'string')
42
+ return value.trim();
43
+ if (!Array.isArray(value))
44
+ return '';
45
+ return value
46
+ .map((part) => {
47
+ if (typeof part === 'string')
48
+ return part;
49
+ const text = part?.text;
50
+ return typeof text === 'string' ? text : '';
51
+ })
52
+ .join('\n')
53
+ .trim();
54
+ }
17
55
  /**
18
56
  * One injection point and two tools. The hook is what puts the project's
19
57
  * digest in front of the model before the first turn, so a session starts
@@ -46,20 +84,74 @@ export default Plugin.define({
46
84
  return;
47
85
  event.system.push({ type: 'text', text: `${DIGEST_HEADER}${text}` });
48
86
  });
87
+ // The reminder rides the message count. A shrink means a compaction just
88
+ // happened, which is the worst moment to lose a detail, so it nudges early.
49
89
  const counts = new Map();
90
+ // The command below is handed no messages, only a session id, so the hook
91
+ // keeps the newest transcript for it. One string per live session, at most
92
+ // 32 KB, dropped once it has been sent.
93
+ const snapshots = new Map();
50
94
  const nudge = await ctx.session.hook('context', async (event) => {
51
95
  const state = counts.get(event.sessionID) ?? { turns: 0, nudgedAt: 0 };
52
96
  const turns = event.messages?.length ?? 0;
97
+ if (turns > state.turns)
98
+ snapshots.set(event.sessionID, transcript(event.messages ?? []));
53
99
  const due = turns < state.turns || turns - state.nudgedAt >= NUDGE_EVERY;
54
100
  counts.set(event.sessionID, { turns, nudgedAt: due ? turns : state.nudgedAt });
55
101
  if (due)
56
102
  event.system.push({ type: 'text', text: `${NUDGE_HEADER}${NUDGE}` });
57
103
  });
104
+ // OpenCode fires this for its own compaction and hands over the messages
105
+ // being replaced. A compressed view swapped in by another plugin never
106
+ // reaches it, which is why the message count is not a signal.
107
+ const compacted = new Set();
108
+ const compaction = await ctx.session.hook('compaction', async (event) => {
109
+ // One compaction can ask twice. The second call must not write again.
110
+ if (compacted.has(event.sessionID))
111
+ return;
112
+ const text = transcript(event.messages ?? []);
113
+ if (text.length < MIN_TRANSCRIPT)
114
+ return;
115
+ compacted.add(event.sessionID);
116
+ const sent = await callTool('memory_learn', { repo: repo ?? undefined, transcript: text, hint: LEARN_HINT }, { ...config, timeoutMs: 4000 }).catch(() => null);
117
+ // Nothing is written to event.system here: this request's prompt becomes
118
+ // the summary that replaces the conversation. A failure gives the session
119
+ // its one chance back, so an outage does not use the compaction up.
120
+ if (!sent?.ok)
121
+ compacted.delete(event.sessionID);
122
+ });
123
+ // A manual handover, for the sessions that end before they ever compact.
124
+ const command = await ctx.command.transform((editor) => {
125
+ editor.add({
126
+ name: 'mimir-learn',
127
+ description: 'Hand this session to Mimir now.',
128
+ execute: async ({ sessionID }) => {
129
+ const text = snapshots.get(sessionID) ?? '';
130
+ let notice = NOTHING_TO_SEND;
131
+ if (text.length >= MIN_TRANSCRIPT) {
132
+ const sent = await callTool('memory_learn', { repo: repo ?? undefined, transcript: text, hint: LEARN_NOW_HINT }, { ...config, timeoutMs: 4000 }).catch(() => null);
133
+ const queued = sent?.ok ? sent.result.queued : undefined;
134
+ if (!sent?.ok)
135
+ notice = UNREACHABLE;
136
+ else if (queued === false)
137
+ notice = QUEUE_OFF;
138
+ else {
139
+ notice = SENT;
140
+ // Kept on failure above, so a retry has something to send.
141
+ snapshots.delete(sessionID);
142
+ }
143
+ }
144
+ await ctx.session.synthetic({ sessionID, text: notice }).catch(() => null);
145
+ },
146
+ });
147
+ });
58
148
  const tools = await registerTools(ctx, config, repo);
59
149
  return async () => {
60
150
  await tools.dispose();
61
151
  await session.dispose();
62
152
  await nudge.dispose();
153
+ await compaction.dispose();
154
+ await command.dispose();
63
155
  };
64
156
  },
65
157
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mojoaar/opencode-mimir",
3
- "version": "0.23.1",
3
+ "version": "0.25.0",
4
4
  "description": "Mimir long-term memory for OpenCode sessions",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -18,6 +18,44 @@ const NUDGE =
18
18
  'call memory_write now at one clear path, or hand the conversation over with mimir_learn, ' +
19
19
  'before the detail is compacted away.'
20
20
 
21
+ export const MAX_TRANSCRIPT = 32_000
22
+ /** A compaction of nothing is not worth a model call. */
23
+ export const MIN_TRANSCRIPT = 500
24
+ const LEARN_HINT =
25
+ 'This session was just compacted. Keep what was decided, what broke and any convention, and skip what Mimir already has.'
26
+ const LEARN_NOW_HINT =
27
+ 'The user asked for this session to be kept now. Keep what was decided, what broke and any convention, and skip what Mimir already has.'
28
+ const NOTHING_TO_SEND = 'Nothing worth handing over yet.'
29
+ const SENT = 'Mimir is writing what this session settled.'
30
+ const QUEUE_OFF = 'Mimir took it, but its queue is not running.'
31
+ const UNREACHABLE = 'Mimir is unreachable, so nothing was sent.'
32
+
33
+ /** Message text is shaped differently by each provider, so read what we can and skip the rest. */
34
+ export function transcript(messages: unknown[]): string {
35
+ const lines: string[] = []
36
+ for (const message of messages) {
37
+ if (typeof message !== 'object' || message === null) continue
38
+ const record = message as { role?: unknown; content?: unknown; parts?: unknown }
39
+ const role = typeof record.role === 'string' ? record.role : 'message'
40
+ const body = textOf(record.content) || textOf(record.parts)
41
+ if (body) lines.push(`${role}: ${body}`)
42
+ }
43
+ return lines.join('\n\n').slice(-MAX_TRANSCRIPT)
44
+ }
45
+
46
+ function textOf(value: unknown): string {
47
+ if (typeof value === 'string') return value.trim()
48
+ if (!Array.isArray(value)) return ''
49
+ return value
50
+ .map((part) => {
51
+ if (typeof part === 'string') return part
52
+ const text = (part as { text?: unknown })?.text
53
+ return typeof text === 'string' ? text : ''
54
+ })
55
+ .join('\n')
56
+ .trim()
57
+ }
58
+
21
59
  /**
22
60
  * One injection point and two tools. The hook is what puts the project's
23
61
  * digest in front of the model before the first turn, so a session starts
@@ -56,21 +94,82 @@ export default Plugin.define({
56
94
  event.system.push({ type: 'text', text: `${DIGEST_HEADER}${text}` })
57
95
  })
58
96
 
97
+ // The reminder rides the message count. A shrink means a compaction just
98
+ // happened, which is the worst moment to lose a detail, so it nudges early.
59
99
  const counts = new Map<string, { turns: number; nudgedAt: number }>()
100
+ // The command below is handed no messages, only a session id, so the hook
101
+ // keeps the newest transcript for it. One string per live session, at most
102
+ // 32 KB, dropped once it has been sent.
103
+ const snapshots = new Map<string, string>()
60
104
  const nudge = await ctx.session.hook('context', async (event) => {
61
105
  const state = counts.get(event.sessionID) ?? { turns: 0, nudgedAt: 0 }
62
106
  const turns = event.messages?.length ?? 0
107
+ if (turns > state.turns) snapshots.set(event.sessionID, transcript(event.messages ?? []))
63
108
  const due = turns < state.turns || turns - state.nudgedAt >= NUDGE_EVERY
64
109
  counts.set(event.sessionID, { turns, nudgedAt: due ? turns : state.nudgedAt })
110
+
65
111
  if (due) event.system.push({ type: 'text', text: `${NUDGE_HEADER}${NUDGE}` })
66
112
  })
67
113
 
114
+ // OpenCode fires this for its own compaction and hands over the messages
115
+ // being replaced. A compressed view swapped in by another plugin never
116
+ // reaches it, which is why the message count is not a signal.
117
+ const compacted = new Set<string>()
118
+ const compaction = await ctx.session.hook('compaction', async (event) => {
119
+ // One compaction can ask twice. The second call must not write again.
120
+ if (compacted.has(event.sessionID)) return
121
+ const text = transcript(event.messages ?? [])
122
+ if (text.length < MIN_TRANSCRIPT) return
123
+ compacted.add(event.sessionID)
124
+
125
+ const sent = await callTool(
126
+ 'memory_learn',
127
+ { repo: repo ?? undefined, transcript: text, hint: LEARN_HINT },
128
+ { ...config, timeoutMs: 4000 },
129
+ ).catch(() => null)
130
+
131
+ // Nothing is written to event.system here: this request's prompt becomes
132
+ // the summary that replaces the conversation. A failure gives the session
133
+ // its one chance back, so an outage does not use the compaction up.
134
+ if (!sent?.ok) compacted.delete(event.sessionID)
135
+ })
136
+
137
+ // A manual handover, for the sessions that end before they ever compact.
138
+ const command = await ctx.command.transform((editor) => {
139
+ editor.add({
140
+ name: 'mimir-learn',
141
+ description: 'Hand this session to Mimir now.',
142
+ execute: async ({ sessionID }) => {
143
+ const text = snapshots.get(sessionID) ?? ''
144
+ let notice = NOTHING_TO_SEND
145
+ if (text.length >= MIN_TRANSCRIPT) {
146
+ const sent = await callTool(
147
+ 'memory_learn',
148
+ { repo: repo ?? undefined, transcript: text, hint: LEARN_NOW_HINT },
149
+ { ...config, timeoutMs: 4000 },
150
+ ).catch(() => null)
151
+ const queued = sent?.ok ? (sent.result as { queued?: boolean }).queued : undefined
152
+ if (!sent?.ok) notice = UNREACHABLE
153
+ else if (queued === false) notice = QUEUE_OFF
154
+ else {
155
+ notice = SENT
156
+ // Kept on failure above, so a retry has something to send.
157
+ snapshots.delete(sessionID)
158
+ }
159
+ }
160
+ await ctx.session.synthetic({ sessionID, text: notice }).catch(() => null)
161
+ },
162
+ })
163
+ })
164
+
68
165
  const tools = await registerTools(ctx, config, repo)
69
166
 
70
167
  return async () => {
71
168
  await tools.dispose()
72
169
  await session.dispose()
73
170
  await nudge.dispose()
171
+ await compaction.dispose()
172
+ await command.dispose()
74
173
  }
75
174
  },
76
175
  })