ei-tui 0.1.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/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +63 -0
- package/src/README.md +96 -0
- package/src/cli/README.md +47 -0
- package/src/cli/commands/facts.ts +25 -0
- package/src/cli/commands/people.ts +25 -0
- package/src/cli/commands/quotes.ts +19 -0
- package/src/cli/commands/topics.ts +25 -0
- package/src/cli/commands/traits.ts +25 -0
- package/src/cli/retrieval.ts +269 -0
- package/src/cli.ts +176 -0
- package/src/core/AGENTS.md +104 -0
- package/src/core/embedding-service.ts +241 -0
- package/src/core/handlers/index.ts +1057 -0
- package/src/core/index.ts +4 -0
- package/src/core/llm-client.ts +265 -0
- package/src/core/model-context-windows.ts +49 -0
- package/src/core/orchestrators/ceremony.ts +500 -0
- package/src/core/orchestrators/extraction-chunker.ts +138 -0
- package/src/core/orchestrators/human-extraction.ts +457 -0
- package/src/core/orchestrators/index.ts +28 -0
- package/src/core/orchestrators/persona-generation.ts +76 -0
- package/src/core/orchestrators/persona-topics.ts +117 -0
- package/src/core/personas/index.ts +5 -0
- package/src/core/personas/opencode-agent.ts +81 -0
- package/src/core/processor.ts +1413 -0
- package/src/core/queue-processor.ts +197 -0
- package/src/core/state/checkpoints.ts +68 -0
- package/src/core/state/human.ts +176 -0
- package/src/core/state/index.ts +5 -0
- package/src/core/state/personas.ts +217 -0
- package/src/core/state/queue.ts +144 -0
- package/src/core/state-manager.ts +347 -0
- package/src/core/types.ts +421 -0
- package/src/core/utils/decay.ts +33 -0
- package/src/index.ts +1 -0
- package/src/integrations/opencode/importer.ts +896 -0
- package/src/integrations/opencode/index.ts +16 -0
- package/src/integrations/opencode/json-reader.ts +304 -0
- package/src/integrations/opencode/reader-factory.ts +35 -0
- package/src/integrations/opencode/sqlite-reader.ts +189 -0
- package/src/integrations/opencode/types.ts +244 -0
- package/src/prompts/AGENTS.md +62 -0
- package/src/prompts/ceremony/description-check.ts +47 -0
- package/src/prompts/ceremony/expire.ts +30 -0
- package/src/prompts/ceremony/explore.ts +60 -0
- package/src/prompts/ceremony/index.ts +11 -0
- package/src/prompts/ceremony/types.ts +42 -0
- package/src/prompts/generation/descriptions.ts +91 -0
- package/src/prompts/generation/index.ts +15 -0
- package/src/prompts/generation/persona.ts +155 -0
- package/src/prompts/generation/seeds.ts +31 -0
- package/src/prompts/generation/types.ts +47 -0
- package/src/prompts/heartbeat/check.ts +179 -0
- package/src/prompts/heartbeat/ei.ts +208 -0
- package/src/prompts/heartbeat/index.ts +15 -0
- package/src/prompts/heartbeat/types.ts +70 -0
- package/src/prompts/human/fact-scan.ts +152 -0
- package/src/prompts/human/index.ts +32 -0
- package/src/prompts/human/item-match.ts +74 -0
- package/src/prompts/human/item-update.ts +322 -0
- package/src/prompts/human/person-scan.ts +115 -0
- package/src/prompts/human/topic-scan.ts +135 -0
- package/src/prompts/human/trait-scan.ts +115 -0
- package/src/prompts/human/types.ts +127 -0
- package/src/prompts/index.ts +90 -0
- package/src/prompts/message-utils.ts +39 -0
- package/src/prompts/persona/index.ts +16 -0
- package/src/prompts/persona/topics-match.ts +69 -0
- package/src/prompts/persona/topics-scan.ts +98 -0
- package/src/prompts/persona/topics-update.ts +157 -0
- package/src/prompts/persona/traits.ts +117 -0
- package/src/prompts/persona/types.ts +74 -0
- package/src/prompts/response/index.ts +147 -0
- package/src/prompts/response/sections.ts +355 -0
- package/src/prompts/response/types.ts +38 -0
- package/src/prompts/validation/ei.ts +93 -0
- package/src/prompts/validation/index.ts +6 -0
- package/src/prompts/validation/types.ts +22 -0
- package/src/storage/crypto.ts +96 -0
- package/src/storage/index.ts +5 -0
- package/src/storage/interface.ts +9 -0
- package/src/storage/local.ts +79 -0
- package/src/storage/merge.ts +69 -0
- package/src/storage/remote.ts +145 -0
- package/src/templates/welcome.ts +91 -0
- package/tui/README.md +62 -0
- package/tui/bunfig.toml +4 -0
- package/tui/src/app.tsx +55 -0
- package/tui/src/commands/archive.tsx +93 -0
- package/tui/src/commands/context.tsx +124 -0
- package/tui/src/commands/delete.tsx +71 -0
- package/tui/src/commands/details.tsx +41 -0
- package/tui/src/commands/editor.tsx +46 -0
- package/tui/src/commands/help.tsx +12 -0
- package/tui/src/commands/me.tsx +145 -0
- package/tui/src/commands/model.ts +47 -0
- package/tui/src/commands/new.ts +31 -0
- package/tui/src/commands/pause.ts +46 -0
- package/tui/src/commands/persona.tsx +58 -0
- package/tui/src/commands/provider.tsx +124 -0
- package/tui/src/commands/quit.ts +22 -0
- package/tui/src/commands/quotes.tsx +172 -0
- package/tui/src/commands/registry.test.ts +137 -0
- package/tui/src/commands/registry.ts +130 -0
- package/tui/src/commands/resume.ts +39 -0
- package/tui/src/commands/setsync.tsx +43 -0
- package/tui/src/commands/settings.tsx +83 -0
- package/tui/src/components/ConfirmOverlay.tsx +51 -0
- package/tui/src/components/ConflictOverlay.tsx +78 -0
- package/tui/src/components/HelpOverlay.tsx +69 -0
- package/tui/src/components/Layout.tsx +24 -0
- package/tui/src/components/MessageList.tsx +174 -0
- package/tui/src/components/PersonaListOverlay.tsx +186 -0
- package/tui/src/components/PromptInput.tsx +145 -0
- package/tui/src/components/ProviderListOverlay.tsx +208 -0
- package/tui/src/components/QuotesOverlay.tsx +157 -0
- package/tui/src/components/Sidebar.tsx +95 -0
- package/tui/src/components/StatusBar.tsx +77 -0
- package/tui/src/components/WelcomeOverlay.tsx +73 -0
- package/tui/src/context/ei.tsx +623 -0
- package/tui/src/context/keyboard.tsx +164 -0
- package/tui/src/context/overlay.tsx +53 -0
- package/tui/src/index.tsx +8 -0
- package/tui/src/storage/file.ts +185 -0
- package/tui/src/util/duration.ts +32 -0
- package/tui/src/util/editor.ts +188 -0
- package/tui/src/util/logger.ts +109 -0
- package/tui/src/util/persona-editor.tsx +181 -0
- package/tui/src/util/provider-editor.tsx +168 -0
- package/tui/src/util/syntax.ts +35 -0
- package/tui/src/util/yaml-serializers.ts +755 -0
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
import type { StateManager } from "../../core/state-manager.js";
|
|
2
|
+
import type { Ei_Interface, Topic, Message, ContextStatus } from "../../core/types.js";
|
|
3
|
+
import { MESSAGE_MIN_COUNT, MESSAGE_MAX_AGE_DAYS } from "../../core/types.js";
|
|
4
|
+
import type { IOpenCodeReader, OpenCodeSession, OpenCodeMessage } from "./types.js";
|
|
5
|
+
import { UTILITY_AGENTS, AGENT_TO_AGENT_PREFIXES } from "./types.js";
|
|
6
|
+
import { createOpenCodeReader } from "./reader-factory.js";
|
|
7
|
+
import { ensureAgentPersona } from "../../core/personas/opencode-agent.js";
|
|
8
|
+
import {
|
|
9
|
+
queueDirectTopicUpdate,
|
|
10
|
+
queueAllScans,
|
|
11
|
+
type ExtractionContext,
|
|
12
|
+
} from "../../core/orchestrators/human-extraction.js";
|
|
13
|
+
import { resolveTokenLimit } from "../../core/llm-client.js";
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// Constants
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
const OPENCODE_TOPIC_GROUPS = ["General", "Coding", "OpenCode"];
|
|
20
|
+
|
|
21
|
+
/** Max extraction calls per archive scan cycle (bounds queue flooding). */
|
|
22
|
+
const ARCHIVE_SCAN_MAX_CALLS = 50;
|
|
23
|
+
const CHARS_PER_TOKEN = 4;
|
|
24
|
+
const MIN_EXTRACTION_TOKENS = 10000;
|
|
25
|
+
const EXTRACTION_BUDGET_RATIO = 0.75;
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// Transient Types (used only during import analysis, never persisted)
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
interface MiniMessage {
|
|
32
|
+
id: string;
|
|
33
|
+
timestamp: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ExternalMessage extends MiniMessage {
|
|
37
|
+
isExternal: true;
|
|
38
|
+
sessionId: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// Export Types
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
interface SessionAgentMessages {
|
|
46
|
+
sessionId: string;
|
|
47
|
+
agentName: string;
|
|
48
|
+
personaId?: string;
|
|
49
|
+
messages: OpenCodeMessage[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ImportResult {
|
|
53
|
+
sessionsProcessed: number;
|
|
54
|
+
topicsCreated: number;
|
|
55
|
+
topicsUpdated: number;
|
|
56
|
+
messagesImported: number;
|
|
57
|
+
messagesPruned: number;
|
|
58
|
+
personasCreated: string[];
|
|
59
|
+
topicUpdatesQueued: number;
|
|
60
|
+
extractionScansQueued: number;
|
|
61
|
+
partialSessionsFound: number;
|
|
62
|
+
archiveScansQueued: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface OpenCodeImporterOptions {
|
|
66
|
+
stateManager: StateManager;
|
|
67
|
+
interface?: Ei_Interface;
|
|
68
|
+
reader?: IOpenCodeReader;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// =============================================================================
|
|
72
|
+
// Utility Functions
|
|
73
|
+
// =============================================================================
|
|
74
|
+
|
|
75
|
+
function isAgentToAgentMessage(content: string): boolean {
|
|
76
|
+
const trimmed = content.trimStart();
|
|
77
|
+
return AGENT_TO_AGENT_PREFIXES.some(prefix => trimmed.startsWith(prefix));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function convertToEiMessage(ocMsg: OpenCodeMessage): Message {
|
|
81
|
+
return {
|
|
82
|
+
id: ocMsg.id,
|
|
83
|
+
role: ocMsg.role === "user" ? "human" : "system",
|
|
84
|
+
content: ocMsg.content,
|
|
85
|
+
timestamp: ocMsg.timestamp,
|
|
86
|
+
read: true,
|
|
87
|
+
context_status: "default" as ContextStatus,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Convert OC message to Ei Message with all extraction flags pre-set. */
|
|
92
|
+
function convertToPreMarkedEiMessage(ocMsg: OpenCodeMessage): Message {
|
|
93
|
+
return {
|
|
94
|
+
...convertToEiMessage(ocMsg),
|
|
95
|
+
f: true,
|
|
96
|
+
r: true,
|
|
97
|
+
p: true,
|
|
98
|
+
o: true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function filterRelevantMessages(messages: OpenCodeMessage[]): OpenCodeMessage[] {
|
|
103
|
+
return messages.filter(msg => {
|
|
104
|
+
if (UTILITY_AGENTS.includes(msg.agent as typeof UTILITY_AGENTS[number])) return false;
|
|
105
|
+
if (isAgentToAgentMessage(msg.content)) return false;
|
|
106
|
+
return true;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function estimateTokensForMessages(messages: OpenCodeMessage[]): number {
|
|
111
|
+
return messages.reduce(
|
|
112
|
+
(sum, msg) => sum + Math.ceil(msg.content.length / CHARS_PER_TOKEN) + 4,
|
|
113
|
+
0
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// =============================================================================
|
|
118
|
+
// Main Import Function
|
|
119
|
+
// =============================================================================
|
|
120
|
+
|
|
121
|
+
export async function importOpenCodeSessions(
|
|
122
|
+
since: Date,
|
|
123
|
+
options: OpenCodeImporterOptions
|
|
124
|
+
): Promise<ImportResult> {
|
|
125
|
+
const { stateManager, interface: eiInterface } = options;
|
|
126
|
+
const reader = options.reader ?? await createOpenCodeReader();
|
|
127
|
+
|
|
128
|
+
const result: ImportResult = {
|
|
129
|
+
sessionsProcessed: 0,
|
|
130
|
+
topicsCreated: 0,
|
|
131
|
+
topicsUpdated: 0,
|
|
132
|
+
messagesImported: 0,
|
|
133
|
+
messagesPruned: 0,
|
|
134
|
+
personasCreated: [],
|
|
135
|
+
topicUpdatesQueued: 0,
|
|
136
|
+
extractionScansQueued: 0,
|
|
137
|
+
partialSessionsFound: 0,
|
|
138
|
+
archiveScansQueued: 0,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// ─── Step 1: Pull ALL sessions → Verify/Write topics ─────────────────
|
|
142
|
+
// new Date(0) ensures we always see all sessions for topic verification,
|
|
143
|
+
// regardless of when last sync was.
|
|
144
|
+
const allSessions = await reader.getSessionsUpdatedSince(new Date(0));
|
|
145
|
+
const primarySessions = allSessions.filter(s => !s.parentId);
|
|
146
|
+
|
|
147
|
+
for (const session of primarySessions) {
|
|
148
|
+
const topicResult = await ensureSessionTopic(session, reader, stateManager);
|
|
149
|
+
if (topicResult === "created") result.topicsCreated++;
|
|
150
|
+
else if (topicResult === "updated") result.topicsUpdated++;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Step 2: Pull messages since last_sync, group by agent ───────────
|
|
154
|
+
const sinceMs = since.getTime();
|
|
155
|
+
const updatedSessions = primarySessions.filter(s => s.time.updated > sinceMs);
|
|
156
|
+
|
|
157
|
+
console.log(
|
|
158
|
+
`[OpenCode] Found ${primarySessions.length} total sessions, ` +
|
|
159
|
+
`${updatedSessions.length} updated since ${since.toISOString()}`
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const agentsForPersona = new Set<string>();
|
|
163
|
+
const sessionAgentBatches: SessionAgentMessages[] = [];
|
|
164
|
+
|
|
165
|
+
for (const session of updatedSessions) {
|
|
166
|
+
result.sessionsProcessed++;
|
|
167
|
+
const messages = await reader.getMessagesForSession(session.id, since);
|
|
168
|
+
const relevant = filterRelevantMessages(messages);
|
|
169
|
+
const messagesByAgent = new Map<string, OpenCodeMessage[]>();
|
|
170
|
+
|
|
171
|
+
for (const msg of relevant) {
|
|
172
|
+
agentsForPersona.add(msg.agent);
|
|
173
|
+
const existing = messagesByAgent.get(msg.agent) ?? [];
|
|
174
|
+
existing.push(msg);
|
|
175
|
+
messagesByAgent.set(msg.agent, existing);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const [agentName, agentMessages] of messagesByAgent) {
|
|
179
|
+
sessionAgentBatches.push({
|
|
180
|
+
sessionId: session.id,
|
|
181
|
+
agentName,
|
|
182
|
+
messages: agentMessages,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Steps 3-8: Only run if we have new messages to process ──────────
|
|
188
|
+
let isFirstImport = false;
|
|
189
|
+
|
|
190
|
+
if (sessionAgentBatches.length > 0) {
|
|
191
|
+
// ─── Step 3: Ensure personas exist ─────────────────────────────────
|
|
192
|
+
const agentNameToPersonaId = new Map<string, string>();
|
|
193
|
+
|
|
194
|
+
for (const agentName of agentsForPersona) {
|
|
195
|
+
let existing = stateManager.persona_getByName(agentName);
|
|
196
|
+
if (!existing) {
|
|
197
|
+
existing = await ensureAgentPersona(agentName, {
|
|
198
|
+
stateManager,
|
|
199
|
+
interface: eiInterface,
|
|
200
|
+
reader,
|
|
201
|
+
});
|
|
202
|
+
result.personasCreated.push(agentName);
|
|
203
|
+
}
|
|
204
|
+
agentNameToPersonaId.set(agentName, existing.id);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const batch of sessionAgentBatches) {
|
|
208
|
+
batch.personaId = agentNameToPersonaId.get(batch.agentName);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Build reverse mapping: personaId → agent names
|
|
212
|
+
const personaIdToAgentNames = new Map<string, Set<string>>();
|
|
213
|
+
for (const [agentName, personaId] of agentNameToPersonaId) {
|
|
214
|
+
const names = personaIdToAgentNames.get(personaId) ?? new Set();
|
|
215
|
+
names.add(agentName);
|
|
216
|
+
personaIdToAgentNames.set(personaId, names);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── Steps 4-5: Merge/Dedup/Prune per persona ─────────────────────
|
|
220
|
+
const batchesByPersona = new Map<string, SessionAgentMessages[]>();
|
|
221
|
+
for (const batch of sessionAgentBatches) {
|
|
222
|
+
if (!batch.personaId) continue;
|
|
223
|
+
const existing = batchesByPersona.get(batch.personaId) ?? [];
|
|
224
|
+
existing.push(batch);
|
|
225
|
+
batchesByPersona.set(batch.personaId, existing);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Track surviving new messages per persona for partial session detection
|
|
229
|
+
const survivingNewByPersona = new Map<string, OpenCodeMessage[]>();
|
|
230
|
+
|
|
231
|
+
for (const [personaId, personaBatches] of batchesByPersona) {
|
|
232
|
+
// Combine all new OC messages for this persona
|
|
233
|
+
const allNew: OpenCodeMessage[] = [];
|
|
234
|
+
for (const batch of personaBatches) {
|
|
235
|
+
allNew.push(...batch.messages);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Get existing persona messages for dedup + pruning analysis
|
|
239
|
+
const existingMessages = stateManager.messages_get(personaId);
|
|
240
|
+
const existingIds = new Set(existingMessages.map(m => m.id));
|
|
241
|
+
|
|
242
|
+
// Dedup: only messages not already in state
|
|
243
|
+
const genuinelyNew = allNew.filter(m => !existingIds.has(m.id));
|
|
244
|
+
if (genuinelyNew.length === 0) continue;
|
|
245
|
+
|
|
246
|
+
// Build merged list for pruning analysis
|
|
247
|
+
const merged: (MiniMessage | ExternalMessage)[] = [
|
|
248
|
+
...existingMessages.map(m => ({ id: m.id, timestamp: m.timestamp })),
|
|
249
|
+
...genuinelyNew.map(m => ({
|
|
250
|
+
id: m.id,
|
|
251
|
+
timestamp: m.timestamp,
|
|
252
|
+
isExternal: true as const,
|
|
253
|
+
sessionId: m.sessionId,
|
|
254
|
+
})),
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
// Prune
|
|
258
|
+
const keptIds = pruneImportMessages(merged, existingMessages);
|
|
259
|
+
const keptSet = new Set(keptIds);
|
|
260
|
+
|
|
261
|
+
// ─── Step 6: Write to persona state ────────────────────────────
|
|
262
|
+
const survivingNew: OpenCodeMessage[] = [];
|
|
263
|
+
for (const ocMsg of genuinelyNew) {
|
|
264
|
+
if (keptSet.has(ocMsg.id)) {
|
|
265
|
+
stateManager.messages_append(personaId, convertToEiMessage(ocMsg));
|
|
266
|
+
survivingNew.push(ocMsg);
|
|
267
|
+
result.messagesImported++;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const prunedExistingIds = existingMessages
|
|
272
|
+
.filter(m => !keptSet.has(m.id))
|
|
273
|
+
.map(m => m.id);
|
|
274
|
+
if (prunedExistingIds.length > 0) {
|
|
275
|
+
stateManager.messages_remove(personaId, prunedExistingIds);
|
|
276
|
+
result.messagesPruned += prunedExistingIds.length;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
stateManager.messages_sort(personaId);
|
|
280
|
+
stateManager.persona_update(personaId, {
|
|
281
|
+
last_activity: new Date().toISOString(),
|
|
282
|
+
});
|
|
283
|
+
eiInterface?.onMessageAdded?.(personaId);
|
|
284
|
+
|
|
285
|
+
if (survivingNew.length > 0) {
|
|
286
|
+
survivingNewByPersona.set(personaId, survivingNew);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (result.topicsCreated > 0 || result.topicsUpdated > 0) {
|
|
291
|
+
eiInterface?.onHumanUpdated?.();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─── Step 7: Detect partial sessions → SessionUpdate ─────────────
|
|
295
|
+
for (const [personaId, survivingMsgs] of survivingNewByPersona) {
|
|
296
|
+
const agentNames = personaIdToAgentNames.get(personaId) ?? new Set();
|
|
297
|
+
const persona = stateManager.persona_getById(personaId);
|
|
298
|
+
if (!persona) continue;
|
|
299
|
+
|
|
300
|
+
// Group surviving new messages by session
|
|
301
|
+
const bySession = new Map<string, OpenCodeMessage[]>();
|
|
302
|
+
for (const msg of survivingMsgs) {
|
|
303
|
+
const existing = bySession.get(msg.sessionId) ?? [];
|
|
304
|
+
existing.push(msg);
|
|
305
|
+
bySession.set(msg.sessionId, existing);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
for (const [sessionId] of bySession) {
|
|
309
|
+
const isPartial = await checkPartialSession(
|
|
310
|
+
personaId, sessionId, agentNames, reader, stateManager
|
|
311
|
+
);
|
|
312
|
+
if (isPartial) {
|
|
313
|
+
result.partialSessionsFound++;
|
|
314
|
+
const scans = await processSessionUpdate(
|
|
315
|
+
personaId, persona.display_name, sessionId, agentNames,
|
|
316
|
+
reader, stateManager
|
|
317
|
+
);
|
|
318
|
+
result.extractionScansQueued += scans;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ─── Step 8: Queue topic updates + extraction scans ──────────────
|
|
324
|
+
isFirstImport = initializeExtractionPointIfNeeded(
|
|
325
|
+
sessionAgentBatches, stateManager
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
if (result.messagesImported > 0) {
|
|
329
|
+
// Topic description updates for sessions with new messages
|
|
330
|
+
result.topicUpdatesQueued = queueTopicUpdatesForBatches(
|
|
331
|
+
sessionAgentBatches, stateManager
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
if (isFirstImport) {
|
|
335
|
+
// First import: all 4 extraction types on ALL surviving messages
|
|
336
|
+
result.extractionScansQueued += queueAllExtractionsForAllMessages(
|
|
337
|
+
batchesByPersona, stateManager
|
|
338
|
+
);
|
|
339
|
+
console.log(
|
|
340
|
+
`[OpenCode] First import: queued extraction scans for ` +
|
|
341
|
+
`${batchesByPersona.size} persona(s)`
|
|
342
|
+
);
|
|
343
|
+
} else {
|
|
344
|
+
// Normal sync: all 4 extraction types on newly imported messages
|
|
345
|
+
result.extractionScansQueued += queueExtractionsForNewMessages(
|
|
346
|
+
batchesByPersona, stateManager
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
console.log(
|
|
351
|
+
`[OpenCode] Queued ${result.topicUpdatesQueued} topic updates, ` +
|
|
352
|
+
`${result.extractionScansQueued} extraction scans`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
} else if (result.topicsCreated > 0 || result.topicsUpdated > 0) {
|
|
356
|
+
eiInterface?.onHumanUpdated?.();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ─── Step 9: Archive scan (skip on first import to avoid queue flood) ─
|
|
360
|
+
if (!isFirstImport) {
|
|
361
|
+
const archiveResult = await processArchiveScan(
|
|
362
|
+
stateManager, reader, eiInterface
|
|
363
|
+
);
|
|
364
|
+
result.archiveScansQueued = archiveResult.scansQueued;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// =============================================================================
|
|
371
|
+
// Pruning
|
|
372
|
+
// =============================================================================
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Determine which messages to keep after merging existing + new external messages.
|
|
376
|
+
*
|
|
377
|
+
* Rules:
|
|
378
|
+
* - Always keep at least minMessages (even if they're ancient)
|
|
379
|
+
* - Remove messages older than maxAgeDays IF:
|
|
380
|
+
* - External (never in state → safe to drop), OR
|
|
381
|
+
* - Fully extracted ([p,r,o,f] all true → knowledge already captured)
|
|
382
|
+
* - Messages that are old but NOT external and NOT fully extracted are KEPT
|
|
383
|
+
* (they still have knowledge to extract)
|
|
384
|
+
*
|
|
385
|
+
* @returns Array of message IDs to keep
|
|
386
|
+
*/
|
|
387
|
+
export function pruneImportMessages(
|
|
388
|
+
merged: (MiniMessage | ExternalMessage)[],
|
|
389
|
+
existingMessages: Message[],
|
|
390
|
+
minMessages: number = MESSAGE_MIN_COUNT,
|
|
391
|
+
maxAgeDays: number = MESSAGE_MAX_AGE_DAYS
|
|
392
|
+
): string[] {
|
|
393
|
+
if (merged.length <= minMessages) return merged.map(m => m.id);
|
|
394
|
+
|
|
395
|
+
const cutoffMs = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
|
|
396
|
+
const sorted = [...merged].sort(
|
|
397
|
+
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
const existingById = new Map(existingMessages.map(m => [m.id, m]));
|
|
401
|
+
|
|
402
|
+
const toRemove: string[] = [];
|
|
403
|
+
for (const m of sorted) {
|
|
404
|
+
if (merged.length - toRemove.length <= minMessages) break;
|
|
405
|
+
|
|
406
|
+
const isOld = new Date(m.timestamp).getTime() < cutoffMs;
|
|
407
|
+
if (!isOld) break; // Sorted by time — no more old messages after this
|
|
408
|
+
|
|
409
|
+
const isExternal = "isExternal" in m && (m as ExternalMessage).isExternal;
|
|
410
|
+
const existing = existingById.get(m.id);
|
|
411
|
+
const fullyExtracted = existing?.f && existing?.r && existing?.o && existing?.p;
|
|
412
|
+
|
|
413
|
+
if (isExternal || fullyExtracted) {
|
|
414
|
+
toRemove.push(m.id);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const removeSet = new Set(toRemove);
|
|
419
|
+
return merged.filter(m => !removeSet.has(m.id)).map(m => m.id);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// =============================================================================
|
|
423
|
+
// Partial Session Detection & SessionUpdate
|
|
424
|
+
// =============================================================================
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Check if a session is "partial" — some messages in state but not all.
|
|
428
|
+
* This happens when an old session gets new messages ("necro session").
|
|
429
|
+
*/
|
|
430
|
+
async function checkPartialSession(
|
|
431
|
+
personaId: string,
|
|
432
|
+
sessionId: string,
|
|
433
|
+
agentNames: Set<string>,
|
|
434
|
+
reader: IOpenCodeReader,
|
|
435
|
+
stateManager: StateManager
|
|
436
|
+
): Promise<boolean> {
|
|
437
|
+
const allSessionMsgs = await reader.getMessagesForSession(sessionId);
|
|
438
|
+
const relevantMsgs = filterRelevantMessages(
|
|
439
|
+
allSessionMsgs.filter(m => agentNames.has(m.agent))
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
const personaMsgs = stateManager.messages_get(personaId);
|
|
443
|
+
const stateIds = new Set(personaMsgs.map(m => m.id));
|
|
444
|
+
|
|
445
|
+
return relevantMsgs.some(m => !stateIds.has(m.id));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Handle a partial session by injecting missing messages and queuing extraction.
|
|
450
|
+
*
|
|
451
|
+
* Old (missing) messages are injected with [p,r,o,f]=true — they serve as
|
|
452
|
+
* context only and are already "fully extracted" so ceremony can prune them.
|
|
453
|
+
* The new messages (already in state, NOT pre-marked) go to messages_analyze
|
|
454
|
+
* for actual extraction.
|
|
455
|
+
*/
|
|
456
|
+
async function processSessionUpdate(
|
|
457
|
+
personaId: string,
|
|
458
|
+
personaDisplayName: string,
|
|
459
|
+
sessionId: string,
|
|
460
|
+
agentNames: Set<string>,
|
|
461
|
+
reader: IOpenCodeReader,
|
|
462
|
+
stateManager: StateManager
|
|
463
|
+
): Promise<number> {
|
|
464
|
+
const allSessionMsgs = await reader.getMessagesForSession(sessionId);
|
|
465
|
+
const relevantMsgs = filterRelevantMessages(
|
|
466
|
+
allSessionMsgs.filter(m => agentNames.has(m.agent))
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
if (relevantMsgs.length === 0) return 0;
|
|
470
|
+
|
|
471
|
+
// Find which messages are missing from state
|
|
472
|
+
const personaMsgs = stateManager.messages_get(personaId);
|
|
473
|
+
const stateIds = new Set(personaMsgs.map(m => m.id));
|
|
474
|
+
const missingMsgs = relevantMsgs.filter(m => !stateIds.has(m.id));
|
|
475
|
+
|
|
476
|
+
// Inject missing messages PRE-MARKED as fully extracted.
|
|
477
|
+
// They're context only — ceremony will prune them (old + [p,r,o,f]=true).
|
|
478
|
+
for (const ocMsg of missingMsgs) {
|
|
479
|
+
stateManager.messages_append(personaId, convertToPreMarkedEiMessage(ocMsg));
|
|
480
|
+
}
|
|
481
|
+
stateManager.messages_sort(personaId);
|
|
482
|
+
|
|
483
|
+
console.log(
|
|
484
|
+
`[OpenCode] SessionUpdate: injected ${missingMsgs.length} pre-marked ` +
|
|
485
|
+
`context messages for session ${sessionId}`
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
// Build extraction context:
|
|
489
|
+
// - context = old injected messages (pre-marked, provide session background)
|
|
490
|
+
// - analyze = new messages already in state (need actual extraction)
|
|
491
|
+
const allInState = stateManager.messages_get(personaId);
|
|
492
|
+
const sessionMsgIds = new Set(relevantMsgs.map(m => m.id));
|
|
493
|
+
const missingIds = new Set(missingMsgs.map(m => m.id));
|
|
494
|
+
|
|
495
|
+
const contextMsgs = allInState.filter(
|
|
496
|
+
m => sessionMsgIds.has(m.id) && missingIds.has(m.id)
|
|
497
|
+
);
|
|
498
|
+
const analyzeMsgs = allInState.filter(
|
|
499
|
+
m => sessionMsgIds.has(m.id) && !missingIds.has(m.id)
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
if (analyzeMsgs.length === 0) return 0;
|
|
503
|
+
|
|
504
|
+
const context: ExtractionContext = {
|
|
505
|
+
personaId,
|
|
506
|
+
personaDisplayName,
|
|
507
|
+
messages_context: contextMsgs,
|
|
508
|
+
messages_analyze: analyzeMsgs,
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
queueAllScans(context, stateManager);
|
|
512
|
+
|
|
513
|
+
const human = stateManager.getHuman();
|
|
514
|
+
const topic = human.topics.find(t => t.id === sessionId);
|
|
515
|
+
if (topic) {
|
|
516
|
+
queueDirectTopicUpdate(topic, context, stateManager);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return 4;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// =============================================================================
|
|
523
|
+
// Archive Scan
|
|
524
|
+
// =============================================================================
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Process old sessions from SQLite for extraction.
|
|
528
|
+
*
|
|
529
|
+
* Replaces gradual-extraction.ts entirely. Reads sessions between
|
|
530
|
+
* extraction_point and the 14-day cutoff, injects their messages into
|
|
531
|
+
* persona state, and queues all 4 extraction types.
|
|
532
|
+
*
|
|
533
|
+
* Unlike SessionUpdate, archive messages are NOT pre-marked — they need
|
|
534
|
+
* actual extraction. The queue-empty gate on ceremony (in ceremony.ts)
|
|
535
|
+
* prevents premature pruning while extraction is pending.
|
|
536
|
+
*
|
|
537
|
+
* Bounded by a token budget to prevent queue flooding.
|
|
538
|
+
*/
|
|
539
|
+
async function processArchiveScan(
|
|
540
|
+
stateManager: StateManager,
|
|
541
|
+
reader: IOpenCodeReader,
|
|
542
|
+
eiInterface?: Ei_Interface
|
|
543
|
+
): Promise<{ scansQueued: number; newExtractionPoint: string | null }> {
|
|
544
|
+
const human = stateManager.getHuman();
|
|
545
|
+
const extractionPoint = human.settings?.opencode?.extraction_point;
|
|
546
|
+
|
|
547
|
+
if (!extractionPoint || extractionPoint === "done") {
|
|
548
|
+
return { scansQueued: 0, newExtractionPoint: null };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const extractionPointMs = new Date(extractionPoint).getTime();
|
|
552
|
+
const cutoffMs = Date.now() - (MESSAGE_MAX_AGE_DAYS * 24 * 60 * 60 * 1000);
|
|
553
|
+
|
|
554
|
+
if (extractionPointMs >= cutoffMs) {
|
|
555
|
+
// Archive scan caught up to the primary window
|
|
556
|
+
updateExtractionPoint(stateManager, "done");
|
|
557
|
+
console.log(`[OpenCode] Archive scan complete — caught up to primary window`);
|
|
558
|
+
return { scansQueued: 0, newExtractionPoint: "done" };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const sessions = await reader.getSessionsInRange(
|
|
562
|
+
new Date(extractionPointMs),
|
|
563
|
+
new Date(cutoffMs)
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
if (sessions.length === 0) {
|
|
567
|
+
// No sessions in archive range — advance to cutoff
|
|
568
|
+
const newPoint = new Date(cutoffMs).toISOString();
|
|
569
|
+
updateExtractionPoint(stateManager, newPoint);
|
|
570
|
+
return { scansQueued: 0, newExtractionPoint: newPoint };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Sort chronologically (getSessionsInRange should return ASC, but ensure)
|
|
574
|
+
sessions.sort((a, b) => a.time.updated - b.time.updated);
|
|
575
|
+
|
|
576
|
+
// Build set of all message IDs currently in state for fast lookups
|
|
577
|
+
const openCodePersonas = stateManager.persona_getAll()
|
|
578
|
+
.filter(p => p.group_primary === "OpenCode");
|
|
579
|
+
const allStateMessageIds = new Set<string>();
|
|
580
|
+
for (const persona of openCodePersonas) {
|
|
581
|
+
for (const m of stateManager.messages_get(persona.id)) {
|
|
582
|
+
allStateMessageIds.add(m.id);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
let tokenBudget = 0;
|
|
587
|
+
const modelTokenLimit = resolveTokenLimit(human.settings?.default_model, human.settings?.accounts);
|
|
588
|
+
const perCallBudget = Math.max(MIN_EXTRACTION_TOKENS, Math.floor(modelTokenLimit * EXTRACTION_BUDGET_RATIO));
|
|
589
|
+
const tokenLimit = ARCHIVE_SCAN_MAX_CALLS * perCallBudget;
|
|
590
|
+
let scansQueued = 0;
|
|
591
|
+
let lastProcessed: OpenCodeSession | null = null;
|
|
592
|
+
|
|
593
|
+
for (const session of sessions) {
|
|
594
|
+
const allMsgs = await reader.getMessagesForSession(session.id);
|
|
595
|
+
const relevant = filterRelevantMessages(allMsgs);
|
|
596
|
+
|
|
597
|
+
// Skip sessions whose messages are already in state
|
|
598
|
+
if (relevant.some(m => allStateMessageIds.has(m.id))) {
|
|
599
|
+
lastProcessed = session;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (relevant.length === 0) {
|
|
604
|
+
lastProcessed = session;
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Token budget check
|
|
609
|
+
const sessionTokens = estimateTokensForMessages(relevant);
|
|
610
|
+
tokenBudget += sessionTokens;
|
|
611
|
+
|
|
612
|
+
// Group by agent → persona
|
|
613
|
+
const byAgent = new Map<string, OpenCodeMessage[]>();
|
|
614
|
+
for (const msg of relevant) {
|
|
615
|
+
const existing = byAgent.get(msg.agent) ?? [];
|
|
616
|
+
existing.push(msg);
|
|
617
|
+
byAgent.set(msg.agent, existing);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
for (const [agentName, agentMsgs] of byAgent) {
|
|
621
|
+
// Resolve persona (create if needed — unlikely for archive but safe)
|
|
622
|
+
let persona = stateManager.persona_getByName(agentName);
|
|
623
|
+
if (!persona) {
|
|
624
|
+
persona = await ensureAgentPersona(agentName, {
|
|
625
|
+
stateManager,
|
|
626
|
+
interface: eiInterface,
|
|
627
|
+
reader,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Inject messages into persona state (NOT pre-marked — need extraction)
|
|
632
|
+
for (const ocMsg of agentMsgs) {
|
|
633
|
+
stateManager.messages_append(persona.id, convertToEiMessage(ocMsg));
|
|
634
|
+
allStateMessageIds.add(ocMsg.id);
|
|
635
|
+
}
|
|
636
|
+
stateManager.messages_sort(persona.id);
|
|
637
|
+
|
|
638
|
+
// Build extraction context from the injected messages
|
|
639
|
+
const injectedMsgs = stateManager.messages_get(persona.id)
|
|
640
|
+
.filter(m => agentMsgs.some(am => am.id === m.id));
|
|
641
|
+
|
|
642
|
+
const context: ExtractionContext = {
|
|
643
|
+
personaId: persona.id,
|
|
644
|
+
personaDisplayName: persona.display_name,
|
|
645
|
+
messages_context: [],
|
|
646
|
+
messages_analyze: injectedMsgs,
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
queueAllScans(context, stateManager);
|
|
650
|
+
scansQueued += 4;
|
|
651
|
+
|
|
652
|
+
const topic = human.topics.find(t => t.id === session.id);
|
|
653
|
+
if (topic) {
|
|
654
|
+
queueDirectTopicUpdate(topic, context, stateManager);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
lastProcessed = session;
|
|
659
|
+
|
|
660
|
+
if (tokenBudget >= tokenLimit) {
|
|
661
|
+
console.log(
|
|
662
|
+
`[OpenCode] Archive scan: token budget reached after ${scansQueued} scans`
|
|
663
|
+
);
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Advance extraction_point
|
|
669
|
+
if (lastProcessed) {
|
|
670
|
+
const newPoint = new Date(lastProcessed.time.updated).toISOString();
|
|
671
|
+
updateExtractionPoint(stateManager, newPoint);
|
|
672
|
+
console.log(
|
|
673
|
+
`[OpenCode] Archive scan: ${scansQueued} scans queued, ` +
|
|
674
|
+
`extraction_point → ${newPoint}`
|
|
675
|
+
);
|
|
676
|
+
return { scansQueued, newExtractionPoint: newPoint };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return { scansQueued: 0, newExtractionPoint: null };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// =============================================================================
|
|
683
|
+
// Extraction Queueing
|
|
684
|
+
// =============================================================================
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Queue topic description updates for sessions with new messages.
|
|
688
|
+
* Finds batch messages in persona state and builds ExtractionContext.
|
|
689
|
+
*/
|
|
690
|
+
function queueTopicUpdatesForBatches(
|
|
691
|
+
batches: SessionAgentMessages[],
|
|
692
|
+
stateManager: StateManager
|
|
693
|
+
): number {
|
|
694
|
+
const human = stateManager.getHuman();
|
|
695
|
+
let totalChunks = 0;
|
|
696
|
+
|
|
697
|
+
for (const batch of batches) {
|
|
698
|
+
if (!batch.personaId) continue;
|
|
699
|
+
|
|
700
|
+
const topic = human.topics.find(t => t.id === batch.sessionId);
|
|
701
|
+
if (!topic) continue;
|
|
702
|
+
|
|
703
|
+
const persona = stateManager.persona_getById(batch.personaId);
|
|
704
|
+
if (!persona) continue;
|
|
705
|
+
|
|
706
|
+
const allMessages = stateManager.messages_get(batch.personaId);
|
|
707
|
+
const batchMessageIds = new Set(batch.messages.map(m => m.id));
|
|
708
|
+
|
|
709
|
+
const analyzeStartIndex = allMessages.findIndex(m => batchMessageIds.has(m.id));
|
|
710
|
+
if (analyzeStartIndex === -1) continue;
|
|
711
|
+
|
|
712
|
+
const context: ExtractionContext = {
|
|
713
|
+
personaId: batch.personaId,
|
|
714
|
+
personaDisplayName: persona.display_name,
|
|
715
|
+
messages_context: allMessages.slice(0, analyzeStartIndex),
|
|
716
|
+
messages_analyze: allMessages.filter(m => batchMessageIds.has(m.id)),
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
if (context.messages_analyze.length === 0) continue;
|
|
720
|
+
|
|
721
|
+
totalChunks += queueDirectTopicUpdate(topic, context, stateManager);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return totalChunks;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Queue all 4 extraction types for newly imported messages (normal sync).
|
|
729
|
+
* Groups by persona to avoid duplicate scan queueing.
|
|
730
|
+
*/
|
|
731
|
+
function queueExtractionsForNewMessages(
|
|
732
|
+
batchesByPersona: Map<string, SessionAgentMessages[]>,
|
|
733
|
+
stateManager: StateManager
|
|
734
|
+
): number {
|
|
735
|
+
let scansQueued = 0;
|
|
736
|
+
|
|
737
|
+
for (const [personaId, personaBatches] of batchesByPersona) {
|
|
738
|
+
const persona = stateManager.persona_getById(personaId);
|
|
739
|
+
if (!persona) continue;
|
|
740
|
+
|
|
741
|
+
const allMessages = stateManager.messages_get(personaId);
|
|
742
|
+
|
|
743
|
+
// Combine all batch message IDs for this persona
|
|
744
|
+
const batchMessageIds = new Set<string>();
|
|
745
|
+
for (const batch of personaBatches) {
|
|
746
|
+
for (const m of batch.messages) {
|
|
747
|
+
batchMessageIds.add(m.id);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Find the new messages that are actually in state (survived pruning)
|
|
752
|
+
const analyzeMessages = allMessages.filter(m => batchMessageIds.has(m.id));
|
|
753
|
+
if (analyzeMessages.length === 0) continue;
|
|
754
|
+
|
|
755
|
+
const analyzeStartIndex = allMessages.findIndex(m => batchMessageIds.has(m.id));
|
|
756
|
+
const contextMessages = analyzeStartIndex > 0
|
|
757
|
+
? allMessages.slice(0, analyzeStartIndex)
|
|
758
|
+
: [];
|
|
759
|
+
|
|
760
|
+
const context: ExtractionContext = {
|
|
761
|
+
personaId,
|
|
762
|
+
personaDisplayName: persona.display_name,
|
|
763
|
+
messages_context: contextMessages,
|
|
764
|
+
messages_analyze: analyzeMessages,
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
queueAllScans(context, stateManager);
|
|
768
|
+
scansQueued += 4;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return scansQueued;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Queue all 4 extraction types on ALL surviving messages for every persona.
|
|
776
|
+
* Used on first import — the "fun" moment where extraction kicks off.
|
|
777
|
+
*/
|
|
778
|
+
function queueAllExtractionsForAllMessages(
|
|
779
|
+
batchesByPersona: Map<string, SessionAgentMessages[]>,
|
|
780
|
+
stateManager: StateManager
|
|
781
|
+
): number {
|
|
782
|
+
let scansQueued = 0;
|
|
783
|
+
|
|
784
|
+
for (const [personaId] of batchesByPersona) {
|
|
785
|
+
const persona = stateManager.persona_getById(personaId);
|
|
786
|
+
if (!persona) continue;
|
|
787
|
+
|
|
788
|
+
const allMessages = stateManager.messages_get(personaId);
|
|
789
|
+
if (allMessages.length === 0) continue;
|
|
790
|
+
|
|
791
|
+
const context: ExtractionContext = {
|
|
792
|
+
personaId,
|
|
793
|
+
personaDisplayName: persona.display_name,
|
|
794
|
+
messages_context: [],
|
|
795
|
+
messages_analyze: allMessages,
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
queueAllScans(context, stateManager);
|
|
799
|
+
scansQueued += 4;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return scansQueued;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// =============================================================================
|
|
806
|
+
// Topic Management
|
|
807
|
+
// =============================================================================
|
|
808
|
+
|
|
809
|
+
async function ensureSessionTopic(
|
|
810
|
+
session: OpenCodeSession,
|
|
811
|
+
reader: IOpenCodeReader,
|
|
812
|
+
stateManager: StateManager
|
|
813
|
+
): Promise<"created" | "updated" | "unchanged"> {
|
|
814
|
+
const human = stateManager.getHuman();
|
|
815
|
+
const existingTopic = human.topics.find((t) => t.id === session.id);
|
|
816
|
+
|
|
817
|
+
const firstAgent = await reader.getFirstAgent(session.id);
|
|
818
|
+
const learnedBy = firstAgent ?? "build";
|
|
819
|
+
|
|
820
|
+
if (existingTopic) {
|
|
821
|
+
if (existingTopic.name !== session.title) {
|
|
822
|
+
const updatedTopic: Topic = {
|
|
823
|
+
...existingTopic,
|
|
824
|
+
name: session.title,
|
|
825
|
+
last_updated: new Date().toISOString(),
|
|
826
|
+
};
|
|
827
|
+
stateManager.human_topic_upsert(updatedTopic);
|
|
828
|
+
return "updated";
|
|
829
|
+
}
|
|
830
|
+
return "unchanged";
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const newTopic: Topic = {
|
|
834
|
+
id: session.id,
|
|
835
|
+
name: session.title,
|
|
836
|
+
description: "",
|
|
837
|
+
sentiment: 0,
|
|
838
|
+
exposure_current: 0.5,
|
|
839
|
+
exposure_desired: 0.3,
|
|
840
|
+
persona_groups: OPENCODE_TOPIC_GROUPS,
|
|
841
|
+
learned_by: learnedBy,
|
|
842
|
+
last_updated: new Date().toISOString(),
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
stateManager.human_topic_upsert(newTopic);
|
|
846
|
+
return "created";
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// =============================================================================
|
|
850
|
+
// State Helpers
|
|
851
|
+
// =============================================================================
|
|
852
|
+
|
|
853
|
+
function initializeExtractionPointIfNeeded(
|
|
854
|
+
batches: SessionAgentMessages[],
|
|
855
|
+
stateManager: StateManager
|
|
856
|
+
): boolean {
|
|
857
|
+
const human = stateManager.getHuman();
|
|
858
|
+
const existingPoint = human.settings?.opencode?.extraction_point;
|
|
859
|
+
|
|
860
|
+
if (existingPoint) return false;
|
|
861
|
+
|
|
862
|
+
let earliestTimestamp: number | null = null;
|
|
863
|
+
|
|
864
|
+
for (const batch of batches) {
|
|
865
|
+
for (const msg of batch.messages) {
|
|
866
|
+
const msgMs = new Date(msg.timestamp).getTime();
|
|
867
|
+
if (earliestTimestamp === null || msgMs < earliestTimestamp) {
|
|
868
|
+
earliestTimestamp = msgMs;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (earliestTimestamp === null) return false;
|
|
874
|
+
|
|
875
|
+
const extractionPoint = new Date(earliestTimestamp).toISOString();
|
|
876
|
+
updateExtractionPoint(stateManager, extractionPoint);
|
|
877
|
+
console.log(`[OpenCode] Initialized extraction_point to ${extractionPoint}`);
|
|
878
|
+
return true;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function updateExtractionPoint(
|
|
882
|
+
stateManager: StateManager,
|
|
883
|
+
newPoint: string
|
|
884
|
+
): void {
|
|
885
|
+
const human = stateManager.getHuman();
|
|
886
|
+
stateManager.setHuman({
|
|
887
|
+
...human,
|
|
888
|
+
settings: {
|
|
889
|
+
...human.settings,
|
|
890
|
+
opencode: {
|
|
891
|
+
...human.settings?.opencode,
|
|
892
|
+
extraction_point: newPoint,
|
|
893
|
+
},
|
|
894
|
+
},
|
|
895
|
+
});
|
|
896
|
+
}
|