@mojoaar/opencode-mimir 0.29.2 → 0.29.3
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 +6 -0
- package/dist/index.js +75 -23
- package/package.json +1 -1
- package/src/index.ts +73 -25
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 (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
session
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
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 (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
else
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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)
|