clawmem 0.1.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/AGENTS.md +660 -0
- package/CLAUDE.md +660 -0
- package/LICENSE +21 -0
- package/README.md +993 -0
- package/SKILL.md +717 -0
- package/bin/clawmem +75 -0
- package/package.json +72 -0
- package/src/amem.ts +797 -0
- package/src/beads.ts +263 -0
- package/src/clawmem.ts +1849 -0
- package/src/collections.ts +405 -0
- package/src/config.ts +178 -0
- package/src/consolidation.ts +123 -0
- package/src/directory-context.ts +248 -0
- package/src/errors.ts +41 -0
- package/src/formatter.ts +427 -0
- package/src/graph-traversal.ts +247 -0
- package/src/hooks/context-surfacing.ts +317 -0
- package/src/hooks/curator-nudge.ts +89 -0
- package/src/hooks/decision-extractor.ts +639 -0
- package/src/hooks/feedback-loop.ts +214 -0
- package/src/hooks/handoff-generator.ts +345 -0
- package/src/hooks/postcompact-inject.ts +226 -0
- package/src/hooks/precompact-extract.ts +314 -0
- package/src/hooks/pretool-inject.ts +79 -0
- package/src/hooks/session-bootstrap.ts +324 -0
- package/src/hooks/staleness-check.ts +130 -0
- package/src/hooks.ts +367 -0
- package/src/indexer.ts +327 -0
- package/src/intent.ts +294 -0
- package/src/limits.ts +26 -0
- package/src/llm.ts +1175 -0
- package/src/mcp.ts +2138 -0
- package/src/memory.ts +336 -0
- package/src/mmr.ts +93 -0
- package/src/observer.ts +269 -0
- package/src/openclaw/engine.ts +283 -0
- package/src/openclaw/index.ts +221 -0
- package/src/openclaw/plugin.json +83 -0
- package/src/openclaw/shell.ts +207 -0
- package/src/openclaw/tools.ts +304 -0
- package/src/profile.ts +346 -0
- package/src/promptguard.ts +218 -0
- package/src/retrieval-gate.ts +106 -0
- package/src/search-utils.ts +127 -0
- package/src/server.ts +783 -0
- package/src/splitter.ts +325 -0
- package/src/store.ts +4062 -0
- package/src/validation.ts +67 -0
- package/src/watcher.ts +58 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feedback Loop Hook - Stop
|
|
3
|
+
*
|
|
4
|
+
* Fires when a session ends. Detects which surfaced notes were actually
|
|
5
|
+
* referenced by the assistant, and boosts their access counts.
|
|
6
|
+
* This closes the learning loop: notes that prove useful rise in confidence,
|
|
7
|
+
* unused notes gradually decay.
|
|
8
|
+
*
|
|
9
|
+
* Silent — does not inject context back to Claude.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Store } from "../store.ts";
|
|
13
|
+
import type { HookInput, HookOutput } from "../hooks.ts";
|
|
14
|
+
import {
|
|
15
|
+
makeEmptyOutput,
|
|
16
|
+
readTranscript,
|
|
17
|
+
validateTranscriptPath,
|
|
18
|
+
} from "../hooks.ts";
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// Handler
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
export async function feedbackLoop(
|
|
25
|
+
store: Store,
|
|
26
|
+
input: HookInput
|
|
27
|
+
): Promise<HookOutput> {
|
|
28
|
+
const transcriptPath = validateTranscriptPath(input.transcriptPath);
|
|
29
|
+
const sessionId = input.sessionId;
|
|
30
|
+
if (!transcriptPath || !sessionId) return makeEmptyOutput("feedback-loop");
|
|
31
|
+
|
|
32
|
+
// Get all notes injected during this session
|
|
33
|
+
const usages = store.getUsageForSession(sessionId);
|
|
34
|
+
if (usages.length === 0) return makeEmptyOutput("feedback-loop");
|
|
35
|
+
|
|
36
|
+
// Collect all injected paths
|
|
37
|
+
const injectedPaths = new Set<string>();
|
|
38
|
+
for (const u of usages) {
|
|
39
|
+
try {
|
|
40
|
+
const paths = JSON.parse(u.injectedPaths) as string[];
|
|
41
|
+
for (const p of paths) injectedPaths.add(p);
|
|
42
|
+
} catch {
|
|
43
|
+
// Skip malformed
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (injectedPaths.size === 0) return makeEmptyOutput("feedback-loop");
|
|
48
|
+
|
|
49
|
+
// Read assistant messages from transcript
|
|
50
|
+
const assistantMessages = readTranscript(transcriptPath, 200, "assistant");
|
|
51
|
+
if (assistantMessages.length === 0) return makeEmptyOutput("feedback-loop");
|
|
52
|
+
|
|
53
|
+
// Build full assistant text for reference detection
|
|
54
|
+
const assistantText = assistantMessages.map(m => m.content).join("\n");
|
|
55
|
+
|
|
56
|
+
// Detect references: check if the assistant mentioned any injected path or title
|
|
57
|
+
const referencedPaths: string[] = [];
|
|
58
|
+
|
|
59
|
+
for (const path of injectedPaths) {
|
|
60
|
+
// Check for path reference
|
|
61
|
+
if (assistantText.includes(path)) {
|
|
62
|
+
referencedPaths.push(path);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check for filename reference
|
|
67
|
+
const filename = path.split("/").pop()?.replace(/\.(md|txt)$/i, "");
|
|
68
|
+
if (filename && filename.length > 3 && assistantText.toLowerCase().includes(filename.toLowerCase())) {
|
|
69
|
+
referencedPaths.push(path);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check for title reference (look up from DB)
|
|
74
|
+
const titleMatch = checkTitleReference(store, path, assistantText);
|
|
75
|
+
if (titleMatch) {
|
|
76
|
+
referencedPaths.push(path);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Boost access counts for referenced notes
|
|
81
|
+
if (referencedPaths.length > 0) {
|
|
82
|
+
store.incrementAccessCount(referencedPaths);
|
|
83
|
+
|
|
84
|
+
// Mark usage records as referenced
|
|
85
|
+
for (const u of usages) {
|
|
86
|
+
try {
|
|
87
|
+
const paths = JSON.parse(u.injectedPaths) as string[];
|
|
88
|
+
if (paths.some(p => referencedPaths.includes(p))) {
|
|
89
|
+
store.markUsageReferenced(u.id);
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// Skip
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Record usage relations between co-referenced documents
|
|
97
|
+
if (referencedPaths.length >= 2) {
|
|
98
|
+
try {
|
|
99
|
+
const docIds = new Map<string, number>();
|
|
100
|
+
for (const path of referencedPaths) {
|
|
101
|
+
const parts = path.split("/");
|
|
102
|
+
if (parts.length < 2) continue;
|
|
103
|
+
const collection = parts[0]!;
|
|
104
|
+
const docPath = parts.slice(1).join("/");
|
|
105
|
+
const doc = store.findActiveDocument(collection, docPath);
|
|
106
|
+
if (doc) docIds.set(path, doc.id);
|
|
107
|
+
}
|
|
108
|
+
const ids = [...docIds.values()];
|
|
109
|
+
for (let i = 0; i < ids.length; i++) {
|
|
110
|
+
for (let j = i + 1; j < ids.length; j++) {
|
|
111
|
+
store.insertRelation(ids[i]!, ids[j]!, "usage");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Non-critical — don't block feedback loop on relation errors
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Record co-activations for the referenced paths
|
|
120
|
+
if (referencedPaths.length >= 2) {
|
|
121
|
+
store.recordCoActivation(referencedPaths);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Utility tracking: detect pin/snooze candidates based on usage patterns
|
|
126
|
+
try {
|
|
127
|
+
trackUtilitySignals(store, injectedPaths, referencedPaths);
|
|
128
|
+
} catch {
|
|
129
|
+
// Non-critical — don't block feedback loop on utility tracking errors
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Silent return — feedback loop doesn't inject context
|
|
133
|
+
return makeEmptyOutput("feedback-loop");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// =============================================================================
|
|
137
|
+
// Utility Signal Tracking
|
|
138
|
+
// =============================================================================
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Track utility signals for lifecycle automation (ReMe-inspired u/f ratio).
|
|
142
|
+
*
|
|
143
|
+
* For each injected path, records whether it was referenced (useful) or not (noise).
|
|
144
|
+
* Over time this builds a utility profile per document:
|
|
145
|
+
* - High utility (referenced often) → pin candidate
|
|
146
|
+
* - Low utility (surfaced often, never referenced) → snooze candidate
|
|
147
|
+
*
|
|
148
|
+
* Writes to `utility_signals` table (created lazily).
|
|
149
|
+
*/
|
|
150
|
+
function trackUtilitySignals(
|
|
151
|
+
store: Store,
|
|
152
|
+
injectedPaths: Set<string>,
|
|
153
|
+
referencedPaths: string[]
|
|
154
|
+
): void {
|
|
155
|
+
store.db.exec(`
|
|
156
|
+
CREATE TABLE IF NOT EXISTS utility_signals (
|
|
157
|
+
path TEXT NOT NULL,
|
|
158
|
+
surfaced_count INTEGER NOT NULL DEFAULT 0,
|
|
159
|
+
referenced_count INTEGER NOT NULL DEFAULT 0,
|
|
160
|
+
last_surfaced TEXT,
|
|
161
|
+
last_referenced TEXT,
|
|
162
|
+
PRIMARY KEY (path)
|
|
163
|
+
)
|
|
164
|
+
`);
|
|
165
|
+
|
|
166
|
+
const referencedSet = new Set(referencedPaths);
|
|
167
|
+
const now = new Date().toISOString();
|
|
168
|
+
|
|
169
|
+
const upsert = store.db.prepare(`
|
|
170
|
+
INSERT INTO utility_signals (path, surfaced_count, referenced_count, last_surfaced, last_referenced)
|
|
171
|
+
VALUES (?, 1, ?, ?, ?)
|
|
172
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
173
|
+
surfaced_count = surfaced_count + 1,
|
|
174
|
+
referenced_count = referenced_count + ?,
|
|
175
|
+
last_surfaced = ?,
|
|
176
|
+
last_referenced = CASE WHEN ? > 0 THEN ? ELSE last_referenced END
|
|
177
|
+
`);
|
|
178
|
+
|
|
179
|
+
for (const path of injectedPaths) {
|
|
180
|
+
const wasReferenced = referencedSet.has(path) ? 1 : 0;
|
|
181
|
+
upsert.run(
|
|
182
|
+
path,
|
|
183
|
+
wasReferenced,
|
|
184
|
+
now,
|
|
185
|
+
wasReferenced > 0 ? now : null,
|
|
186
|
+
wasReferenced,
|
|
187
|
+
now,
|
|
188
|
+
wasReferenced,
|
|
189
|
+
now
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// =============================================================================
|
|
195
|
+
// Reference Detection
|
|
196
|
+
// =============================================================================
|
|
197
|
+
|
|
198
|
+
function checkTitleReference(store: Store, path: string, text: string): boolean {
|
|
199
|
+
try {
|
|
200
|
+
const parts = path.split("/");
|
|
201
|
+
if (parts.length < 2) return false;
|
|
202
|
+
const collection = parts[0]!;
|
|
203
|
+
const docPath = parts.slice(1).join("/");
|
|
204
|
+
const doc = store.findActiveDocument(collection, docPath);
|
|
205
|
+
if (!doc?.title) return false;
|
|
206
|
+
|
|
207
|
+
// Skip generic titles
|
|
208
|
+
if (doc.title.length < 5) return false;
|
|
209
|
+
|
|
210
|
+
return text.toLowerCase().includes(doc.title.toLowerCase());
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handoff Generator Hook - Stop
|
|
3
|
+
*
|
|
4
|
+
* Fires when a Claude Code session ends. Analyzes the transcript
|
|
5
|
+
* to generate a handoff note summarizing: what was done, current state,
|
|
6
|
+
* decisions made, and next steps. Stored in _clawmem collection.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Store } from "../store.ts";
|
|
10
|
+
import type { HookInput, HookOutput } from "../hooks.ts";
|
|
11
|
+
import {
|
|
12
|
+
makeContextOutput,
|
|
13
|
+
makeEmptyOutput,
|
|
14
|
+
readTranscript,
|
|
15
|
+
validateTranscriptPath,
|
|
16
|
+
type TranscriptMessage,
|
|
17
|
+
} from "../hooks.ts";
|
|
18
|
+
import { extractSummary, type SessionSummary } from "../observer.ts";
|
|
19
|
+
import { updateDirectoryContext } from "../directory-context.ts";
|
|
20
|
+
import { loadConfig } from "../collections.ts";
|
|
21
|
+
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// Config
|
|
24
|
+
// =============================================================================
|
|
25
|
+
|
|
26
|
+
const MIN_MESSAGES_FOR_HANDOFF = 4;
|
|
27
|
+
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// Handler
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
32
|
+
export async function handoffGenerator(
|
|
33
|
+
store: Store,
|
|
34
|
+
input: HookInput
|
|
35
|
+
): Promise<HookOutput> {
|
|
36
|
+
const transcriptPath = validateTranscriptPath(input.transcriptPath);
|
|
37
|
+
if (!transcriptPath) return makeEmptyOutput("handoff-generator");
|
|
38
|
+
|
|
39
|
+
const messages = readTranscript(transcriptPath, 200);
|
|
40
|
+
if (messages.length < MIN_MESSAGES_FOR_HANDOFF) return makeEmptyOutput("handoff-generator");
|
|
41
|
+
|
|
42
|
+
const sessionId = input.sessionId || `session-${Date.now()}`;
|
|
43
|
+
const now = new Date();
|
|
44
|
+
const timestamp = now.toISOString();
|
|
45
|
+
const dateStr = timestamp.slice(0, 10);
|
|
46
|
+
|
|
47
|
+
// Try observer for rich summary, fall back to regex
|
|
48
|
+
const summary = await extractSummary(messages);
|
|
49
|
+
const handoff = summary
|
|
50
|
+
? buildHandoffFromSummary(summary, messages, sessionId, dateStr)
|
|
51
|
+
: buildHandoff(messages, sessionId, dateStr);
|
|
52
|
+
|
|
53
|
+
// Use saveMemory API with dedup protection.
|
|
54
|
+
// semanticPayload = session ID + core summary fields.
|
|
55
|
+
// Include sessionId so different sessions never dedup even if content is similar.
|
|
56
|
+
// SessionSummary fields: request, investigated, learned, completed, nextSteps (all strings)
|
|
57
|
+
const semanticPayload = summary
|
|
58
|
+
? [sessionId, summary.request, summary.investigated, summary.learned, summary.completed, summary.nextSteps].filter(Boolean).join("\n")
|
|
59
|
+
: [sessionId, extractSummaryLine(messages) || handoff].join("\n");
|
|
60
|
+
|
|
61
|
+
const handoffPath = `handoffs/${dateStr}-${sessionId.slice(0, 8)}.md`;
|
|
62
|
+
const result = store.saveMemory({
|
|
63
|
+
collection: "_clawmem",
|
|
64
|
+
path: handoffPath,
|
|
65
|
+
title: `Handoff ${dateStr}`,
|
|
66
|
+
body: handoff,
|
|
67
|
+
contentType: "handoff",
|
|
68
|
+
confidence: 0.60,
|
|
69
|
+
semanticPayload,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (result.action === 'deduplicated') {
|
|
73
|
+
process.stderr.write(`[handoff-generator] Dedup: existing handoff within window (doc ${result.docId}, count=${result.duplicateCount})\n`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Update session record with handoff path
|
|
77
|
+
try {
|
|
78
|
+
store.updateSession(sessionId, {
|
|
79
|
+
endedAt: timestamp,
|
|
80
|
+
handoffPath,
|
|
81
|
+
summary: extractSummaryLine(messages),
|
|
82
|
+
});
|
|
83
|
+
} catch {
|
|
84
|
+
// Non-fatal
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Extract files changed from transcript
|
|
88
|
+
const filesChanged = extractFilesChanged(messages);
|
|
89
|
+
if (filesChanged.length > 0) {
|
|
90
|
+
try {
|
|
91
|
+
store.updateSession(sessionId, { filesChanged });
|
|
92
|
+
} catch { /* non-fatal */ }
|
|
93
|
+
|
|
94
|
+
// Trigger directory context update if enabled
|
|
95
|
+
const config = loadConfig();
|
|
96
|
+
if (config.directoryContext) {
|
|
97
|
+
try {
|
|
98
|
+
updateDirectoryContext(store, filesChanged);
|
|
99
|
+
} catch { /* non-fatal */ }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return makeContextOutput(
|
|
104
|
+
"handoff-generator",
|
|
105
|
+
`<vault-handoff>Handoff note saved: ${handoffPath}</vault-handoff>`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// =============================================================================
|
|
110
|
+
// Observer-based Handoff Builder
|
|
111
|
+
// =============================================================================
|
|
112
|
+
|
|
113
|
+
function buildHandoffFromSummary(
|
|
114
|
+
summary: SessionSummary,
|
|
115
|
+
messages: TranscriptMessage[],
|
|
116
|
+
sessionId: string,
|
|
117
|
+
dateStr: string
|
|
118
|
+
): string {
|
|
119
|
+
const filesChanged = extractFilesChanged(messages);
|
|
120
|
+
|
|
121
|
+
const lines = [
|
|
122
|
+
`---`,
|
|
123
|
+
`content_type: handoff`,
|
|
124
|
+
`tags: [auto-generated, observer]`,
|
|
125
|
+
`---`,
|
|
126
|
+
``,
|
|
127
|
+
`# Session Handoff — ${dateStr}`,
|
|
128
|
+
``,
|
|
129
|
+
`Session: \`${sessionId.slice(0, 8)}\``,
|
|
130
|
+
``,
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
if (summary.request !== "None") {
|
|
134
|
+
lines.push(`## Request`, ``, summary.request, ``);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (summary.investigated !== "None") {
|
|
138
|
+
lines.push(`## What Was Investigated`, ``, summary.investigated, ``);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (summary.learned !== "None") {
|
|
142
|
+
lines.push(`## What Was Learned`, ``, summary.learned, ``);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (summary.completed !== "None") {
|
|
146
|
+
lines.push(`## What Was Done`, ``, summary.completed, ``);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (filesChanged.length > 0) {
|
|
150
|
+
lines.push(`## Files Changed`, ``);
|
|
151
|
+
for (const f of filesChanged.slice(0, 20)) {
|
|
152
|
+
lines.push(`- \`${f}\``);
|
|
153
|
+
}
|
|
154
|
+
lines.push(``);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (summary.nextSteps !== "None") {
|
|
158
|
+
lines.push(`## Next Session Should`, ``, summary.nextSteps, ``);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return lines.join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// =============================================================================
|
|
165
|
+
// Regex-based Handoff Builder (Fallback)
|
|
166
|
+
// =============================================================================
|
|
167
|
+
|
|
168
|
+
function buildHandoff(
|
|
169
|
+
messages: TranscriptMessage[],
|
|
170
|
+
sessionId: string,
|
|
171
|
+
dateStr: string
|
|
172
|
+
): string {
|
|
173
|
+
const topics = extractTopics(messages);
|
|
174
|
+
const actions = extractActions(messages);
|
|
175
|
+
const nextSteps = extractNextSteps(messages);
|
|
176
|
+
const filesChanged = extractFilesChanged(messages);
|
|
177
|
+
|
|
178
|
+
const lines = [
|
|
179
|
+
`---`,
|
|
180
|
+
`content_type: handoff`,
|
|
181
|
+
`tags: [auto-generated]`,
|
|
182
|
+
`---`,
|
|
183
|
+
``,
|
|
184
|
+
`# Session Handoff — ${dateStr}`,
|
|
185
|
+
``,
|
|
186
|
+
`Session: \`${sessionId.slice(0, 8)}\``,
|
|
187
|
+
``,
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
if (topics.length > 0) {
|
|
191
|
+
lines.push(`## Current State`, ``);
|
|
192
|
+
for (const topic of topics) {
|
|
193
|
+
lines.push(`- ${topic}`);
|
|
194
|
+
}
|
|
195
|
+
lines.push(``);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (actions.length > 0) {
|
|
199
|
+
lines.push(`## What Was Done`, ``);
|
|
200
|
+
for (const action of actions) {
|
|
201
|
+
lines.push(`- ${action}`);
|
|
202
|
+
}
|
|
203
|
+
lines.push(``);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (filesChanged.length > 0) {
|
|
207
|
+
lines.push(`## Files Changed`, ``);
|
|
208
|
+
for (const f of filesChanged.slice(0, 20)) {
|
|
209
|
+
lines.push(`- \`${f}\``);
|
|
210
|
+
}
|
|
211
|
+
lines.push(``);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (nextSteps.length > 0) {
|
|
215
|
+
lines.push(`## Next Session Should`, ``);
|
|
216
|
+
for (const step of nextSteps) {
|
|
217
|
+
lines.push(`- ${step}`);
|
|
218
|
+
}
|
|
219
|
+
lines.push(``);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return lines.join("\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// =============================================================================
|
|
226
|
+
// Content Extraction
|
|
227
|
+
// =============================================================================
|
|
228
|
+
|
|
229
|
+
function extractTopics(messages: TranscriptMessage[]): string[] {
|
|
230
|
+
const topics: string[] = [];
|
|
231
|
+
const seen = new Set<string>();
|
|
232
|
+
|
|
233
|
+
// Get themes from user messages
|
|
234
|
+
for (const msg of messages) {
|
|
235
|
+
if (msg.role !== "user") continue;
|
|
236
|
+
const first = msg.content.split("\n")[0]?.trim();
|
|
237
|
+
if (!first || first.length < 10 || first.length > 200) continue;
|
|
238
|
+
if (first.startsWith("/")) continue; // slash commands
|
|
239
|
+
|
|
240
|
+
const key = first.slice(0, 50).toLowerCase();
|
|
241
|
+
if (seen.has(key)) continue;
|
|
242
|
+
seen.add(key);
|
|
243
|
+
topics.push(first);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return topics.slice(0, 5);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function extractActions(messages: TranscriptMessage[]): string[] {
|
|
250
|
+
const actions: string[] = [];
|
|
251
|
+
const seen = new Set<string>();
|
|
252
|
+
|
|
253
|
+
const actionPatterns = [
|
|
254
|
+
/\b(?:created|wrote|added|implemented|built|set up|configured|installed|fixed|updated|modified|refactored|deleted|removed)\b/i,
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
for (const msg of messages) {
|
|
258
|
+
if (msg.role !== "assistant") continue;
|
|
259
|
+
|
|
260
|
+
const sentences = msg.content.split(/(?<=[.!?])\s+/);
|
|
261
|
+
for (const sentence of sentences) {
|
|
262
|
+
if (sentence.length < 15 || sentence.length > 300) continue;
|
|
263
|
+
if (!actionPatterns.some(p => p.test(sentence))) continue;
|
|
264
|
+
|
|
265
|
+
const key = sentence.slice(0, 60).toLowerCase();
|
|
266
|
+
if (seen.has(key)) continue;
|
|
267
|
+
seen.add(key);
|
|
268
|
+
actions.push(sentence.trim());
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return actions.slice(0, 10);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function extractNextSteps(messages: TranscriptMessage[]): string[] {
|
|
276
|
+
const nextSteps: string[] = [];
|
|
277
|
+
const seen = new Set<string>();
|
|
278
|
+
|
|
279
|
+
const nextPatterns = [
|
|
280
|
+
/\bnext\s+(?:step|task|we\s+(?:need|should|can)|up|thing)\b/i,
|
|
281
|
+
/\btodo\b/i,
|
|
282
|
+
/\bremaining\b/i,
|
|
283
|
+
/\blater\b.*\b(?:we|you)\s+(?:can|should|need)\b/i,
|
|
284
|
+
/\bstill\s+need\s+to\b/i,
|
|
285
|
+
/\bnot\s+yet\s+(?:done|implemented|completed)\b/i,
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
// Scan last 30 messages (most relevant for next steps)
|
|
289
|
+
const tail = messages.slice(-30);
|
|
290
|
+
for (const msg of tail) {
|
|
291
|
+
if (msg.role !== "assistant") continue;
|
|
292
|
+
|
|
293
|
+
const sentences = msg.content.split(/(?<=[.!?])\s+/);
|
|
294
|
+
for (const sentence of sentences) {
|
|
295
|
+
if (sentence.length < 15 || sentence.length > 300) continue;
|
|
296
|
+
if (!nextPatterns.some(p => p.test(sentence))) continue;
|
|
297
|
+
|
|
298
|
+
const key = sentence.slice(0, 60).toLowerCase();
|
|
299
|
+
if (seen.has(key)) continue;
|
|
300
|
+
seen.add(key);
|
|
301
|
+
nextSteps.push(sentence.trim());
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return nextSteps.slice(0, 5);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const MAX_FILES_EXTRACTED = 200;
|
|
309
|
+
|
|
310
|
+
function extractFilesChanged(messages: TranscriptMessage[]): string[] {
|
|
311
|
+
const files = new Set<string>();
|
|
312
|
+
|
|
313
|
+
const filePatterns = [
|
|
314
|
+
/(?:created|wrote|edited|modified|updated|deleted)\s+(?:file\s+)?[`"]?([^\s`"]+\.\w{1,10})[`"]?/gi,
|
|
315
|
+
/(?:Write|Edit|Read)\s+tool.*?[`"]([^\s`"]+\.\w{1,10})[`"]?/gi,
|
|
316
|
+
/^\s*(?:[-+]){3}\s+(a|b)\/(.+\.\w{1,10})/gm,
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
for (const msg of messages) {
|
|
320
|
+
if (msg.role !== "assistant") continue;
|
|
321
|
+
if (files.size >= MAX_FILES_EXTRACTED) break;
|
|
322
|
+
for (const pattern of filePatterns) {
|
|
323
|
+
pattern.lastIndex = 0;
|
|
324
|
+
let match;
|
|
325
|
+
while ((match = pattern.exec(msg.content)) !== null) {
|
|
326
|
+
if (files.size >= MAX_FILES_EXTRACTED) break;
|
|
327
|
+
const file = match[2] || match[1];
|
|
328
|
+
if (file && !file.includes("*") && file.length < 200) {
|
|
329
|
+
files.add(file);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return [...files];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function extractSummaryLine(messages: TranscriptMessage[]): string {
|
|
339
|
+
// Get first user message as summary theme
|
|
340
|
+
const firstUser = messages.find(m => m.role === "user");
|
|
341
|
+
if (!firstUser) return "Unknown session";
|
|
342
|
+
|
|
343
|
+
const first = firstUser.content.split("\n")[0]?.trim() || "";
|
|
344
|
+
return first.length > 100 ? first.slice(0, 100) + "..." : first;
|
|
345
|
+
}
|