@massu/core 0.1.0 → 0.1.2
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 +71 -0
- package/README.md +2 -2
- package/dist/hooks/cost-tracker.js +149 -11527
- package/dist/hooks/post-edit-context.js +127 -11493
- package/dist/hooks/post-tool-use.js +169 -11550
- package/dist/hooks/pre-compact.js +149 -11530
- package/dist/hooks/pre-delete-check.js +144 -11523
- package/dist/hooks/quality-event.js +149 -11527
- package/dist/hooks/session-end.js +188 -11570
- package/dist/hooks/session-start.js +159 -11534
- package/dist/hooks/user-prompt.js +149 -11530
- package/package.json +14 -19
- package/src/adr-generator.ts +292 -0
- package/src/analytics.ts +373 -0
- package/src/audit-trail.ts +450 -0
- package/src/backfill-sessions.ts +180 -0
- package/src/cli.ts +105 -0
- package/src/cloud-sync.ts +190 -0
- package/src/commands/doctor.ts +300 -0
- package/src/commands/init.ts +395 -0
- package/src/commands/install-hooks.ts +26 -0
- package/src/config.ts +357 -0
- package/src/cost-tracker.ts +355 -0
- package/src/db.ts +233 -0
- package/src/dependency-scorer.ts +337 -0
- package/src/docs-map.json +100 -0
- package/src/docs-tools.ts +517 -0
- package/src/domains.ts +181 -0
- package/src/hooks/cost-tracker.ts +66 -0
- package/src/hooks/intent-suggester.ts +131 -0
- package/src/hooks/post-edit-context.ts +91 -0
- package/src/hooks/post-tool-use.ts +175 -0
- package/src/hooks/pre-compact.ts +146 -0
- package/src/hooks/pre-delete-check.ts +153 -0
- package/src/hooks/quality-event.ts +127 -0
- package/src/hooks/security-gate.ts +121 -0
- package/src/hooks/session-end.ts +467 -0
- package/src/hooks/session-start.ts +210 -0
- package/src/hooks/user-prompt.ts +91 -0
- package/src/import-resolver.ts +224 -0
- package/src/memory-db.ts +1376 -0
- package/src/memory-tools.ts +391 -0
- package/src/middleware-tree.ts +70 -0
- package/src/observability-tools.ts +343 -0
- package/src/observation-extractor.ts +411 -0
- package/src/page-deps.ts +283 -0
- package/src/prompt-analyzer.ts +332 -0
- package/src/regression-detector.ts +319 -0
- package/src/rules.ts +57 -0
- package/src/schema-mapper.ts +232 -0
- package/src/security-scorer.ts +405 -0
- package/src/security-utils.ts +133 -0
- package/src/sentinel-db.ts +578 -0
- package/src/sentinel-scanner.ts +405 -0
- package/src/sentinel-tools.ts +512 -0
- package/src/sentinel-types.ts +140 -0
- package/src/server.ts +189 -0
- package/src/session-archiver.ts +112 -0
- package/src/session-state-generator.ts +174 -0
- package/src/team-knowledge.ts +407 -0
- package/src/tools.ts +847 -0
- package/src/transcript-parser.ts +458 -0
- package/src/trpc-index.ts +214 -0
- package/src/validate-features-runner.ts +106 -0
- package/src/validation-engine.ts +358 -0
- package/dist/cli.js +0 -7890
- package/dist/server.js +0 -7008
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
import { createReadStream } from 'fs';
|
|
5
|
+
import { createInterface } from 'readline';
|
|
6
|
+
|
|
7
|
+
// ============================================================
|
|
8
|
+
// P2-001: JSONL Transcript Parser
|
|
9
|
+
// ============================================================
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Represents a parsed transcript entry.
|
|
13
|
+
*/
|
|
14
|
+
export interface TranscriptEntry {
|
|
15
|
+
type: 'user' | 'assistant' | 'system' | 'progress' | 'summary' | 'file-history-snapshot' | 'unknown';
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
gitBranch?: string;
|
|
18
|
+
timestamp?: string;
|
|
19
|
+
uuid?: string;
|
|
20
|
+
isMeta?: boolean;
|
|
21
|
+
message?: TranscriptMessage;
|
|
22
|
+
data?: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TranscriptMessage {
|
|
26
|
+
role: 'user' | 'assistant' | 'system';
|
|
27
|
+
content: TranscriptContentBlock[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type TranscriptContentBlock =
|
|
31
|
+
| { type: 'text'; text: string }
|
|
32
|
+
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
|
|
33
|
+
| { type: 'tool_result'; tool_use_id: string; content: string | TranscriptContentBlock[]; is_error?: boolean }
|
|
34
|
+
| { type: string; [key: string]: unknown };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parsed tool call with input and result linked.
|
|
38
|
+
*/
|
|
39
|
+
export interface ParsedToolCall {
|
|
40
|
+
toolName: string;
|
|
41
|
+
toolUseId: string;
|
|
42
|
+
input: Record<string, unknown>;
|
|
43
|
+
result?: string;
|
|
44
|
+
isError?: boolean;
|
|
45
|
+
timestamp?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* File operation extracted from tool calls.
|
|
50
|
+
*/
|
|
51
|
+
export interface FileOperation {
|
|
52
|
+
type: 'read' | 'write' | 'edit' | 'glob' | 'grep' | 'delete';
|
|
53
|
+
filePath: string;
|
|
54
|
+
toolName: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Extracted decision from assistant text.
|
|
59
|
+
*/
|
|
60
|
+
export interface ExtractedDecision {
|
|
61
|
+
text: string;
|
|
62
|
+
context: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extracted failed attempt from assistant text.
|
|
67
|
+
*/
|
|
68
|
+
export interface ExtractedFailedAttempt {
|
|
69
|
+
text: string;
|
|
70
|
+
context: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse a JSONL transcript file line-by-line (streaming).
|
|
75
|
+
* Handles 400MB+ files without loading entire file into memory.
|
|
76
|
+
*/
|
|
77
|
+
export async function parseTranscript(filePath: string): Promise<TranscriptEntry[]> {
|
|
78
|
+
const entries: TranscriptEntry[] = [];
|
|
79
|
+
|
|
80
|
+
const rl = createInterface({
|
|
81
|
+
input: createReadStream(filePath, { encoding: 'utf-8' }),
|
|
82
|
+
crlfDelay: Infinity,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
for await (const line of rl) {
|
|
86
|
+
const trimmed = line.trim();
|
|
87
|
+
if (!trimmed) continue;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const raw = JSON.parse(trimmed) as Record<string, unknown>;
|
|
91
|
+
const entry = parseEntry(raw);
|
|
92
|
+
if (entry) {
|
|
93
|
+
entries.push(entry);
|
|
94
|
+
}
|
|
95
|
+
} catch (_e) {
|
|
96
|
+
// Skip unparseable lines - defensive parsing
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return entries;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse a single JSONL entry into a TranscriptEntry.
|
|
106
|
+
*/
|
|
107
|
+
function parseEntry(raw: Record<string, unknown>): TranscriptEntry | null {
|
|
108
|
+
const entryType = raw.type as string | undefined;
|
|
109
|
+
if (!entryType) return null;
|
|
110
|
+
|
|
111
|
+
const base: TranscriptEntry = {
|
|
112
|
+
type: (['user', 'assistant', 'system', 'progress', 'summary', 'file-history-snapshot'].includes(entryType)
|
|
113
|
+
? entryType
|
|
114
|
+
: 'unknown') as TranscriptEntry['type'],
|
|
115
|
+
sessionId: raw.sessionId as string | undefined,
|
|
116
|
+
gitBranch: raw.gitBranch as string | undefined,
|
|
117
|
+
timestamp: raw.timestamp as string | undefined,
|
|
118
|
+
uuid: raw.uuid as string | undefined,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if (raw.isMeta) {
|
|
122
|
+
base.isMeta = true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (entryType === 'user' || entryType === 'assistant') {
|
|
126
|
+
const msgRaw = raw.message as Record<string, unknown> | undefined;
|
|
127
|
+
if (msgRaw) {
|
|
128
|
+
base.message = {
|
|
129
|
+
role: (msgRaw.role as string ?? entryType) as 'user' | 'assistant',
|
|
130
|
+
content: normalizeContent(msgRaw.content),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (entryType === 'progress') {
|
|
136
|
+
base.data = raw.data as Record<string, unknown> | undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return base;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Normalize content to array of content blocks.
|
|
144
|
+
*/
|
|
145
|
+
function normalizeContent(content: unknown): TranscriptContentBlock[] {
|
|
146
|
+
if (!content) return [];
|
|
147
|
+
if (typeof content === 'string') {
|
|
148
|
+
return [{ type: 'text', text: content }];
|
|
149
|
+
}
|
|
150
|
+
if (Array.isArray(content)) {
|
|
151
|
+
return content.filter((block): block is TranscriptContentBlock =>
|
|
152
|
+
typeof block === 'object' && block !== null && 'type' in block
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ============================================================
|
|
159
|
+
// Extraction utilities
|
|
160
|
+
// ============================================================
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Extract all user messages from transcript entries.
|
|
164
|
+
*/
|
|
165
|
+
export function extractUserMessages(entries: TranscriptEntry[]): Array<{ text: string; timestamp?: string }> {
|
|
166
|
+
const messages: Array<{ text: string; timestamp?: string }> = [];
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
if (entry.type !== 'user' || !entry.message) continue;
|
|
169
|
+
// Skip meta/system messages
|
|
170
|
+
if (entry.isMeta) continue;
|
|
171
|
+
|
|
172
|
+
const text = getTextFromContent(entry.message.content);
|
|
173
|
+
if (text.trim()) {
|
|
174
|
+
messages.push({ text: text.trim(), timestamp: entry.timestamp });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return messages;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Extract all assistant text messages.
|
|
182
|
+
*/
|
|
183
|
+
export function extractAssistantMessages(entries: TranscriptEntry[]): Array<{ text: string; timestamp?: string }> {
|
|
184
|
+
const messages: Array<{ text: string; timestamp?: string }> = [];
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
if (entry.type !== 'assistant' || !entry.message) continue;
|
|
187
|
+
const text = getTextFromContent(entry.message.content);
|
|
188
|
+
if (text.trim()) {
|
|
189
|
+
messages.push({ text: text.trim(), timestamp: entry.timestamp });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return messages;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Extract all tool calls from transcript entries.
|
|
197
|
+
*/
|
|
198
|
+
export function extractToolCalls(entries: TranscriptEntry[]): ParsedToolCall[] {
|
|
199
|
+
const toolCalls: ParsedToolCall[] = [];
|
|
200
|
+
const toolUseMap = new Map<string, ParsedToolCall>();
|
|
201
|
+
|
|
202
|
+
for (const entry of entries) {
|
|
203
|
+
if (!entry.message?.content) continue;
|
|
204
|
+
|
|
205
|
+
for (const block of entry.message.content) {
|
|
206
|
+
if (block.type === 'tool_use') {
|
|
207
|
+
const tc: ParsedToolCall = {
|
|
208
|
+
toolName: (block as { name: string }).name,
|
|
209
|
+
toolUseId: (block as { id: string }).id,
|
|
210
|
+
input: (block as { input: Record<string, unknown> }).input ?? {},
|
|
211
|
+
timestamp: entry.timestamp,
|
|
212
|
+
};
|
|
213
|
+
toolCalls.push(tc);
|
|
214
|
+
toolUseMap.set(tc.toolUseId, tc);
|
|
215
|
+
} else if (block.type === 'tool_result') {
|
|
216
|
+
const toolUseId = (block as { tool_use_id: string }).tool_use_id;
|
|
217
|
+
const existing = toolUseMap.get(toolUseId);
|
|
218
|
+
if (existing) {
|
|
219
|
+
existing.result = getToolResultText(block);
|
|
220
|
+
existing.isError = (block as { is_error?: boolean }).is_error ?? false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return toolCalls;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Extract file operations from tool calls.
|
|
231
|
+
*/
|
|
232
|
+
export function extractFileOperations(toolCalls: ParsedToolCall[]): FileOperation[] {
|
|
233
|
+
const ops: FileOperation[] = [];
|
|
234
|
+
|
|
235
|
+
for (const tc of toolCalls) {
|
|
236
|
+
switch (tc.toolName) {
|
|
237
|
+
case 'Read': {
|
|
238
|
+
const filePath = tc.input.file_path as string;
|
|
239
|
+
if (filePath) ops.push({ type: 'read', filePath, toolName: 'Read' });
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case 'Write': {
|
|
243
|
+
const filePath = tc.input.file_path as string;
|
|
244
|
+
if (filePath) ops.push({ type: 'write', filePath, toolName: 'Write' });
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
case 'Edit': {
|
|
248
|
+
const filePath = tc.input.file_path as string;
|
|
249
|
+
if (filePath) ops.push({ type: 'edit', filePath, toolName: 'Edit' });
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
case 'Glob': {
|
|
253
|
+
ops.push({ type: 'glob', filePath: tc.input.pattern as string ?? '', toolName: 'Glob' });
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case 'Grep': {
|
|
257
|
+
ops.push({ type: 'grep', filePath: tc.input.path as string ?? '', toolName: 'Grep' });
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return ops;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Extract verification commands from tool calls.
|
|
268
|
+
*/
|
|
269
|
+
export function extractVerificationCommands(toolCalls: ParsedToolCall[]): Array<{
|
|
270
|
+
vrType: string;
|
|
271
|
+
command: string;
|
|
272
|
+
result: string;
|
|
273
|
+
passed: boolean;
|
|
274
|
+
}> {
|
|
275
|
+
const verifications: Array<{
|
|
276
|
+
vrType: string;
|
|
277
|
+
command: string;
|
|
278
|
+
result: string;
|
|
279
|
+
passed: boolean;
|
|
280
|
+
}> = [];
|
|
281
|
+
|
|
282
|
+
for (const tc of toolCalls) {
|
|
283
|
+
if (tc.toolName !== 'Bash') continue;
|
|
284
|
+
const cmd = tc.input.command as string ?? '';
|
|
285
|
+
const result = tc.result ?? '';
|
|
286
|
+
|
|
287
|
+
// Pattern scanner
|
|
288
|
+
if (cmd.includes('pattern-scanner')) {
|
|
289
|
+
verifications.push({
|
|
290
|
+
vrType: 'VR-PATTERN',
|
|
291
|
+
command: cmd,
|
|
292
|
+
result: result.slice(0, 500),
|
|
293
|
+
passed: !result.includes('FAIL') && !result.includes('BLOCKED'),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
// Build
|
|
297
|
+
if (cmd.includes('npm run build')) {
|
|
298
|
+
verifications.push({
|
|
299
|
+
vrType: 'VR-BUILD',
|
|
300
|
+
command: cmd,
|
|
301
|
+
result: result.slice(0, 500),
|
|
302
|
+
passed: !tc.isError && !result.includes('error'),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
// Type check
|
|
306
|
+
if (cmd.includes('tsc --noEmit')) {
|
|
307
|
+
verifications.push({
|
|
308
|
+
vrType: 'VR-TYPE',
|
|
309
|
+
command: cmd,
|
|
310
|
+
result: result.slice(0, 500),
|
|
311
|
+
passed: !tc.isError && !result.includes('error'),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
// Tests
|
|
315
|
+
if (cmd.includes('npm test') || cmd.includes('vitest run') || cmd.includes('vitest ')) {
|
|
316
|
+
verifications.push({
|
|
317
|
+
vrType: 'VR-TEST',
|
|
318
|
+
command: cmd,
|
|
319
|
+
result: result.slice(0, 500),
|
|
320
|
+
passed: !tc.isError && !result.includes('FAIL'),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return verifications;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Extract decisions from assistant text (heuristic).
|
|
330
|
+
*/
|
|
331
|
+
export function extractDecisions(entries: TranscriptEntry[]): ExtractedDecision[] {
|
|
332
|
+
const decisions: ExtractedDecision[] = [];
|
|
333
|
+
const decisionPatterns = /\b(decided|chose|chosen|decision|instead of|opted for|going with|approach:|strategy:)\b/i;
|
|
334
|
+
|
|
335
|
+
for (const entry of entries) {
|
|
336
|
+
if (entry.type !== 'assistant' || !entry.message) continue;
|
|
337
|
+
const text = getTextFromContent(entry.message.content);
|
|
338
|
+
if (!text) continue;
|
|
339
|
+
|
|
340
|
+
// Split into sentences/paragraphs
|
|
341
|
+
const paragraphs = text.split(/\n\n|\.\s+/);
|
|
342
|
+
for (const para of paragraphs) {
|
|
343
|
+
if (decisionPatterns.test(para) && para.length > 20 && para.length < 500) {
|
|
344
|
+
decisions.push({
|
|
345
|
+
text: para.trim().slice(0, 300),
|
|
346
|
+
context: text.slice(0, 200),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return decisions;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Extract failed attempts from assistant text (heuristic).
|
|
357
|
+
*/
|
|
358
|
+
export function extractFailedAttempts(entries: TranscriptEntry[]): ExtractedFailedAttempt[] {
|
|
359
|
+
const failures: ExtractedFailedAttempt[] = [];
|
|
360
|
+
const failurePatterns = /\b(error|failed|doesn't work|didn't work|reverted|rolled back|bug|broken|issue:|problem:)\b/i;
|
|
361
|
+
|
|
362
|
+
for (const entry of entries) {
|
|
363
|
+
if (entry.type !== 'assistant' || !entry.message) continue;
|
|
364
|
+
const text = getTextFromContent(entry.message.content);
|
|
365
|
+
if (!text) continue;
|
|
366
|
+
|
|
367
|
+
const paragraphs = text.split(/\n\n|\.\s+/);
|
|
368
|
+
for (const para of paragraphs) {
|
|
369
|
+
if (failurePatterns.test(para) && para.length > 20 && para.length < 500) {
|
|
370
|
+
failures.push({
|
|
371
|
+
text: para.trim().slice(0, 300),
|
|
372
|
+
context: text.slice(0, 200),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return failures;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Parse a JSONL transcript file starting from a specific line (for incremental parsing).
|
|
383
|
+
* Skips the first `startLine` lines and returns entries from the rest.
|
|
384
|
+
*/
|
|
385
|
+
export async function parseTranscriptFrom(filePath: string, startLine: number): Promise<{ entries: TranscriptEntry[]; totalLines: number }> {
|
|
386
|
+
const entries: TranscriptEntry[] = [];
|
|
387
|
+
let lineNumber = 0;
|
|
388
|
+
|
|
389
|
+
const rl = createInterface({
|
|
390
|
+
input: createReadStream(filePath, { encoding: 'utf-8' }),
|
|
391
|
+
crlfDelay: Infinity,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
for await (const line of rl) {
|
|
395
|
+
lineNumber++;
|
|
396
|
+
if (lineNumber <= startLine) continue;
|
|
397
|
+
|
|
398
|
+
const trimmed = line.trim();
|
|
399
|
+
if (!trimmed) continue;
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
const raw = JSON.parse(trimmed) as Record<string, unknown>;
|
|
403
|
+
const entry = parseEntry(raw);
|
|
404
|
+
if (entry) {
|
|
405
|
+
entries.push(entry);
|
|
406
|
+
}
|
|
407
|
+
} catch (_e) {
|
|
408
|
+
// Skip unparseable lines
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return { entries, totalLines: lineNumber };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Estimate token count for a text string.
|
|
418
|
+
* Approximation: chars / 4.
|
|
419
|
+
*/
|
|
420
|
+
export function estimateTokens(text: string): number {
|
|
421
|
+
return Math.ceil(text.length / 4);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Get the last assistant message from entries (useful for session summaries).
|
|
426
|
+
*/
|
|
427
|
+
export function getLastAssistantMessage(entries: TranscriptEntry[]): string | null {
|
|
428
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
429
|
+
if (entries[i].type === 'assistant' && entries[i].message) {
|
|
430
|
+
const text = getTextFromContent(entries[i].message!.content);
|
|
431
|
+
if (text.trim()) return text.trim();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ============================================================
|
|
438
|
+
// Helpers
|
|
439
|
+
// ============================================================
|
|
440
|
+
|
|
441
|
+
function getTextFromContent(content: TranscriptContentBlock[]): string {
|
|
442
|
+
return content
|
|
443
|
+
.filter((block): block is { type: 'text'; text: string } => block.type === 'text')
|
|
444
|
+
.map(block => block.text)
|
|
445
|
+
.join('\n');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function getToolResultText(block: TranscriptContentBlock): string {
|
|
449
|
+
const content = (block as { content: string | TranscriptContentBlock[] }).content;
|
|
450
|
+
if (typeof content === 'string') return content;
|
|
451
|
+
if (Array.isArray(content)) {
|
|
452
|
+
return content
|
|
453
|
+
.filter((b): b is { type: 'text'; text: string } => typeof b === 'object' && b !== null && b.type === 'text')
|
|
454
|
+
.map(b => b.text)
|
|
455
|
+
.join('\n');
|
|
456
|
+
}
|
|
457
|
+
return '';
|
|
458
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
5
|
+
import { resolve, join } from 'path';
|
|
6
|
+
import type Database from 'better-sqlite3';
|
|
7
|
+
import { getConfig, getResolvedPaths, getProjectRoot } from './config.ts';
|
|
8
|
+
|
|
9
|
+
interface RouterMapping {
|
|
10
|
+
key: string; // e.g., "orders" (used as api.orders.*)
|
|
11
|
+
variable: string; // e.g., "ordersRouter"
|
|
12
|
+
file: string; // e.g., "src/server/api/routers/orders.ts"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ProcedureInfo {
|
|
16
|
+
name: string;
|
|
17
|
+
type: 'query' | 'mutation';
|
|
18
|
+
isProtected: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse src/server/api/root.ts to extract router key-to-file mapping.
|
|
23
|
+
* The key is what UI code uses: api.[key].[procedure]
|
|
24
|
+
*/
|
|
25
|
+
export function parseRootRouter(): RouterMapping[] {
|
|
26
|
+
const paths = getResolvedPaths();
|
|
27
|
+
const rootPath = paths.rootRouterPath;
|
|
28
|
+
if (!existsSync(rootPath)) {
|
|
29
|
+
throw new Error(`Root router not found at ${rootPath}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const source = readFileSync(rootPath, 'utf-8');
|
|
33
|
+
const mappings: RouterMapping[] = [];
|
|
34
|
+
|
|
35
|
+
// Step 1: Parse imports to map variable names to file paths
|
|
36
|
+
// import { ordersRouter } from './routers/orders'
|
|
37
|
+
const importMap = new Map<string, string>();
|
|
38
|
+
const importRegex = /import\s+\{[^}]*?(\w+Router)[^}]*\}\s+from\s+['"]\.\/routers\/([^'"]+)['"]/g;
|
|
39
|
+
let match;
|
|
40
|
+
while ((match = importRegex.exec(source)) !== null) {
|
|
41
|
+
const variable = match[1];
|
|
42
|
+
let filePath = match[2];
|
|
43
|
+
// Resolve to actual file path
|
|
44
|
+
const fullPath = resolve(paths.routersDir, filePath);
|
|
45
|
+
// Try with extensions
|
|
46
|
+
for (const ext of ['.ts', '.tsx', '']) {
|
|
47
|
+
const candidate = fullPath + ext;
|
|
48
|
+
const routersRelPath = getConfig().paths.routers ?? 'src/server/api/routers';
|
|
49
|
+
if (existsSync(candidate)) {
|
|
50
|
+
filePath = routersRelPath + '/' + filePath + ext;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
// Check if it's a directory with index
|
|
54
|
+
const indexCandidate = join(fullPath, 'index.ts');
|
|
55
|
+
if (existsSync(indexCandidate)) {
|
|
56
|
+
filePath = routersRelPath + '/' + filePath + '/index.ts';
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
importMap.set(variable, filePath);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Step 2: Parse router registration - "orders: ordersRouter"
|
|
64
|
+
const regRegex = /(\w+)\s*:\s*(\w+Router)/g;
|
|
65
|
+
while ((match = regRegex.exec(source)) !== null) {
|
|
66
|
+
const key = match[1];
|
|
67
|
+
const variable = match[2];
|
|
68
|
+
const file = importMap.get(variable);
|
|
69
|
+
if (file) {
|
|
70
|
+
mappings.push({ key, variable, file });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return mappings;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Extract procedure definitions from a router file.
|
|
79
|
+
*/
|
|
80
|
+
export function extractProcedures(routerFilePath: string): ProcedureInfo[] {
|
|
81
|
+
const absPath = resolve(getProjectRoot(), routerFilePath);
|
|
82
|
+
if (!existsSync(absPath)) return [];
|
|
83
|
+
|
|
84
|
+
const source = readFileSync(absPath, 'utf-8');
|
|
85
|
+
const procedures: ProcedureInfo[] = [];
|
|
86
|
+
const seen = new Set<string>();
|
|
87
|
+
|
|
88
|
+
// Pattern: procedureName: protectedProcedure or publicProcedure
|
|
89
|
+
const procRegex = /(\w+)\s*:\s*(protected|public)Procedure/g;
|
|
90
|
+
let match;
|
|
91
|
+
while ((match = procRegex.exec(source)) !== null) {
|
|
92
|
+
const name = match[1];
|
|
93
|
+
const isProtected = match[2] === 'protected';
|
|
94
|
+
if (seen.has(name)) continue;
|
|
95
|
+
seen.add(name);
|
|
96
|
+
|
|
97
|
+
// Determine if query or mutation by looking ahead
|
|
98
|
+
const afterMatch = source.slice(match.index);
|
|
99
|
+
const typeMatch = afterMatch.match(/\.(query|mutation)\s*\(/);
|
|
100
|
+
const type = typeMatch ? (typeMatch[1] as 'query' | 'mutation') : 'query';
|
|
101
|
+
|
|
102
|
+
procedures.push({ name, type, isProtected });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return procedures;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Find UI call sites for a given router key and procedure name.
|
|
110
|
+
*/
|
|
111
|
+
export function findUICallSites(routerKey: string, procedureName: string): { file: string; line: number; pattern: string }[] {
|
|
112
|
+
const callSites: { file: string; line: number; pattern: string }[] = [];
|
|
113
|
+
const config = getConfig();
|
|
114
|
+
const root = getProjectRoot();
|
|
115
|
+
const src = config.paths.source;
|
|
116
|
+
const searchDirs = [
|
|
117
|
+
resolve(root, config.paths.pages ?? (src + '/app')),
|
|
118
|
+
resolve(root, config.paths.components ?? (src + '/components')),
|
|
119
|
+
resolve(root, config.paths.hooks ?? (src + '/hooks')),
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const searchPattern = `api.${routerKey}.${procedureName}`;
|
|
123
|
+
|
|
124
|
+
for (const dir of searchDirs) {
|
|
125
|
+
if (!existsSync(dir)) continue;
|
|
126
|
+
searchDirectory(dir, searchPattern, callSites);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return callSites;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function searchDirectory(dir: string, pattern: string, results: { file: string; line: number; pattern: string }[]): void {
|
|
133
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
const fullPath = join(dir, entry.name);
|
|
136
|
+
if (entry.isDirectory()) {
|
|
137
|
+
if (entry.name === 'node_modules' || entry.name === '.next') continue;
|
|
138
|
+
searchDirectory(fullPath, pattern, results);
|
|
139
|
+
} else if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) {
|
|
140
|
+
try {
|
|
141
|
+
const source = readFileSync(fullPath, 'utf-8');
|
|
142
|
+
const lines = source.split('\n');
|
|
143
|
+
for (let i = 0; i < lines.length; i++) {
|
|
144
|
+
if (lines[i].includes(pattern)) {
|
|
145
|
+
const relPath = fullPath.slice(getProjectRoot().length + 1);
|
|
146
|
+
// Extract the full call pattern (e.g., api.orders.create.useMutation())
|
|
147
|
+
const lineContent = lines[i].trim();
|
|
148
|
+
const callMatch = lineContent.match(new RegExp(`(api\\.${escapeRegex(pattern.slice(4))}\\.[\\w.()]+)`));
|
|
149
|
+
results.push({
|
|
150
|
+
file: relPath,
|
|
151
|
+
line: i + 1,
|
|
152
|
+
pattern: callMatch ? callMatch[1] : pattern,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// Skip unreadable files
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function escapeRegex(str: string): string {
|
|
164
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Build the full tRPC procedure index.
|
|
169
|
+
* Stores results in massu_trpc_procedures and massu_trpc_call_sites tables.
|
|
170
|
+
*/
|
|
171
|
+
export function buildTrpcIndex(dataDb: Database.Database): { totalProcedures: number; withCallers: number; withoutCallers: number } {
|
|
172
|
+
// Clear existing data
|
|
173
|
+
dataDb.exec('DELETE FROM massu_trpc_call_sites');
|
|
174
|
+
dataDb.exec('DELETE FROM massu_trpc_procedures');
|
|
175
|
+
|
|
176
|
+
const routerMappings = parseRootRouter();
|
|
177
|
+
|
|
178
|
+
const insertProc = dataDb.prepare(
|
|
179
|
+
'INSERT INTO massu_trpc_procedures (router_file, router_name, procedure_name, procedure_type, has_ui_caller) VALUES (?, ?, ?, ?, ?)'
|
|
180
|
+
);
|
|
181
|
+
const insertCallSite = dataDb.prepare(
|
|
182
|
+
'INSERT INTO massu_trpc_call_sites (procedure_id, file, line, call_pattern) VALUES (?, ?, ?, ?)'
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
let totalProcedures = 0;
|
|
186
|
+
let withCallers = 0;
|
|
187
|
+
let withoutCallers = 0;
|
|
188
|
+
|
|
189
|
+
const insertAll = dataDb.transaction(() => {
|
|
190
|
+
for (const router of routerMappings) {
|
|
191
|
+
const procedures = extractProcedures(router.file);
|
|
192
|
+
|
|
193
|
+
for (const proc of procedures) {
|
|
194
|
+
const callSites = findUICallSites(router.key, proc.name);
|
|
195
|
+
const hasUICaller = callSites.length > 0 ? 1 : 0;
|
|
196
|
+
|
|
197
|
+
const result = insertProc.run(router.file, router.key, proc.name, proc.type, hasUICaller);
|
|
198
|
+
const procId = result.lastInsertRowid;
|
|
199
|
+
|
|
200
|
+
for (const site of callSites) {
|
|
201
|
+
insertCallSite.run(procId, site.file, site.line, site.pattern);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
totalProcedures++;
|
|
205
|
+
if (hasUICaller) withCallers++;
|
|
206
|
+
else withoutCallers++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
insertAll();
|
|
212
|
+
|
|
213
|
+
return { totalProcedures, withCallers, withoutCallers };
|
|
214
|
+
}
|