@mojoaar/opencode-mimir 0.29.2 → 0.29.4

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/index.d.ts CHANGED
@@ -6,6 +6,12 @@ export declare const MAX_TRANSCRIPT = 32000;
6
6
  export declare const MIN_TRANSCRIPT = 500;
7
7
  /** Message text is shaped differently by each provider, so read what we can and skip the rest. */
8
8
  export declare function transcript(messages: unknown[]): string;
9
+ /**
10
+ * The session API hands back message records, not the provider shapes the
11
+ * context hook uses: a user turn keeps its words in `text`, an assistant turn
12
+ * in `content`. System and synthetic records are instructions, not conversation.
13
+ */
14
+ export declare function sessionTranscript(messages: readonly unknown[]): string;
9
15
  /**
10
16
  * One injection point and two tools. The hook is what puts the project's
11
17
  * digest in front of the model before the first turn, so a session starts
package/dist/index.js CHANGED
@@ -20,6 +20,8 @@ export const MIN_TRANSCRIPT = 500;
20
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
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
22
  const NOTHING_TO_SEND = 'Nothing worth handing over yet.';
23
+ const ALREADY = 'This session was already handed over.';
24
+ const UNREADABLE = 'Mimir could not read this session, so nothing was sent.';
23
25
  const SENT = 'Mimir is writing what this session settled.';
24
26
  const QUEUE_OFF = 'Mimir took it, but its queue is not running.';
25
27
  const UNREACHABLE = 'Mimir is unreachable, so nothing was sent.';
@@ -52,6 +54,41 @@ function textOf(value) {
52
54
  .join('\n')
53
55
  .trim();
54
56
  }
57
+ /**
58
+ * The session API hands back message records, not the provider shapes the
59
+ * context hook uses: a user turn keeps its words in `text`, an assistant turn
60
+ * in `content`. System and synthetic records are instructions, not conversation.
61
+ */
62
+ export function sessionTranscript(messages) {
63
+ const lines = [];
64
+ for (const message of messages) {
65
+ if (typeof message !== 'object' || message === null)
66
+ continue;
67
+ const record = message;
68
+ if (record.type === 'user') {
69
+ const body = textOf(record.text);
70
+ if (body)
71
+ lines.push(`user: ${body}`);
72
+ }
73
+ else if (record.type === 'assistant') {
74
+ const body = assistantText(record.content);
75
+ if (body)
76
+ lines.push(`assistant: ${body}`);
77
+ }
78
+ }
79
+ return lines.join('\n\n').slice(-MAX_TRANSCRIPT);
80
+ }
81
+ /** Reasoning and tool parts are not conversation, and they are the bulk of it. */
82
+ function assistantText(content) {
83
+ if (!Array.isArray(content))
84
+ return '';
85
+ return content
86
+ .filter((part) => part?.type === 'text')
87
+ .map((part) => part.text)
88
+ .filter((part) => typeof part === 'string')
89
+ .join('\n')
90
+ .trim();
91
+ }
55
92
  /**
56
93
  * One injection point and two tools. The hook is what puts the project's
57
94
  * digest in front of the model before the first turn, so a session starts
@@ -91,15 +128,9 @@ export default Plugin.define({
91
128
  // The reminder rides the message count. A shrink means a compaction just
92
129
  // happened, which is the worst moment to lose a detail, so it nudges early.
93
130
  const counts = new Map();
94
- // The command below is handed no messages, only a session id, so the hook
95
- // keeps the newest transcript for it. One string per live session, at most
96
- // 32 KB, dropped once it has been sent.
97
- const snapshots = new Map();
98
131
  const nudge = await ctx.session.hook('context', async (event) => {
99
132
  const state = counts.get(event.sessionID) ?? { turns: 0, nudgedAt: 0 };
100
133
  const turns = event.messages?.length ?? 0;
101
- if (turns > state.turns)
102
- snapshots.set(event.sessionID, transcript(event.messages ?? []));
103
134
  const due = turns < state.turns || turns - state.nudgedAt >= NUDGE_EVERY;
104
135
  counts.set(event.sessionID, { turns, nudgedAt: due ? turns : state.nudgedAt });
105
136
  if (due)
@@ -131,30 +162,51 @@ export default Plugin.define({
131
162
  compacted.delete(event.sessionID);
132
163
  });
133
164
  // A manual handover, for the sessions that end before they ever compact.
165
+ // One process hands a session over once, so a second command does not
166
+ // queue the same transcript again.
167
+ const sent = new Set();
134
168
  const command = await ctx.command.transform((editor) => {
135
169
  editor.add({
136
170
  name: 'mimir-learn',
137
171
  description: 'Hand this session to Mimir now.',
138
172
  execute: async ({ sessionID }) => {
139
- const text = snapshots.get(sessionID) ?? '';
140
173
  let notice = NOTHING_TO_SEND;
141
- if (text.length >= MIN_TRANSCRIPT) {
142
- const sent = await callTool('memory_learn', {
143
- repo: repo ?? undefined,
144
- transcript: text,
145
- hint: LEARN_NOW_HINT,
146
- client: 'opencode',
147
- session: sessionID,
148
- }, { ...config, timeoutMs: 4000 }).catch(() => null);
149
- const queued = sent?.ok ? sent.result.queued : undefined;
150
- if (!sent?.ok)
151
- notice = UNREACHABLE;
152
- else if (queued === false)
153
- notice = QUEUE_OFF;
174
+ if (sent.has(sessionID)) {
175
+ notice = ALREADY;
176
+ }
177
+ else {
178
+ let history = null;
179
+ try {
180
+ history = await ctx.session.context({ sessionID });
181
+ }
182
+ catch {
183
+ history = null;
184
+ }
185
+ // The manual command is explicit, so any real text is worth a
186
+ // model call. The 500 character floor is for automatic compaction.
187
+ if (!Array.isArray(history)) {
188
+ notice = UNREADABLE;
189
+ }
154
190
  else {
155
- notice = SENT;
156
- // Kept on failure above, so a retry has something to send.
157
- snapshots.delete(sessionID);
191
+ const text = sessionTranscript(history);
192
+ if (text.trim() !== '') {
193
+ const result = await callTool('memory_learn', {
194
+ repo: repo ?? undefined,
195
+ transcript: text,
196
+ hint: LEARN_NOW_HINT,
197
+ client: 'opencode',
198
+ session: sessionID,
199
+ }, { ...config, timeoutMs: 4000 }).catch(() => null);
200
+ const queued = result?.ok ? result.result.queued : undefined;
201
+ if (!result?.ok)
202
+ notice = UNREACHABLE;
203
+ else if (queued === false)
204
+ notice = QUEUE_OFF;
205
+ else {
206
+ notice = SENT;
207
+ sent.add(sessionID);
208
+ }
209
+ }
158
210
  }
159
211
  }
160
212
  await ctx.session.synthetic({ sessionID, text: notice }).catch(() => null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mojoaar/opencode-mimir",
3
- "version": "0.29.2",
3
+ "version": "0.29.4",
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
@@ -26,6 +26,8 @@ const LEARN_HINT =
26
26
  const LEARN_NOW_HINT =
27
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
28
  const NOTHING_TO_SEND = 'Nothing worth handing over yet.'
29
+ const ALREADY = 'This session was already handed over.'
30
+ const UNREADABLE = 'Mimir could not read this session, so nothing was sent.'
29
31
  const SENT = 'Mimir is writing what this session settled.'
30
32
  const QUEUE_OFF = 'Mimir took it, but its queue is not running.'
31
33
  const UNREACHABLE = 'Mimir is unreachable, so nothing was sent.'
@@ -56,6 +58,38 @@ function textOf(value: unknown): string {
56
58
  .trim()
57
59
  }
58
60
 
61
+ /**
62
+ * The session API hands back message records, not the provider shapes the
63
+ * context hook uses: a user turn keeps its words in `text`, an assistant turn
64
+ * in `content`. System and synthetic records are instructions, not conversation.
65
+ */
66
+ export function sessionTranscript(messages: readonly unknown[]): string {
67
+ const lines: string[] = []
68
+ for (const message of messages) {
69
+ if (typeof message !== 'object' || message === null) continue
70
+ const record = message as { type?: unknown; text?: unknown; content?: unknown }
71
+ if (record.type === 'user') {
72
+ const body = textOf(record.text)
73
+ if (body) lines.push(`user: ${body}`)
74
+ } else if (record.type === 'assistant') {
75
+ const body = assistantText(record.content)
76
+ if (body) lines.push(`assistant: ${body}`)
77
+ }
78
+ }
79
+ return lines.join('\n\n').slice(-MAX_TRANSCRIPT)
80
+ }
81
+
82
+ /** Reasoning and tool parts are not conversation, and they are the bulk of it. */
83
+ function assistantText(content: unknown): string {
84
+ if (!Array.isArray(content)) return ''
85
+ return content
86
+ .filter((part) => (part as { type?: unknown })?.type === 'text')
87
+ .map((part) => (part as { text?: unknown }).text)
88
+ .filter((part): part is string => typeof part === 'string')
89
+ .join('\n')
90
+ .trim()
91
+ }
92
+
59
93
  /**
60
94
  * One injection point and two tools. The hook is what puts the project's
61
95
  * digest in front of the model before the first turn, so a session starts
@@ -104,14 +138,9 @@ export default Plugin.define({
104
138
  // The reminder rides the message count. A shrink means a compaction just
105
139
  // happened, which is the worst moment to lose a detail, so it nudges early.
106
140
  const counts = new Map<string, { turns: number; nudgedAt: number }>()
107
- // The command below is handed no messages, only a session id, so the hook
108
- // keeps the newest transcript for it. One string per live session, at most
109
- // 32 KB, dropped once it has been sent.
110
- const snapshots = new Map<string, string>()
111
141
  const nudge = await ctx.session.hook('context', async (event) => {
112
142
  const state = counts.get(event.sessionID) ?? { turns: 0, nudgedAt: 0 }
113
143
  const turns = event.messages?.length ?? 0
114
- if (turns > state.turns) snapshots.set(event.sessionID, transcript(event.messages ?? []))
115
144
  const due = turns < state.turns || turns - state.nudgedAt >= NUDGE_EVERY
116
145
  counts.set(event.sessionID, { turns, nudgedAt: due ? turns : state.nudgedAt })
117
146
 
@@ -148,32 +177,51 @@ export default Plugin.define({
148
177
  })
149
178
 
150
179
  // A manual handover, for the sessions that end before they ever compact.
180
+ // One process hands a session over once, so a second command does not
181
+ // queue the same transcript again.
182
+ const sent = new Set<string>()
151
183
  const command = await ctx.command.transform((editor) => {
152
184
  editor.add({
153
185
  name: 'mimir-learn',
154
186
  description: 'Hand this session to Mimir now.',
155
187
  execute: async ({ sessionID }) => {
156
- const text = snapshots.get(sessionID) ?? ''
157
188
  let notice = NOTHING_TO_SEND
158
- if (text.length >= MIN_TRANSCRIPT) {
159
- const sent = await callTool(
160
- 'memory_learn',
161
- {
162
- repo: repo ?? undefined,
163
- transcript: text,
164
- hint: LEARN_NOW_HINT,
165
- client: 'opencode',
166
- session: sessionID,
167
- },
168
- { ...config, timeoutMs: 4000 },
169
- ).catch(() => null)
170
- const queued = sent?.ok ? (sent.result as { queued?: boolean }).queued : undefined
171
- if (!sent?.ok) notice = UNREACHABLE
172
- else if (queued === false) notice = QUEUE_OFF
173
- else {
174
- notice = SENT
175
- // Kept on failure above, so a retry has something to send.
176
- snapshots.delete(sessionID)
189
+ if (sent.has(sessionID)) {
190
+ notice = ALREADY
191
+ } else {
192
+ let history: readonly unknown[] | null = null
193
+ try {
194
+ history = await ctx.session.context({ sessionID })
195
+ } catch {
196
+ history = null
197
+ }
198
+
199
+ // The manual command is explicit, so any real text is worth a
200
+ // model call. The 500 character floor is for automatic compaction.
201
+ if (!Array.isArray(history)) {
202
+ notice = UNREADABLE
203
+ } else {
204
+ const text = sessionTranscript(history)
205
+ if (text.trim() !== '') {
206
+ const result = await callTool(
207
+ 'memory_learn',
208
+ {
209
+ repo: repo ?? undefined,
210
+ transcript: text,
211
+ hint: LEARN_NOW_HINT,
212
+ client: 'opencode',
213
+ session: sessionID,
214
+ },
215
+ { ...config, timeoutMs: 4000 },
216
+ ).catch(() => null)
217
+ const queued = result?.ok ? (result.result as { queued?: boolean }).queued : undefined
218
+ if (!result?.ok) notice = UNREACHABLE
219
+ else if (queued === false) notice = QUEUE_OFF
220
+ else {
221
+ notice = SENT
222
+ sent.add(sessionID)
223
+ }
224
+ }
177
225
  }
178
226
  }
179
227
  await ctx.session.synthetic({ sessionID, text: notice }).catch(() => null)