true-mem 1.0.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/LICENSE +21 -0
- package/README.md +217 -0
- package/dist/adapters/opencode/index.d.ts +11 -0
- package/dist/adapters/opencode/index.d.ts.map +1 -0
- package/dist/adapters/opencode/index.js +676 -0
- package/dist/adapters/opencode/index.js.map +1 -0
- package/dist/adapters/opencode/injection.d.ts +52 -0
- package/dist/adapters/opencode/injection.d.ts.map +1 -0
- package/dist/adapters/opencode/injection.js +93 -0
- package/dist/adapters/opencode/injection.js.map +1 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +61 -0
- package/dist/config.js.map +1 -0
- package/dist/extraction/queue.d.ts +35 -0
- package/dist/extraction/queue.d.ts.map +1 -0
- package/dist/extraction/queue.js +75 -0
- package/dist/extraction/queue.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2900 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +6 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +30 -0
- package/dist/logger.js.map +1 -0
- package/dist/memory/classifier.d.ts +78 -0
- package/dist/memory/classifier.d.ts.map +1 -0
- package/dist/memory/classifier.js +363 -0
- package/dist/memory/classifier.js.map +1 -0
- package/dist/memory/embeddings.d.ts +41 -0
- package/dist/memory/embeddings.d.ts.map +1 -0
- package/dist/memory/embeddings.js +98 -0
- package/dist/memory/embeddings.js.map +1 -0
- package/dist/memory/negative-patterns.d.ts +32 -0
- package/dist/memory/negative-patterns.d.ts.map +1 -0
- package/dist/memory/negative-patterns.js +71 -0
- package/dist/memory/negative-patterns.js.map +1 -0
- package/dist/memory/patterns.d.ts +110 -0
- package/dist/memory/patterns.d.ts.map +1 -0
- package/dist/memory/patterns.js +623 -0
- package/dist/memory/patterns.js.map +1 -0
- package/dist/memory/reconsolidate.d.ts +57 -0
- package/dist/memory/reconsolidate.d.ts.map +1 -0
- package/dist/memory/reconsolidate.js +96 -0
- package/dist/memory/reconsolidate.js.map +1 -0
- package/dist/memory/role-patterns.d.ts +50 -0
- package/dist/memory/role-patterns.d.ts.map +1 -0
- package/dist/memory/role-patterns.js +188 -0
- package/dist/memory/role-patterns.js.map +1 -0
- package/dist/shutdown.d.ts +31 -0
- package/dist/shutdown.d.ts.map +1 -0
- package/dist/shutdown.js +120 -0
- package/dist/shutdown.js.map +1 -0
- package/dist/storage/database.d.ts +77 -0
- package/dist/storage/database.d.ts.map +1 -0
- package/dist/storage/database.js +590 -0
- package/dist/storage/database.js.map +1 -0
- package/dist/storage/sqlite-adapter.d.ts +35 -0
- package/dist/storage/sqlite-adapter.d.ts.map +1 -0
- package/dist/storage/sqlite-adapter.js +99 -0
- package/dist/storage/sqlite-adapter.js.map +1 -0
- package/dist/types.d.ts +266 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +63 -0
- package/dist/types.js.map +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True-Memory OpenCode Adapter
|
|
3
|
+
*/
|
|
4
|
+
const BUILD_TIME = "2026-02-23T09:45:00.000Z";
|
|
5
|
+
import { DEFAULT_CONFIG } from '../../config.js';
|
|
6
|
+
import { createMemoryDatabase } from '../../storage/database.js';
|
|
7
|
+
import { log } from '../../logger.js';
|
|
8
|
+
import { shouldStoreMemory, classifyWithRoleAwareness, calculateRoleWeightedScore, } from '../../memory/classifier.js';
|
|
9
|
+
import { matchAllPatterns } from '../../memory/patterns.js';
|
|
10
|
+
import { getExtractionQueue } from '../../extraction/queue.js';
|
|
11
|
+
import { registerShutdownHandler, executeShutdown } from '../../shutdown.js';
|
|
12
|
+
import { getAtomicMemories, wrapMemories } from './injection.js';
|
|
13
|
+
// Debounce state for message.updated events
|
|
14
|
+
let messageDebounceTimer = null;
|
|
15
|
+
let pendingMessageEvent = null;
|
|
16
|
+
// Session ID extraction helper
|
|
17
|
+
function getSessionIdFromEvent(properties) {
|
|
18
|
+
if (!properties)
|
|
19
|
+
return undefined;
|
|
20
|
+
const info = properties.info;
|
|
21
|
+
if (info && typeof info.id === 'string')
|
|
22
|
+
return info.id;
|
|
23
|
+
if (typeof properties.sessionID === 'string')
|
|
24
|
+
return properties.sessionID;
|
|
25
|
+
if (typeof properties.id === 'string')
|
|
26
|
+
return properties.id;
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
// Sub-agent detection helper
|
|
30
|
+
function isSubAgentSession(sessionId) {
|
|
31
|
+
// Heuristic: sub-agent sessions typically contain "-task-" in the ID
|
|
32
|
+
return sessionId.includes('-task-');
|
|
33
|
+
}
|
|
34
|
+
// Debounce helper for message.updated events
|
|
35
|
+
function debounceMessageUpdate(state, eventProps, handler) {
|
|
36
|
+
pendingMessageEvent = { properties: eventProps };
|
|
37
|
+
if (messageDebounceTimer) {
|
|
38
|
+
clearTimeout(messageDebounceTimer);
|
|
39
|
+
}
|
|
40
|
+
messageDebounceTimer = setTimeout(() => {
|
|
41
|
+
if (pendingMessageEvent) {
|
|
42
|
+
handler(state, pendingMessageEvent.properties)
|
|
43
|
+
.catch(err => log(`Message processing error: ${err}`));
|
|
44
|
+
}
|
|
45
|
+
pendingMessageEvent = null;
|
|
46
|
+
messageDebounceTimer = null;
|
|
47
|
+
}, 500); // 500ms debounce
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Create OpenCode plugin hooks
|
|
51
|
+
*/
|
|
52
|
+
export async function createTrueMemoryPlugin(ctx, configOverrides = {}) {
|
|
53
|
+
log('createTrueMemoryPlugin called');
|
|
54
|
+
const config = {
|
|
55
|
+
...DEFAULT_CONFIG,
|
|
56
|
+
...configOverrides,
|
|
57
|
+
};
|
|
58
|
+
// Initialize database
|
|
59
|
+
const db = await createMemoryDatabase(config);
|
|
60
|
+
log('Database initialized');
|
|
61
|
+
// Register shutdown handler for database
|
|
62
|
+
registerShutdownHandler('database', () => db.close());
|
|
63
|
+
// Resolve project root
|
|
64
|
+
const worktree = (!ctx.worktree || ctx.worktree === '/' || ctx.worktree === '\\')
|
|
65
|
+
? ctx.directory
|
|
66
|
+
: ctx.worktree;
|
|
67
|
+
const state = {
|
|
68
|
+
db,
|
|
69
|
+
config,
|
|
70
|
+
currentSessionId: null,
|
|
71
|
+
injectedSessions: new Set(),
|
|
72
|
+
worktree,
|
|
73
|
+
client: ctx.client,
|
|
74
|
+
};
|
|
75
|
+
log(`True-Memory initialized — worktree=${worktree}`);
|
|
76
|
+
// Extract project name and create professional startup message
|
|
77
|
+
const projectName = worktree.split(/[/\\]/).pop() || 'Unknown';
|
|
78
|
+
const startupMessage = `🧠 True-Memory: Plugin loaded successfully | v2.0.1 [${BUILD_TIME}] | Mode: Jaccard Similarity | Project: ${projectName}`;
|
|
79
|
+
// Log to file-based logger only (to avoid overwriting OpenCode TUI during lazy initialization)
|
|
80
|
+
log(startupMessage);
|
|
81
|
+
return {
|
|
82
|
+
event: async ({ event }) => {
|
|
83
|
+
// Skip noisy events
|
|
84
|
+
const silentEvents = new Set(['message.part.delta', 'message.part.updated', 'session.diff']);
|
|
85
|
+
if (silentEvents.has(event.type))
|
|
86
|
+
return;
|
|
87
|
+
log(`Event: ${event.type}`);
|
|
88
|
+
const sessionId = getSessionIdFromEvent(event.properties);
|
|
89
|
+
switch (event.type) {
|
|
90
|
+
case 'session.created':
|
|
91
|
+
await handleSessionCreated(state, sessionId);
|
|
92
|
+
break;
|
|
93
|
+
case 'session.idle':
|
|
94
|
+
// Add extraction job to queue for sequential processing
|
|
95
|
+
queueExtractionJob(state, sessionId);
|
|
96
|
+
break;
|
|
97
|
+
case 'session.deleted':
|
|
98
|
+
case 'session.error':
|
|
99
|
+
await handleSessionEnd(state, event.type, sessionId);
|
|
100
|
+
break;
|
|
101
|
+
case 'server.instance.disposed':
|
|
102
|
+
// Server is shutting down, execute synchronous shutdown
|
|
103
|
+
// CRITICAL: Don't await - Bun handles cleanup automatically
|
|
104
|
+
log('Server instance disposed, triggering sync shutdown');
|
|
105
|
+
executeShutdown('server.instance.disposed');
|
|
106
|
+
break;
|
|
107
|
+
case 'message.updated':
|
|
108
|
+
if (state.config.opencode.extractOnMessage) {
|
|
109
|
+
// Debounce message updates to avoid blocking UI
|
|
110
|
+
debounceMessageUpdate(state, event.properties, handleMessageUpdated);
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
'tool.execute.before': async (input, output) => {
|
|
116
|
+
const toolInput = input;
|
|
117
|
+
const toolName = toolInput.tool;
|
|
118
|
+
log(`tool.execute.before: ${toolName}`);
|
|
119
|
+
// Only inject for task and background_task tools
|
|
120
|
+
if (toolName !== 'task' && toolName !== 'background_task') {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Extract prompt from output args
|
|
124
|
+
const outputWithArgs = output;
|
|
125
|
+
const originalPrompt = outputWithArgs.args?.prompt;
|
|
126
|
+
if (!originalPrompt) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Retrieve relevant memories using atomic injection
|
|
130
|
+
try {
|
|
131
|
+
const injectionState = {
|
|
132
|
+
db: state.db,
|
|
133
|
+
worktree: state.worktree,
|
|
134
|
+
};
|
|
135
|
+
const memories = await getAtomicMemories(injectionState, originalPrompt, 10);
|
|
136
|
+
if (memories.length > 0) {
|
|
137
|
+
const wrappedContext = wrapMemories(memories, state.worktree, 'project');
|
|
138
|
+
// Update the prompt in output args
|
|
139
|
+
outputWithArgs.args.prompt = `${wrappedContext}\n\n${originalPrompt}`;
|
|
140
|
+
log(`Atomic injection: ${memories.length} memories injected for ${toolName}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
log(`Atomic injection failed for ${toolName}: ${error}`);
|
|
145
|
+
// Continue without injection on error
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
'tool.execute.after': async (input, output) => {
|
|
149
|
+
log(`tool.execute.after: ${input.tool}`);
|
|
150
|
+
if (!state.currentSessionId && input.sessionID) {
|
|
151
|
+
state.currentSessionId = input.sessionID;
|
|
152
|
+
}
|
|
153
|
+
if (!state.currentSessionId)
|
|
154
|
+
return;
|
|
155
|
+
await handlePostToolUse(state, input, output);
|
|
156
|
+
},
|
|
157
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
158
|
+
log('experimental.chat.system.transform: Injecting global memories');
|
|
159
|
+
try {
|
|
160
|
+
const injectionState = {
|
|
161
|
+
db: state.db,
|
|
162
|
+
worktree: state.worktree,
|
|
163
|
+
};
|
|
164
|
+
// Retrieve global memories (user-level: constraints, preferences, learning, procedural)
|
|
165
|
+
const userLevelClassifications = ['constraint', 'preference', 'learning', 'procedural'];
|
|
166
|
+
const allMemories = injectionState.db.getMemoriesByScope(undefined, 20);
|
|
167
|
+
// Filter only user-level memories for global context
|
|
168
|
+
const globalMemories = allMemories.filter(m => userLevelClassifications.includes(m.classification));
|
|
169
|
+
if (globalMemories.length > 0) {
|
|
170
|
+
const wrappedContext = wrapMemories(globalMemories, state.worktree, 'global');
|
|
171
|
+
// Handle system as string[] - append to the last element
|
|
172
|
+
const systemArray = Array.isArray(output.system) ? output.system : [output.system];
|
|
173
|
+
const lastElement = systemArray[systemArray.length - 1] || '';
|
|
174
|
+
systemArray[systemArray.length - 1] = `${lastElement}\n\n${wrappedContext}`;
|
|
175
|
+
output.system = systemArray;
|
|
176
|
+
log(`Global injection: ${globalMemories.length} memories injected into system prompt`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
log(`Global injection failed: ${error}`);
|
|
181
|
+
// Continue without injection on error
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
'experimental.session.compacting': async (input, output) => {
|
|
185
|
+
log('Compaction hook triggered');
|
|
186
|
+
const sessionId = input.sessionID ?? state.currentSessionId;
|
|
187
|
+
if (state.config.opencode.injectOnCompaction) {
|
|
188
|
+
const memories = await getRelevantMemories(state, state.config.opencode.maxCompactionMemories);
|
|
189
|
+
if (memories.length > 0) {
|
|
190
|
+
const memoryContext = formatMemoriesForInjection(memories, state.worktree);
|
|
191
|
+
output.prompt = buildCompactionPrompt(memoryContext);
|
|
192
|
+
log(`Injected ${memories.length} memories into compaction`);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
output.prompt = buildCompactionPrompt(null);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
// Queue helper for session idle processing
|
|
202
|
+
function queueExtractionJob(state, sessionId) {
|
|
203
|
+
const queue = getExtractionQueue();
|
|
204
|
+
queue.add({
|
|
205
|
+
description: `session:${sessionId ?? state.currentSessionId}`,
|
|
206
|
+
execute: async () => {
|
|
207
|
+
await processSessionIdle(state, sessionId);
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// Session handlers
|
|
212
|
+
async function handleSessionCreated(state, sessionId) {
|
|
213
|
+
if (!sessionId)
|
|
214
|
+
return;
|
|
215
|
+
state.currentSessionId = sessionId;
|
|
216
|
+
log(`Session created: ${sessionId}`);
|
|
217
|
+
// ✅ Sola creazione sessione - nessun maintenance bloccante
|
|
218
|
+
state.db.createSession(sessionId, state.worktree, { agentType: 'opencode' });
|
|
219
|
+
}
|
|
220
|
+
async function processSessionIdle(state, sessionId) {
|
|
221
|
+
const effectiveSessionId = sessionId ?? state.currentSessionId;
|
|
222
|
+
if (!effectiveSessionId)
|
|
223
|
+
return;
|
|
224
|
+
if (sessionId && !state.currentSessionId) {
|
|
225
|
+
state.currentSessionId = sessionId;
|
|
226
|
+
}
|
|
227
|
+
// Skip extraction for sub-agent sessions to avoid duplicate extraction
|
|
228
|
+
if (isSubAgentSession(effectiveSessionId)) {
|
|
229
|
+
log(`Skipping extraction: sub-agent session detected (${effectiveSessionId})`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const watermark = state.db.getMessageWatermark(effectiveSessionId);
|
|
233
|
+
let messages;
|
|
234
|
+
try {
|
|
235
|
+
const response = await state.client.session.messages({ path: { id: effectiveSessionId } });
|
|
236
|
+
if (response.error) {
|
|
237
|
+
log(`Failed to fetch messages: ${response.error}`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
messages = response.data ?? [];
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
log(`Failed to fetch messages: ${error}`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (!messages || messages.length <= watermark)
|
|
247
|
+
return;
|
|
248
|
+
const newMessages = messages.slice(watermark);
|
|
249
|
+
const { text: conversationText, lines: roleLines } = extractConversationTextWithRoles(newMessages);
|
|
250
|
+
log('Debug: Clean conversation text (start):', conversationText.slice(0, 200));
|
|
251
|
+
log('Debug: Role-aware lines extracted:', roleLines.length);
|
|
252
|
+
if (!conversationText.trim()) {
|
|
253
|
+
state.db.updateMessageWatermark(effectiveSessionId, messages.length);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// Check for injection markers as a final safety net before processing
|
|
257
|
+
const injectionMarkers = [
|
|
258
|
+
/## Relevant Memories from Previous Sessions/i,
|
|
259
|
+
/### User Preferences & Constraints/i,
|
|
260
|
+
/### .* Context/i,
|
|
261
|
+
/## Compaction Instructions/i,
|
|
262
|
+
/\[LTM\]/i,
|
|
263
|
+
/\[STM\]/i,
|
|
264
|
+
];
|
|
265
|
+
const hasInjectedContent = injectionMarkers.some(marker => marker.test(conversationText));
|
|
266
|
+
if (hasInjectedContent) {
|
|
267
|
+
log(`WARNING: Conversation contains injection markers (safety check), extractConversationText should have filtered them out`);
|
|
268
|
+
// Don't skip extraction - let the filtered conversationText be processed
|
|
269
|
+
}
|
|
270
|
+
// Extract memories using role-aware classifier
|
|
271
|
+
log(`Processing ${newMessages.length} new messages, ${roleLines.length} lines with role info`);
|
|
272
|
+
// Get signals from patterns (applied to full conversation)
|
|
273
|
+
const signals = matchAllPatterns(conversationText);
|
|
274
|
+
log('Debug: Detected signals:', JSON.stringify(signals));
|
|
275
|
+
let extractionAttempted = false;
|
|
276
|
+
let extractionSucceeded = false;
|
|
277
|
+
if (signals.length > 0) {
|
|
278
|
+
extractionAttempted = true;
|
|
279
|
+
try {
|
|
280
|
+
// Process each Human message with role-aware classification
|
|
281
|
+
const humanMessages = roleLines.filter(line => line.role === 'user');
|
|
282
|
+
log(`Debug: Processing ${humanMessages.length} Human messages for memory extraction`);
|
|
283
|
+
for (const humanMsg of humanMessages) {
|
|
284
|
+
const { text, role } = humanMsg;
|
|
285
|
+
// Get signals specific to this message
|
|
286
|
+
const msgSignals = matchAllPatterns(text);
|
|
287
|
+
if (msgSignals.length === 0) {
|
|
288
|
+
continue; // No signals in this message, skip
|
|
289
|
+
}
|
|
290
|
+
log(`Debug: Processing Human message (${msgSignals.length} signals):`, text.slice(0, 100));
|
|
291
|
+
// Calculate base signal score (average weight of matched signals)
|
|
292
|
+
const baseSignalScore = msgSignals.reduce((sum, s) => sum + s.weight, 0) / msgSignals.length;
|
|
293
|
+
// Apply role weighting (10x for Human messages)
|
|
294
|
+
const roleWeightedScore = calculateRoleWeightedScore(baseSignalScore, role, text);
|
|
295
|
+
log(`Debug: Role-weighted score: ${roleWeightedScore.toFixed(2)} (base: ${baseSignalScore.toFixed(2)})`);
|
|
296
|
+
// Build role-aware context
|
|
297
|
+
const roleAwareContext = {
|
|
298
|
+
primaryRole: role,
|
|
299
|
+
roleWeightedScore,
|
|
300
|
+
hasAssistantContext: roleLines.some(line => line.role === 'assistant'),
|
|
301
|
+
fullConversation: conversationText,
|
|
302
|
+
};
|
|
303
|
+
// Classify with role-awareness
|
|
304
|
+
const { classification, confidence, isolatedContent, roleValidated, validationReason } = classifyWithRoleAwareness(text, msgSignals, roleAwareContext);
|
|
305
|
+
log(`Debug: Classification result: ${classification}, confidence: ${confidence.toFixed(2)}, roleValidated: ${roleValidated}, reason: ${validationReason}`);
|
|
306
|
+
if (classification && roleValidated) {
|
|
307
|
+
// Apply three-layer defense
|
|
308
|
+
const result = shouldStoreMemory(isolatedContent, classification, baseSignalScore);
|
|
309
|
+
if (result.store) {
|
|
310
|
+
// Determine scope
|
|
311
|
+
// - User-level classifications: global scope (all projects)
|
|
312
|
+
// - Explicit intent semantic: also global (user said "remember this")
|
|
313
|
+
const userLevelClassifications = ['constraint', 'preference', 'learning', 'procedural'];
|
|
314
|
+
const isExplicitIntentSemantic = classification === 'semantic' && confidence >= 0.85;
|
|
315
|
+
const isUserLevel = userLevelClassifications.includes(classification) || isExplicitIntentSemantic;
|
|
316
|
+
const scope = isUserLevel ? undefined : state.worktree;
|
|
317
|
+
// Determine store: STM vs LTM
|
|
318
|
+
// - Explicit intent (confidence >= 0.85) → LTM (user explicitly said "remember this")
|
|
319
|
+
// - Auto-promote classifications → LTM (bugfix, learning, decision)
|
|
320
|
+
// - Everything else → STM
|
|
321
|
+
const autoPromoteClassifications = ['bugfix', 'learning', 'decision'];
|
|
322
|
+
const isExplicitIntent = confidence >= 0.85;
|
|
323
|
+
const shouldPromoteToLtm = isExplicitIntent || autoPromoteClassifications.includes(classification);
|
|
324
|
+
const store = shouldPromoteToLtm ? 'ltm' : 'stm';
|
|
325
|
+
// Store memory (no embeddings - using Jaccard similarity)
|
|
326
|
+
await state.db.createMemory(store, classification, extractCleanSummary(isolatedContent), // Clean summary without prefixes
|
|
327
|
+
[], {
|
|
328
|
+
sessionId: effectiveSessionId,
|
|
329
|
+
projectScope: scope,
|
|
330
|
+
importance: confidence, // Use confidence from classifyWithRoleAwareness
|
|
331
|
+
confidence: confidence,
|
|
332
|
+
});
|
|
333
|
+
log(`Stored ${classification} memory in ${store.toUpperCase()} (confidence: ${confidence.toFixed(2)}, role: ${role}, reason: ${result.reason})`);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
log(`Skipped ${classification} memory: ${result.reason}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
else if (classification && !roleValidated) {
|
|
340
|
+
log(`Skipped ${classification} memory: ${validationReason}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
extractionSucceeded = true;
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
log(`Extraction failed with critical error: ${error}`);
|
|
347
|
+
// Don't update watermark if extraction failed with critical error
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Only update watermark if extraction was attempted and succeeded, or if no extraction was needed
|
|
352
|
+
if (!extractionAttempted || extractionSucceeded) {
|
|
353
|
+
state.db.updateMessageWatermark(effectiveSessionId, messages.length);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async function handleSessionEnd(state, eventType, sessionId) {
|
|
357
|
+
const effectiveSessionId = sessionId ?? state.currentSessionId;
|
|
358
|
+
if (!effectiveSessionId)
|
|
359
|
+
return;
|
|
360
|
+
// ✅ ESEGUI maintenance alla fine della sessione (non blocca startup)
|
|
361
|
+
try {
|
|
362
|
+
const decayed = state.db.applyDecay();
|
|
363
|
+
const promoted = state.db.runConsolidation();
|
|
364
|
+
if (decayed > 0 || promoted > 0) {
|
|
365
|
+
log(`Maintenance: decayed ${decayed} memories, promoted ${promoted} to LTM`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
log(`Maintenance error: ${err}`);
|
|
370
|
+
}
|
|
371
|
+
const reason = eventType === 'session.error' ? 'abandoned' : 'normal';
|
|
372
|
+
state.db.endSession(effectiveSessionId, reason === 'abandoned' ? 'abandoned' : 'completed');
|
|
373
|
+
state.currentSessionId = null;
|
|
374
|
+
log(`Session ended: ${effectiveSessionId} (${reason})`);
|
|
375
|
+
}
|
|
376
|
+
async function handleMessageUpdated(state, eventProps) {
|
|
377
|
+
const info = eventProps?.info;
|
|
378
|
+
const sessionId = info?.sessionID ?? eventProps?.sessionID ?? state.currentSessionId;
|
|
379
|
+
if (!sessionId)
|
|
380
|
+
return;
|
|
381
|
+
if (!state.currentSessionId && sessionId) {
|
|
382
|
+
state.currentSessionId = sessionId;
|
|
383
|
+
}
|
|
384
|
+
// Lazy injection disabled - using atomic injection via tool.execute.before and experimental.chat.system.transform
|
|
385
|
+
// This avoids duplicate injections and provides more context-aware memory retrieval
|
|
386
|
+
// const role = info?.role ?? (eventProps?.role as string | undefined);
|
|
387
|
+
// if (role === 'user' && !state.injectedSessions.has(sessionId)) {
|
|
388
|
+
// state.injectedSessions.add(sessionId);
|
|
389
|
+
// log(`Lazy injection for session ${sessionId}`);
|
|
390
|
+
//
|
|
391
|
+
// // Extract user's message content for contextual retrieval
|
|
392
|
+
// let userQuery: string | undefined;
|
|
393
|
+
// const parts = info?.parts ?? (eventProps?.parts as Part[] | undefined);
|
|
394
|
+
// if (parts && parts.length > 0) {
|
|
395
|
+
// for (const part of parts) {
|
|
396
|
+
// if (part.type === 'text' && 'text' in part) {
|
|
397
|
+
// userQuery = (part as { text: string }).text;
|
|
398
|
+
// break;
|
|
399
|
+
// }
|
|
400
|
+
// }
|
|
401
|
+
// }
|
|
402
|
+
//
|
|
403
|
+
// const memories = await getRelevantMemories(state, state.config.opencode.maxSessionStartMemories, userQuery);
|
|
404
|
+
// if (memories.length > 0) {
|
|
405
|
+
// const memoryContext = formatMemoriesForInjection(memories, state.worktree);
|
|
406
|
+
// await injectContext(state, sessionId, memoryContext);
|
|
407
|
+
// log(`Lazy injection: ${memories.length} memories`);
|
|
408
|
+
// }
|
|
409
|
+
// }
|
|
410
|
+
}
|
|
411
|
+
async function handlePostToolUse(state, input, output) {
|
|
412
|
+
const sessionId = state.currentSessionId;
|
|
413
|
+
if (!sessionId)
|
|
414
|
+
return;
|
|
415
|
+
const toolOutput = output.output && output.output.length > 2000
|
|
416
|
+
? output.output.slice(0, 2000) + '...[truncated]'
|
|
417
|
+
: (output.output ?? '');
|
|
418
|
+
state.db.createEvent(sessionId, 'PostToolUse', '', {
|
|
419
|
+
toolName: input.tool,
|
|
420
|
+
toolInput: JSON.stringify(input.args),
|
|
421
|
+
toolOutput,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
// Helpers
|
|
425
|
+
/**
|
|
426
|
+
* Extract a clean summary from conversation text.
|
|
427
|
+
* Removes "Human:" / "Assistant:" prefixes and trims to reasonable length.
|
|
428
|
+
*/
|
|
429
|
+
function extractCleanSummary(conversationText, maxLength = 500) {
|
|
430
|
+
// Remove role prefixes
|
|
431
|
+
let cleaned = conversationText
|
|
432
|
+
.replace(/^(Human|Assistant):\s*/gm, '')
|
|
433
|
+
.replace(/\n+/g, ' ')
|
|
434
|
+
.trim();
|
|
435
|
+
// Remove any remaining injection markers (second line of defense)
|
|
436
|
+
const injectionMarkers = [
|
|
437
|
+
/## Relevant Memories from Previous Sessions/gi,
|
|
438
|
+
/### User Preferences & Constraints/gi,
|
|
439
|
+
/### .* Context/gi,
|
|
440
|
+
/## Compaction Instructions/gi,
|
|
441
|
+
/\[LTM\]/gi,
|
|
442
|
+
/\[STM\]/gi,
|
|
443
|
+
];
|
|
444
|
+
for (const marker of injectionMarkers) {
|
|
445
|
+
cleaned = cleaned.replace(marker, '');
|
|
446
|
+
}
|
|
447
|
+
// Normalize whitespace after marker removal
|
|
448
|
+
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
|
449
|
+
// Truncate if necessary, try to break at word boundaries
|
|
450
|
+
if (cleaned.length <= maxLength) {
|
|
451
|
+
return cleaned;
|
|
452
|
+
}
|
|
453
|
+
// Find last complete word within limit
|
|
454
|
+
const truncated = cleaned.slice(0, maxLength);
|
|
455
|
+
const lastSpace = truncated.lastIndexOf(' ');
|
|
456
|
+
if (lastSpace > maxLength * 0.8) {
|
|
457
|
+
return truncated.slice(0, lastSpace) + '...';
|
|
458
|
+
}
|
|
459
|
+
return truncated + '...';
|
|
460
|
+
}
|
|
461
|
+
function extractConversationText(messages) {
|
|
462
|
+
const lines = [];
|
|
463
|
+
// Regex patterns that indicate injected content (case-insensitive, should be filtered out)
|
|
464
|
+
const injectionMarkers = [
|
|
465
|
+
/## Relevant Memories from Previous Sessions/i,
|
|
466
|
+
/### User Preferences & Constraints/i,
|
|
467
|
+
/### .* Context/i, // Matches "### ProjectName Context" pattern
|
|
468
|
+
/## Compaction Instructions/i,
|
|
469
|
+
/\[LTM\]/i,
|
|
470
|
+
/\[STM\]/i,
|
|
471
|
+
];
|
|
472
|
+
// Regex patterns that indicate tool execution or results (should be filtered out)
|
|
473
|
+
const toolMarkers = [
|
|
474
|
+
/\[Tool:\s*\w+\]/i,
|
|
475
|
+
/^Tool Result:/i,
|
|
476
|
+
/^Tool Error:/i,
|
|
477
|
+
/<tool_use>[\s\S]*?<\/tool_use>/gi, // Strip <tool_use> blocks
|
|
478
|
+
/<tool_result>[\s\S]*?<\/tool_result>/gi, // Strip <tool_result> blocks
|
|
479
|
+
/```json[\s\S]*?"tool"[\s\S]*?```/gi, // Strip JSON blobs with tool
|
|
480
|
+
];
|
|
481
|
+
for (const msg of messages) {
|
|
482
|
+
const role = msg.info.role === 'user' ? 'Human' : 'Assistant';
|
|
483
|
+
for (const part of msg.parts) {
|
|
484
|
+
if (part.type === 'text' && 'text' in part) {
|
|
485
|
+
const text = part.text;
|
|
486
|
+
// Skip parts that contain any injection marker (prevents re-extracting injected content)
|
|
487
|
+
const hasInjectionMarker = injectionMarkers.some(marker => marker.test(text));
|
|
488
|
+
if (hasInjectionMarker) {
|
|
489
|
+
continue; // Skip this part entirely
|
|
490
|
+
}
|
|
491
|
+
// Skip parts that look like tool execution or results
|
|
492
|
+
const hasToolMarker = toolMarkers.some(marker => marker.test(text));
|
|
493
|
+
if (hasToolMarker) {
|
|
494
|
+
continue; // Skip this part entirely
|
|
495
|
+
}
|
|
496
|
+
lines.push(`${role}: ${text}`);
|
|
497
|
+
}
|
|
498
|
+
else if (part.type === 'tool') {
|
|
499
|
+
const toolPart = part;
|
|
500
|
+
if (toolPart.state?.status === 'completed' || toolPart.state?.status === 'error') {
|
|
501
|
+
lines.push(`Assistant: [Tool: ${toolPart.tool}]`);
|
|
502
|
+
if (toolPart.state.output)
|
|
503
|
+
lines.push(`Tool Result: ${toolPart.state.output.slice(0, 2000)}`);
|
|
504
|
+
if (toolPart.state.error)
|
|
505
|
+
lines.push(`Tool Error: ${toolPart.state.error}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return lines.join('\n');
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Extract conversation text with role information
|
|
514
|
+
* Returns both the text and role-aware line information
|
|
515
|
+
*/
|
|
516
|
+
function extractConversationTextWithRoles(messages) {
|
|
517
|
+
const textLines = [];
|
|
518
|
+
const roleLines = [];
|
|
519
|
+
// Regex patterns that indicate injected content (case-insensitive, should be filtered out)
|
|
520
|
+
const injectionMarkers = [
|
|
521
|
+
/## Relevant Memories from Previous Sessions/i,
|
|
522
|
+
/### User Preferences & Constraints/i,
|
|
523
|
+
/### .* Context/i, // Matches "### ProjectName Context" pattern
|
|
524
|
+
/## Compaction Instructions/i,
|
|
525
|
+
/\[LTM\]/i,
|
|
526
|
+
/\[STM\]/i,
|
|
527
|
+
];
|
|
528
|
+
// Regex patterns that indicate tool execution or results (should be filtered out)
|
|
529
|
+
const toolMarkers = [
|
|
530
|
+
/\[Tool:\s*\w+\]/i,
|
|
531
|
+
/^Tool Result:/i,
|
|
532
|
+
/^Tool Error:/i,
|
|
533
|
+
/<tool_use>[\s\S]*?<\/tool_use>/gi, // Strip <tool_use> blocks
|
|
534
|
+
/<tool_result>[\s\S]*?<\/tool_result>/gi, // Strip <tool_result> blocks
|
|
535
|
+
/```json[\s\S]*?"tool"[\s\S]*?```/gi, // Strip JSON blobs with tool
|
|
536
|
+
];
|
|
537
|
+
for (const msg of messages) {
|
|
538
|
+
const role = msg.info.role === 'user' ? 'user' : 'assistant';
|
|
539
|
+
const roleLabel = role === 'user' ? 'Human' : 'Assistant';
|
|
540
|
+
for (const part of msg.parts) {
|
|
541
|
+
if (part.type === 'text' && 'text' in part) {
|
|
542
|
+
const text = part.text;
|
|
543
|
+
// Skip parts that contain any injection marker (prevents re-extracting injected content)
|
|
544
|
+
const hasInjectionMarker = injectionMarkers.some(marker => marker.test(text));
|
|
545
|
+
if (hasInjectionMarker) {
|
|
546
|
+
continue; // Skip this part entirely
|
|
547
|
+
}
|
|
548
|
+
// Skip parts that look like tool execution or results
|
|
549
|
+
const hasToolMarker = toolMarkers.some(marker => marker.test(text));
|
|
550
|
+
if (hasToolMarker) {
|
|
551
|
+
continue; // Skip this part entirely
|
|
552
|
+
}
|
|
553
|
+
textLines.push(`${roleLabel}: ${text}`);
|
|
554
|
+
roleLines.push({
|
|
555
|
+
text,
|
|
556
|
+
role,
|
|
557
|
+
lineNumber: textLines.length - 1,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
else if (part.type === 'tool') {
|
|
561
|
+
const toolPart = part;
|
|
562
|
+
if (toolPart.state?.status === 'completed' || toolPart.state?.status === 'error') {
|
|
563
|
+
const toolText = `Assistant: [Tool: ${toolPart.tool}]`;
|
|
564
|
+
textLines.push(toolText);
|
|
565
|
+
roleLines.push({
|
|
566
|
+
text: toolText,
|
|
567
|
+
role: 'assistant',
|
|
568
|
+
lineNumber: textLines.length - 1,
|
|
569
|
+
});
|
|
570
|
+
if (toolPart.state.output) {
|
|
571
|
+
const outputText = `Tool Result: ${toolPart.state.output.slice(0, 2000)}`;
|
|
572
|
+
textLines.push(outputText);
|
|
573
|
+
roleLines.push({
|
|
574
|
+
text: outputText,
|
|
575
|
+
role: 'assistant',
|
|
576
|
+
lineNumber: textLines.length - 1,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
if (toolPart.state.error) {
|
|
580
|
+
const errorText = `Tool Error: ${toolPart.state.error}`;
|
|
581
|
+
textLines.push(errorText);
|
|
582
|
+
roleLines.push({
|
|
583
|
+
text: errorText,
|
|
584
|
+
role: 'assistant',
|
|
585
|
+
lineNumber: textLines.length - 1,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
text: textLines.join('\n'),
|
|
594
|
+
lines: roleLines,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
async function getRelevantMemories(state, limit, query) {
|
|
598
|
+
if (query) {
|
|
599
|
+
// Use Jaccard similarity search (text-based, no embeddings)
|
|
600
|
+
return state.db.vectorSearch(query, state.worktree, limit);
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
// Fall back to scope-based retrieval
|
|
604
|
+
return state.db.getMemoriesByScope(state.worktree, limit);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
function formatMemoriesForInjection(memories, currentProject) {
|
|
608
|
+
const lines = ['## Relevant Memories from Previous Sessions', ''];
|
|
609
|
+
const userLevelClassifications = ['constraint', 'preference', 'learning', 'procedural'];
|
|
610
|
+
const userLevel = memories.filter(m => userLevelClassifications.includes(m.classification));
|
|
611
|
+
const projectLevel = memories.filter(m => !userLevelClassifications.includes(m.classification));
|
|
612
|
+
if (userLevel.length > 0) {
|
|
613
|
+
lines.push('### User Preferences & Constraints');
|
|
614
|
+
lines.push('_These apply across all projects_');
|
|
615
|
+
lines.push('');
|
|
616
|
+
for (const mem of userLevel) {
|
|
617
|
+
const storeLabel = mem.store === 'ltm' ? '[LTM]' : '[STM]';
|
|
618
|
+
lines.push(`- ${storeLabel} [${mem.classification}] ${mem.summary}`);
|
|
619
|
+
}
|
|
620
|
+
lines.push('');
|
|
621
|
+
}
|
|
622
|
+
if (projectLevel.length > 0) {
|
|
623
|
+
const projectName = currentProject ? currentProject.split(/[/\\]/).pop() : 'Current Project';
|
|
624
|
+
lines.push(`### ${projectName} Context`);
|
|
625
|
+
lines.push('');
|
|
626
|
+
for (const mem of projectLevel) {
|
|
627
|
+
const storeLabel = mem.store === 'ltm' ? '[LTM]' : '[STM]';
|
|
628
|
+
lines.push(`- ${storeLabel} [${mem.classification}] ${mem.summary}`);
|
|
629
|
+
}
|
|
630
|
+
lines.push('');
|
|
631
|
+
}
|
|
632
|
+
return lines.join('\n');
|
|
633
|
+
}
|
|
634
|
+
async function injectContext(state, sessionId, context) {
|
|
635
|
+
try {
|
|
636
|
+
await state.client.session.prompt({
|
|
637
|
+
path: { id: sessionId },
|
|
638
|
+
body: {
|
|
639
|
+
noReply: true,
|
|
640
|
+
parts: [{ type: 'text', text: context }],
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
log(`Failed to inject context: ${error}`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function buildCompactionPrompt(memoriesMarkdown) {
|
|
649
|
+
const sections = [];
|
|
650
|
+
if (memoriesMarkdown) {
|
|
651
|
+
sections.push(memoriesMarkdown);
|
|
652
|
+
}
|
|
653
|
+
sections.push(`## Compaction Instructions
|
|
654
|
+
|
|
655
|
+
You are compacting a conversation. Preserve:
|
|
656
|
+
|
|
657
|
+
### MUST PRESERVE
|
|
658
|
+
- Current task/goal
|
|
659
|
+
- User constraints, preferences, requirements
|
|
660
|
+
- Decisions and rationale
|
|
661
|
+
- Errors and solutions
|
|
662
|
+
- Files modified and why
|
|
663
|
+
- Current state of in-progress work
|
|
664
|
+
|
|
665
|
+
### CAN DISCARD
|
|
666
|
+
- Verbose tool outputs (summarize)
|
|
667
|
+
- Intermediate reasoning
|
|
668
|
+
- Exploratory discussions
|
|
669
|
+
- Repetitive information
|
|
670
|
+
|
|
671
|
+
### OUTPUT FORMAT
|
|
672
|
+
Write a structured summary: task, accomplishments, remaining work, critical context.`);
|
|
673
|
+
return sections.join('\n\n');
|
|
674
|
+
}
|
|
675
|
+
export default createTrueMemoryPlugin;
|
|
676
|
+
//# sourceMappingURL=index.js.map
|