@gramatr/client 0.5.1
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 +17 -0
- package/CLAUDE.md +18 -0
- package/README.md +108 -0
- package/bin/add-api-key.ts +264 -0
- package/bin/clean-legacy-install.ts +28 -0
- package/bin/clear-creds.ts +141 -0
- package/bin/get-token.py +3 -0
- package/bin/gmtr-login.ts +599 -0
- package/bin/gramatr.js +36 -0
- package/bin/gramatr.ts +374 -0
- package/bin/install.ts +716 -0
- package/bin/lib/config.ts +57 -0
- package/bin/lib/git.ts +111 -0
- package/bin/lib/stdin.ts +53 -0
- package/bin/logout.ts +76 -0
- package/bin/render-claude-hooks.ts +16 -0
- package/bin/statusline.ts +81 -0
- package/bin/uninstall.ts +289 -0
- package/chatgpt/README.md +95 -0
- package/chatgpt/install.ts +140 -0
- package/chatgpt/lib/chatgpt-install-utils.ts +89 -0
- package/codex/README.md +28 -0
- package/codex/hooks/session-start.ts +73 -0
- package/codex/hooks/stop.ts +34 -0
- package/codex/hooks/user-prompt-submit.ts +79 -0
- package/codex/install.ts +116 -0
- package/codex/lib/codex-hook-utils.ts +48 -0
- package/codex/lib/codex-install-utils.ts +123 -0
- package/core/auth.ts +170 -0
- package/core/feedback.ts +55 -0
- package/core/formatting.ts +179 -0
- package/core/install.ts +107 -0
- package/core/installer-cli.ts +122 -0
- package/core/migration.ts +479 -0
- package/core/routing.ts +108 -0
- package/core/session.ts +202 -0
- package/core/targets.ts +292 -0
- package/core/types.ts +179 -0
- package/core/version-check.ts +219 -0
- package/core/version.ts +47 -0
- package/desktop/README.md +72 -0
- package/desktop/build-mcpb.ts +166 -0
- package/desktop/install.ts +136 -0
- package/desktop/lib/desktop-install-utils.ts +70 -0
- package/gemini/README.md +95 -0
- package/gemini/hooks/session-start.ts +72 -0
- package/gemini/hooks/stop.ts +30 -0
- package/gemini/hooks/user-prompt-submit.ts +77 -0
- package/gemini/install.ts +281 -0
- package/gemini/lib/gemini-hook-utils.ts +63 -0
- package/gemini/lib/gemini-install-utils.ts +169 -0
- package/hooks/GMTRPromptEnricher.hook.ts +651 -0
- package/hooks/GMTRRatingCapture.hook.ts +198 -0
- package/hooks/GMTRSecurityValidator.hook.ts +399 -0
- package/hooks/GMTRToolTracker.hook.ts +181 -0
- package/hooks/StopOrchestrator.hook.ts +78 -0
- package/hooks/gmtr-tool-tracker-utils.ts +105 -0
- package/hooks/lib/gmtr-hook-utils.ts +770 -0
- package/hooks/lib/identity.ts +227 -0
- package/hooks/lib/notify.ts +46 -0
- package/hooks/lib/paths.ts +104 -0
- package/hooks/lib/transcript-parser.ts +452 -0
- package/hooks/session-end.hook.ts +168 -0
- package/hooks/session-start.hook.ts +501 -0
- package/package.json +63 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* transcript-parser.ts - Claude transcript parsing utilities
|
|
4
|
+
*
|
|
5
|
+
* Shared library for extracting content from Claude Code transcript files.
|
|
6
|
+
* Used by Stop hooks for voice, tab state, and response capture.
|
|
7
|
+
*
|
|
8
|
+
* CLI Usage:
|
|
9
|
+
* bun transcript-parser.ts <transcript_path>
|
|
10
|
+
* bun transcript-parser.ts <transcript_path> --voice
|
|
11
|
+
* bun transcript-parser.ts <transcript_path> --plain
|
|
12
|
+
* bun transcript-parser.ts <transcript_path> --structured
|
|
13
|
+
* bun transcript-parser.ts <transcript_path> --state
|
|
14
|
+
*
|
|
15
|
+
* Module Usage:
|
|
16
|
+
* import { parseTranscript, getLastAssistantMessage } from './transcript-parser'
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from 'fs';
|
|
20
|
+
import { getIdentity } from './identity';
|
|
21
|
+
|
|
22
|
+
const DA_IDENTITY = getIdentity();
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Types
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
export interface StructuredResponse {
|
|
29
|
+
date?: string;
|
|
30
|
+
summary?: string;
|
|
31
|
+
analysis?: string;
|
|
32
|
+
actions?: string;
|
|
33
|
+
results?: string;
|
|
34
|
+
status?: string;
|
|
35
|
+
next?: string;
|
|
36
|
+
completed?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ResponseState = 'awaitingInput' | 'completed' | 'error';
|
|
40
|
+
|
|
41
|
+
export interface ParsedTranscript {
|
|
42
|
+
/** Raw transcript content */
|
|
43
|
+
raw: string;
|
|
44
|
+
/** Last assistant message text */
|
|
45
|
+
lastMessage: string;
|
|
46
|
+
/** Full text from current response turn (all assistant blocks combined) */
|
|
47
|
+
currentResponseText: string;
|
|
48
|
+
/** Voice completion text (for TTS) */
|
|
49
|
+
voiceCompletion: string;
|
|
50
|
+
/** Plain completion text (for tab title) */
|
|
51
|
+
plainCompletion: string;
|
|
52
|
+
/** Structured sections extracted from response */
|
|
53
|
+
structured: StructuredResponse;
|
|
54
|
+
/** Response state for tab coloring */
|
|
55
|
+
responseState: ResponseState;
|
|
56
|
+
/** Last real user prompt text */
|
|
57
|
+
lastUserPrompt: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ============================================================================
|
|
61
|
+
// Core Parsing Functions
|
|
62
|
+
// ============================================================================
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Safely convert Claude content (string or array of blocks) to plain text.
|
|
66
|
+
*/
|
|
67
|
+
export function contentToText(content: unknown): string {
|
|
68
|
+
if (typeof content === 'string') return content;
|
|
69
|
+
if (Array.isArray(content)) {
|
|
70
|
+
return content
|
|
71
|
+
.map(c => {
|
|
72
|
+
if (typeof c === 'string') return c;
|
|
73
|
+
if (c?.text) return c.text;
|
|
74
|
+
if (c?.content) return contentToText(c.content);
|
|
75
|
+
return '';
|
|
76
|
+
})
|
|
77
|
+
.join(' ')
|
|
78
|
+
.trim();
|
|
79
|
+
}
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parse last assistant message from transcript content.
|
|
85
|
+
* Takes raw content string to avoid re-reading file.
|
|
86
|
+
*/
|
|
87
|
+
export function parseLastAssistantMessage(transcriptContent: string): string {
|
|
88
|
+
const lines = transcriptContent.trim().split('\n');
|
|
89
|
+
let lastAssistantMessage = '';
|
|
90
|
+
|
|
91
|
+
for (const line of lines) {
|
|
92
|
+
if (line.trim()) {
|
|
93
|
+
try {
|
|
94
|
+
const entry = JSON.parse(line) as any;
|
|
95
|
+
if (entry.type === 'assistant' && entry.message?.content) {
|
|
96
|
+
const text = contentToText(entry.message.content);
|
|
97
|
+
if (text) {
|
|
98
|
+
lastAssistantMessage = text;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// Skip invalid JSON lines
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return lastAssistantMessage;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Collect assistant text from the CURRENT response turn only.
|
|
112
|
+
* A "turn" is everything after the last human message in the transcript.
|
|
113
|
+
* This prevents voice/completion extraction from picking up stale lines
|
|
114
|
+
* from previous turns when the Stop hook fires.
|
|
115
|
+
*
|
|
116
|
+
* Within a single turn, there may be multiple assistant entries
|
|
117
|
+
* (text → tool_use → tool_result → more text). All are collected.
|
|
118
|
+
*/
|
|
119
|
+
export function collectCurrentResponseText(transcriptContent: string): string {
|
|
120
|
+
const lines = transcriptContent.trim().split('\n');
|
|
121
|
+
|
|
122
|
+
// Find the index of the last REAL user prompt.
|
|
123
|
+
// Claude Code transcript uses type='user' for both actual user prompts AND
|
|
124
|
+
// tool_result entries (which are mid-response). Real user prompts have at
|
|
125
|
+
// least one {type:'text'} content block. Tool results only have {type:'tool_result'}.
|
|
126
|
+
let lastHumanIndex = -1;
|
|
127
|
+
for (let i = 0; i < lines.length; i++) {
|
|
128
|
+
if (lines[i].trim()) {
|
|
129
|
+
try {
|
|
130
|
+
const entry = JSON.parse(lines[i]) as any;
|
|
131
|
+
if (entry.type === 'human' || entry.type === 'user') {
|
|
132
|
+
const content = entry.message?.content;
|
|
133
|
+
// String content = real user message
|
|
134
|
+
if (typeof content === 'string') {
|
|
135
|
+
lastHumanIndex = i;
|
|
136
|
+
} else if (Array.isArray(content)) {
|
|
137
|
+
// Check for text blocks — indicates a real user prompt
|
|
138
|
+
const hasText = content.some((b: any) => b?.type === 'text' && b?.text?.trim());
|
|
139
|
+
if (hasText) {
|
|
140
|
+
lastHumanIndex = i;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// Skip invalid JSON lines
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Collect only assistant text AFTER the last human message
|
|
151
|
+
const textParts: string[] = [];
|
|
152
|
+
for (let i = lastHumanIndex + 1; i < lines.length; i++) {
|
|
153
|
+
if (lines[i].trim()) {
|
|
154
|
+
try {
|
|
155
|
+
const entry = JSON.parse(lines[i]) as any;
|
|
156
|
+
if (entry.type === 'assistant' && entry.message?.content) {
|
|
157
|
+
const text = contentToText(entry.message.content);
|
|
158
|
+
if (text) {
|
|
159
|
+
textParts.push(text);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
// Skip invalid JSON lines
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return textParts.join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function parseLastUserPrompt(transcriptContent: string): string {
|
|
172
|
+
const lines = transcriptContent.trim().split('\n');
|
|
173
|
+
let lastUserPrompt = '';
|
|
174
|
+
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
if (!line.trim()) continue;
|
|
177
|
+
try {
|
|
178
|
+
const entry = JSON.parse(line) as any;
|
|
179
|
+
if (entry.type === 'human' || entry.type === 'user') {
|
|
180
|
+
const content = entry.message?.content;
|
|
181
|
+
if (typeof content === 'string' && content.trim()) {
|
|
182
|
+
lastUserPrompt = content.trim();
|
|
183
|
+
} else if (Array.isArray(content)) {
|
|
184
|
+
const text = content
|
|
185
|
+
.filter((block: any) => block?.type === 'text' && typeof block?.text === 'string')
|
|
186
|
+
.map((block: any) => block.text.trim())
|
|
187
|
+
.filter(Boolean)
|
|
188
|
+
.join('\n')
|
|
189
|
+
.trim();
|
|
190
|
+
if (text) lastUserPrompt = text;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// Ignore malformed rows
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return lastUserPrompt;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get last assistant message from transcript file.
|
|
203
|
+
* Convenience function that reads file and parses.
|
|
204
|
+
*/
|
|
205
|
+
export function getLastAssistantMessage(transcriptPath: string): string {
|
|
206
|
+
try {
|
|
207
|
+
const content = readFileSync(transcriptPath, 'utf-8');
|
|
208
|
+
return parseLastAssistantMessage(content);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error('[TranscriptParser] Error reading transcript:', error);
|
|
211
|
+
return '';
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ============================================================================
|
|
216
|
+
// Extraction Functions
|
|
217
|
+
// ============================================================================
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Extract voice completion line for TTS.
|
|
221
|
+
* Uses LAST match to avoid capturing mentions in analysis text.
|
|
222
|
+
*/
|
|
223
|
+
export function extractVoiceCompletion(text: string): string {
|
|
224
|
+
// Remove system-reminder tags
|
|
225
|
+
text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '');
|
|
226
|
+
|
|
227
|
+
// Use global flag and find LAST match (voice line is at end of response)
|
|
228
|
+
const completedPatterns = [
|
|
229
|
+
new RegExp(`🗣️\\s*\\*{0,2}${DA_IDENTITY.name}:?\\*{0,2}\\s*(.+?)(?:\\n|$)`, 'gi'),
|
|
230
|
+
/🎯\s*\*{0,2}COMPLETED:?\*{0,2}\s*(.+?)(?:\n|$)/gi,
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
for (const pattern of completedPatterns) {
|
|
234
|
+
const matches = [...text.matchAll(pattern)];
|
|
235
|
+
if (matches.length > 0) {
|
|
236
|
+
// Use LAST match - the actual voice line at end of response
|
|
237
|
+
const lastMatch = matches[matches.length - 1];
|
|
238
|
+
if (lastMatch && lastMatch[1]) {
|
|
239
|
+
let completed = lastMatch[1].trim();
|
|
240
|
+
// Clean up agent tags
|
|
241
|
+
completed = completed.replace(/^\[AGENT:\w+\]\s*/i, '');
|
|
242
|
+
// Voice server handles sanitization
|
|
243
|
+
return completed.trim();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Don't say anything if no voice line found
|
|
249
|
+
return '';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Extract plain completion text for display/tab titles.
|
|
254
|
+
* Uses LAST match to avoid capturing mentions in analysis text.
|
|
255
|
+
*/
|
|
256
|
+
export function extractCompletionPlain(text: string): string {
|
|
257
|
+
text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '');
|
|
258
|
+
|
|
259
|
+
// Use global flag and find LAST match (voice line is at end of response)
|
|
260
|
+
const completedPatterns = [
|
|
261
|
+
new RegExp(`🗣️\\s*\\*{0,2}${DA_IDENTITY.name}:?\\*{0,2}\\s*(.+?)(?:\\n|$)`, 'gi'),
|
|
262
|
+
/🎯\s*\*{0,2}COMPLETED:?\*{0,2}\s*(.+?)(?:\n|$)/gi,
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
for (const pattern of completedPatterns) {
|
|
266
|
+
const matches = [...text.matchAll(pattern)];
|
|
267
|
+
if (matches.length > 0) {
|
|
268
|
+
// Use LAST match - the actual voice line at end of response
|
|
269
|
+
const lastMatch = matches[matches.length - 1];
|
|
270
|
+
if (lastMatch && lastMatch[1]) {
|
|
271
|
+
let completed = lastMatch[1].trim();
|
|
272
|
+
completed = completed.replace(/^\[AGENT:\w+\]\s*/i, '');
|
|
273
|
+
completed = completed.replace(/\[.*?\]/g, '');
|
|
274
|
+
completed = completed.replace(/\*\*/g, '');
|
|
275
|
+
completed = completed.replace(/\*/g, '');
|
|
276
|
+
completed = completed.replace(/[\p{Emoji}\p{Emoji_Component}]/gu, '');
|
|
277
|
+
completed = completed.replace(/\s+/g, ' ').trim();
|
|
278
|
+
return completed;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Fallback: try to extract something meaningful from the response
|
|
284
|
+
const summaryMatch = text.match(/📋\s*\*{0,2}SUMMARY:?\*{0,2}\s*(.+?)(?:\n|$)/i);
|
|
285
|
+
if (summaryMatch && summaryMatch[1]) {
|
|
286
|
+
let summary = summaryMatch[1].trim().slice(0, 30);
|
|
287
|
+
return summary.length > 27 ? summary.slice(0, 27) + '…' : summary;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// No voice line found — return empty, let downstream handle fallback
|
|
291
|
+
return '';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Extract structured sections from response.
|
|
296
|
+
*/
|
|
297
|
+
export function extractStructuredSections(text: string): StructuredResponse {
|
|
298
|
+
const result: StructuredResponse = {};
|
|
299
|
+
|
|
300
|
+
text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '');
|
|
301
|
+
|
|
302
|
+
const patterns: Record<keyof StructuredResponse, RegExp> = {
|
|
303
|
+
date: /📅\s*(.+?)(?:\n|$)/i,
|
|
304
|
+
summary: /📋\s*SUMMARY:\s*(.+?)(?:\n|$)/i,
|
|
305
|
+
analysis: /🔍\s*ANALYSIS:\s*(.+?)(?:\n|$)/i,
|
|
306
|
+
actions: /⚡\s*ACTIONS:\s*(.+?)(?:\n|$)/i,
|
|
307
|
+
results: /✅\s*RESULTS:\s*(.+?)(?:\n|$)/i,
|
|
308
|
+
status: /📊\s*STATUS:\s*(.+?)(?:\n|$)/i,
|
|
309
|
+
next: /➡️\s*NEXT:\s*(.+?)(?:\n|$)/i,
|
|
310
|
+
completed: new RegExp(`(?:🗣️\\s*${DA_IDENTITY.name}:|🎯\\s*COMPLETED:)\\s*(.+?)(?:\\n|$)`, 'i'),
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
for (const [key, pattern] of Object.entries(patterns)) {
|
|
314
|
+
const match = text.match(pattern);
|
|
315
|
+
if (match && match[1]) {
|
|
316
|
+
result[key as keyof StructuredResponse] = match[1].trim();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ============================================================================
|
|
324
|
+
// State Detection
|
|
325
|
+
// ============================================================================
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Detect response state for tab coloring.
|
|
329
|
+
* Takes parsed content to avoid re-reading file.
|
|
330
|
+
*/
|
|
331
|
+
export function detectResponseState(lastMessage: string, transcriptContent: string): ResponseState {
|
|
332
|
+
try {
|
|
333
|
+
// Check if the LAST assistant message used AskUserQuestion
|
|
334
|
+
const lines = transcriptContent.trim().split('\n');
|
|
335
|
+
let lastAssistantEntry: any = null;
|
|
336
|
+
|
|
337
|
+
for (const line of lines) {
|
|
338
|
+
try {
|
|
339
|
+
const entry = JSON.parse(line);
|
|
340
|
+
if (entry.type === 'assistant' && entry.message?.content) {
|
|
341
|
+
lastAssistantEntry = entry;
|
|
342
|
+
}
|
|
343
|
+
} catch {}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (lastAssistantEntry?.message?.content) {
|
|
347
|
+
const content = Array.isArray(lastAssistantEntry.message.content)
|
|
348
|
+
? lastAssistantEntry.message.content
|
|
349
|
+
: [];
|
|
350
|
+
for (const block of content) {
|
|
351
|
+
if (block.type === 'tool_use' && block.name === 'AskUserQuestion') {
|
|
352
|
+
return 'awaitingInput';
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
} catch (err) {
|
|
357
|
+
console.error('[TranscriptParser] Error detecting response state:', err);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Check for error indicators
|
|
361
|
+
if (/📊\s*STATUS:.*(?:error|failed|broken|problem|issue)/i.test(lastMessage)) {
|
|
362
|
+
return 'error';
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const hasErrorKeyword = /\b(?:error|failed|exception|crash|broken)\b/i.test(lastMessage);
|
|
366
|
+
const hasErrorEmoji = /❌|🚨|⚠️/.test(lastMessage);
|
|
367
|
+
if (hasErrorKeyword && hasErrorEmoji) {
|
|
368
|
+
return 'error';
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return 'completed';
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ============================================================================
|
|
375
|
+
// Unified Parser
|
|
376
|
+
// ============================================================================
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Parse transcript and extract all relevant data in one pass.
|
|
380
|
+
* This is the main function for the orchestrator pattern.
|
|
381
|
+
*/
|
|
382
|
+
export function parseTranscript(transcriptPath: string): ParsedTranscript {
|
|
383
|
+
try {
|
|
384
|
+
const raw = readFileSync(transcriptPath, 'utf-8');
|
|
385
|
+
const lastMessage = parseLastAssistantMessage(raw);
|
|
386
|
+
// Collect assistant text from CURRENT response turn only.
|
|
387
|
+
// This prevents stale voice lines from previous turns being read
|
|
388
|
+
// when the Stop hook fires. Within the current turn, multiple
|
|
389
|
+
// assistant entries exist (text → tool_use → tool_result → more text).
|
|
390
|
+
const currentResponseText = collectCurrentResponseText(raw);
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
raw,
|
|
394
|
+
lastMessage,
|
|
395
|
+
currentResponseText,
|
|
396
|
+
voiceCompletion: extractVoiceCompletion(currentResponseText),
|
|
397
|
+
plainCompletion: extractCompletionPlain(currentResponseText),
|
|
398
|
+
structured: extractStructuredSections(currentResponseText),
|
|
399
|
+
responseState: detectResponseState(lastMessage, raw),
|
|
400
|
+
lastUserPrompt: parseLastUserPrompt(raw),
|
|
401
|
+
};
|
|
402
|
+
} catch (error) {
|
|
403
|
+
console.error('[TranscriptParser] Error parsing transcript:', error);
|
|
404
|
+
return {
|
|
405
|
+
raw: '',
|
|
406
|
+
lastMessage: '',
|
|
407
|
+
currentResponseText: '',
|
|
408
|
+
voiceCompletion: '',
|
|
409
|
+
plainCompletion: '',
|
|
410
|
+
structured: {},
|
|
411
|
+
responseState: 'completed',
|
|
412
|
+
lastUserPrompt: '',
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ============================================================================
|
|
418
|
+
// CLI
|
|
419
|
+
// ============================================================================
|
|
420
|
+
|
|
421
|
+
if (import.meta.main) {
|
|
422
|
+
const args = process.argv.slice(2);
|
|
423
|
+
const transcriptPath = args.find(a => !a.startsWith('-'));
|
|
424
|
+
|
|
425
|
+
if (!transcriptPath) {
|
|
426
|
+
console.log(`Usage: bun transcript-parser.ts <transcript_path> [options]
|
|
427
|
+
|
|
428
|
+
Options:
|
|
429
|
+
--voice Output voice completion (for TTS)
|
|
430
|
+
--plain Output plain completion (for tab titles)
|
|
431
|
+
--structured Output structured sections as JSON
|
|
432
|
+
--state Output response state
|
|
433
|
+
--all Output full parsed transcript as JSON (default)
|
|
434
|
+
`);
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const parsed = parseTranscript(transcriptPath);
|
|
439
|
+
|
|
440
|
+
if (args.includes('--voice')) {
|
|
441
|
+
console.log(parsed.voiceCompletion);
|
|
442
|
+
} else if (args.includes('--plain')) {
|
|
443
|
+
console.log(parsed.plainCompletion);
|
|
444
|
+
} else if (args.includes('--structured')) {
|
|
445
|
+
console.log(JSON.stringify(parsed.structured, null, 2));
|
|
446
|
+
} else if (args.includes('--state')) {
|
|
447
|
+
console.log(parsed.responseState);
|
|
448
|
+
} else {
|
|
449
|
+
// Default: output everything
|
|
450
|
+
console.log(JSON.stringify(parsed, null, 2));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* session-end.hook.ts — gramatr SessionEnd Hook
|
|
4
|
+
*
|
|
5
|
+
* Replaces session-end.sh (176 lines). Tracks session completion,
|
|
6
|
+
* saves commit history, and registers handoff with gramatr server.
|
|
7
|
+
*
|
|
8
|
+
* TRIGGER: SessionEnd
|
|
9
|
+
* OUTPUT: stderr (user display)
|
|
10
|
+
*
|
|
11
|
+
* ZERO external CLI dependencies — no jq, sed, curl, awk.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { writeFileSync } from 'fs';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
import {
|
|
17
|
+
readHookInput,
|
|
18
|
+
getGitContext,
|
|
19
|
+
deriveProjectId,
|
|
20
|
+
readGmtrConfig,
|
|
21
|
+
writeGmtrConfig,
|
|
22
|
+
resolveAuthToken,
|
|
23
|
+
callMcpToolRaw,
|
|
24
|
+
getCommitCountSince,
|
|
25
|
+
getCommitLogSince,
|
|
26
|
+
getCommitsSince,
|
|
27
|
+
getFilesChanged,
|
|
28
|
+
appendLine,
|
|
29
|
+
log,
|
|
30
|
+
now,
|
|
31
|
+
} from './lib/gmtr-hook-utils.ts';
|
|
32
|
+
|
|
33
|
+
async function main(): Promise<void> {
|
|
34
|
+
try {
|
|
35
|
+
log('\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501');
|
|
36
|
+
log('Claude Code Session Ending...');
|
|
37
|
+
log('\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501');
|
|
38
|
+
|
|
39
|
+
const input = await readHookInput();
|
|
40
|
+
const sessionId = input.session_id || 'unknown';
|
|
41
|
+
const reason = input.reason || 'other';
|
|
42
|
+
const timestamp = now();
|
|
43
|
+
const home = process.env.HOME || '';
|
|
44
|
+
|
|
45
|
+
const git = getGitContext();
|
|
46
|
+
|
|
47
|
+
if (git) {
|
|
48
|
+
const projectId = deriveProjectId(git.remote, git.projectName);
|
|
49
|
+
|
|
50
|
+
log(`Project: ${git.projectName} (${projectId})`);
|
|
51
|
+
log(`Session: ${sessionId}`);
|
|
52
|
+
log(`Reason: ${reason}`);
|
|
53
|
+
|
|
54
|
+
// Get commit stats
|
|
55
|
+
const config = readGmtrConfig(git.root);
|
|
56
|
+
const sessionStart =
|
|
57
|
+
config?.current_session?.last_updated || config?.metadata?.updated || '';
|
|
58
|
+
|
|
59
|
+
let commitsCount = 0;
|
|
60
|
+
if (sessionStart) {
|
|
61
|
+
commitsCount = getCommitCountSince(sessionStart);
|
|
62
|
+
|
|
63
|
+
if (commitsCount > 0) {
|
|
64
|
+
log(` Commits made this session: ${commitsCount}`);
|
|
65
|
+
|
|
66
|
+
// Save commit summary for next session
|
|
67
|
+
const commitLog = getCommitLogSince(sessionStart);
|
|
68
|
+
const commitsFile = join(home, '.claude', 'last-session-commits.txt');
|
|
69
|
+
writeFileSync(commitsFile, commitLog.join('\n') + '\n');
|
|
70
|
+
log(' Commit history saved for next session');
|
|
71
|
+
} else {
|
|
72
|
+
log(' No commits this session');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Write session end marker
|
|
77
|
+
const historyFile = join(home, '.claude', 'session-history.log');
|
|
78
|
+
appendLine(historyFile, `Session ended at ${timestamp} (reason: ${reason}, session: ${sessionId})`);
|
|
79
|
+
|
|
80
|
+
// Update settings.json
|
|
81
|
+
if (config) {
|
|
82
|
+
config.metadata = config.metadata || {};
|
|
83
|
+
config.metadata.updated = timestamp;
|
|
84
|
+
config.metadata.last_session_end_reason = reason;
|
|
85
|
+
config.project_id = projectId;
|
|
86
|
+
writeGmtrConfig(git.root, config);
|
|
87
|
+
log(' Updated settings.json');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Automatic Handoff to gramatr ──
|
|
91
|
+
const token = resolveAuthToken();
|
|
92
|
+
|
|
93
|
+
if (token) {
|
|
94
|
+
// Build session summary from git data
|
|
95
|
+
let branch = 'unknown';
|
|
96
|
+
try {
|
|
97
|
+
const { execSync } = await import('child_process');
|
|
98
|
+
branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
99
|
+
encoding: 'utf8',
|
|
100
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
101
|
+
}).trim();
|
|
102
|
+
} catch {
|
|
103
|
+
// ignore
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Build structured handoff summary (5 required sections)
|
|
107
|
+
let commitList: string[] = [];
|
|
108
|
+
let modified: string[] = [];
|
|
109
|
+
if (commitsCount > 0 && sessionStart) {
|
|
110
|
+
commitList = getCommitsSince(sessionStart, 10);
|
|
111
|
+
}
|
|
112
|
+
if (commitsCount > 0) {
|
|
113
|
+
modified = getFilesChanged(`HEAD~${Math.min(commitsCount, 20)}`, 'HEAD', 15);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Section 1: WHERE WE ARE
|
|
117
|
+
let summary = `Session ended (${reason}). Project: ${git.projectName} (${projectId}). Branch: ${branch}.`;
|
|
118
|
+
if (commitsCount > 0) {
|
|
119
|
+
summary += ` Commits: ${commitsCount}. ${commitList.join('; ')}`;
|
|
120
|
+
}
|
|
121
|
+
if (modified.length > 0) {
|
|
122
|
+
summary += ` Files: ${modified.join(', ')}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Get session state from config
|
|
126
|
+
const sessionEntityId = config?.current_session?.gmtr_entity_id || '';
|
|
127
|
+
const interactionId = config?.current_session?.interaction_id || '';
|
|
128
|
+
|
|
129
|
+
log('');
|
|
130
|
+
log('Saving session state to gramatr...');
|
|
131
|
+
|
|
132
|
+
// Session lifecycle only — gmtr_session_end records git summary on the session entity.
|
|
133
|
+
// Handoffs are saved by the AGENT in the LEARN phase via gmtr_save_handoff (HARD GATE).
|
|
134
|
+
// The hook does NOT save handoffs — it lacks conversation context.
|
|
135
|
+
try {
|
|
136
|
+
const rawResult = await callMcpToolRaw('gmtr_session_end', {
|
|
137
|
+
entity_id: sessionEntityId || sessionId,
|
|
138
|
+
session_id: sessionId,
|
|
139
|
+
interaction_id: interactionId,
|
|
140
|
+
project_id: projectId,
|
|
141
|
+
summary: summary,
|
|
142
|
+
tool_call_count: 0,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (rawResult && rawResult.includes('"status":"ended"')) {
|
|
146
|
+
log(' Session end recorded');
|
|
147
|
+
} else if (rawResult && rawResult.includes('"isError":true')) {
|
|
148
|
+
log(' Session end had errors (session may not have gmtr entity)');
|
|
149
|
+
} else {
|
|
150
|
+
log(' Session end uncertain (server may be unavailable)');
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
log(' Session end uncertain (server may be unavailable)');
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
log(' No auth token \u2014 skipping gramatr handoff');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
log('\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501');
|
|
161
|
+
log('Session cleanup complete');
|
|
162
|
+
log('\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501');
|
|
163
|
+
} catch (err) {
|
|
164
|
+
log(`[gramatr] session-end error: ${String(err)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
main();
|