gramatr 0.3.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.
Files changed (50) hide show
  1. package/CLAUDE.md +18 -0
  2. package/README.md +78 -0
  3. package/bin/clean-legacy-install.ts +28 -0
  4. package/bin/get-token.py +3 -0
  5. package/bin/gmtr-login.ts +547 -0
  6. package/bin/gramatr.js +33 -0
  7. package/bin/gramatr.ts +248 -0
  8. package/bin/install.ts +756 -0
  9. package/bin/render-claude-hooks.ts +16 -0
  10. package/bin/statusline.ts +437 -0
  11. package/bin/uninstall.ts +289 -0
  12. package/bin/version-sync.ts +46 -0
  13. package/codex/README.md +28 -0
  14. package/codex/hooks/session-start.ts +73 -0
  15. package/codex/hooks/stop.ts +34 -0
  16. package/codex/hooks/user-prompt-submit.ts +76 -0
  17. package/codex/install.ts +99 -0
  18. package/codex/lib/codex-hook-utils.ts +48 -0
  19. package/codex/lib/codex-install-utils.ts +123 -0
  20. package/core/feedback.ts +55 -0
  21. package/core/formatting.ts +167 -0
  22. package/core/install.ts +114 -0
  23. package/core/installer-cli.ts +122 -0
  24. package/core/migration.ts +244 -0
  25. package/core/routing.ts +98 -0
  26. package/core/session.ts +202 -0
  27. package/core/targets.ts +292 -0
  28. package/core/types.ts +178 -0
  29. package/core/version.ts +2 -0
  30. package/gemini/README.md +95 -0
  31. package/gemini/hooks/session-start.ts +72 -0
  32. package/gemini/hooks/stop.ts +30 -0
  33. package/gemini/hooks/user-prompt-submit.ts +74 -0
  34. package/gemini/install.ts +272 -0
  35. package/gemini/lib/gemini-hook-utils.ts +63 -0
  36. package/gemini/lib/gemini-install-utils.ts +169 -0
  37. package/hooks/GMTRPromptEnricher.hook.ts +650 -0
  38. package/hooks/GMTRRatingCapture.hook.ts +198 -0
  39. package/hooks/GMTRSecurityValidator.hook.ts +399 -0
  40. package/hooks/GMTRToolTracker.hook.ts +181 -0
  41. package/hooks/StopOrchestrator.hook.ts +78 -0
  42. package/hooks/gmtr-tool-tracker-utils.ts +105 -0
  43. package/hooks/lib/gmtr-hook-utils.ts +771 -0
  44. package/hooks/lib/identity.ts +227 -0
  45. package/hooks/lib/notify.ts +46 -0
  46. package/hooks/lib/paths.ts +104 -0
  47. package/hooks/lib/transcript-parser.ts +452 -0
  48. package/hooks/session-end.hook.ts +168 -0
  49. package/hooks/session-start.hook.ts +490 -0
  50. package/package.json +54 -0
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GMTRToolTracker.hook.ts — grāmatr PostToolUse Hook
4
+ *
5
+ * Fires after any GMTR MCP tool call. Extracts execution metrics from
6
+ * tool_result and surfaces them as a user-visible status line.
7
+ *
8
+ * TRIGGER: PostToolUse (matcher: mcp__.*gramatr.*__)
9
+ * PERFORMANCE: <5ms. Outputs continue immediately, then processes.
10
+ *
11
+ * What it surfaces:
12
+ * - Tool name (short form, e.g., "search_semantic")
13
+ * - Classifier model + inference time (if intelligence layer was used)
14
+ * - Token savings ratio
15
+ * - Cache hit/miss
16
+ * - Total execution time
17
+ *
18
+ * Output format (to stderr, visible in Claude Code):
19
+ * [GMTR] search_semantic | 1.4s | qwen2.5:14b 0.32s | saved 6K tokens (80%) | cache:MISS
20
+ */
21
+
22
+ import { extractToolShortName, extractExecutionSummary, buildStatusLine } from './gmtr-tool-tracker-utils.js';
23
+ import type { ExecutionSummary } from './gmtr-tool-tracker-utils.js';
24
+
25
+ // ── Types ──
26
+
27
+ interface HookInput {
28
+ tool_name: string;
29
+ tool_input: Record<string, unknown>;
30
+ tool_response: Array<{ type: string; text?: string }>; // Claude Code sends tool_response (array), not tool_result (string)
31
+ session_id: string;
32
+ hook_event_name?: string;
33
+ tool_use_id?: string;
34
+ }
35
+
36
+ // ── Stdin Reader ──
37
+
38
+ function readStdin(timeoutMs: number): Promise<string> {
39
+ return new Promise((resolve) => {
40
+ let data = '';
41
+ const timer = setTimeout(() => resolve(data), timeoutMs);
42
+
43
+ process.stdin.setEncoding('utf-8');
44
+ process.stdin.on('data', (chunk: string) => { data += chunk; });
45
+ process.stdin.on('end', () => { clearTimeout(timer); resolve(data); });
46
+ process.stdin.on('error', () => { clearTimeout(timer); resolve(data); });
47
+ process.stdin.resume();
48
+ });
49
+ }
50
+
51
+ // ── Debug Logger ──
52
+
53
+ async function debugLog(msg: string) {
54
+ try {
55
+ const { appendFileSync } = await import('fs');
56
+ appendFileSync('/tmp/gmtr-hook-debug.log', `[${new Date().toISOString()}] ${msg}\n`);
57
+ } catch {}
58
+ }
59
+
60
+ // ── Main ──
61
+
62
+ async function main() {
63
+ // Step 1: Read stdin FIRST — data is already in pipe buffer from Claude Code.
64
+ // Writing stdout before reading stdin causes Claude Code to close the pipe,
65
+ // which means the hook never sees the tool_result data.
66
+ const raw = await readStdin(3000);
67
+
68
+ // Step 2: NOW output continue — never block Claude Code longer than necessary
69
+ console.log(JSON.stringify({ continue: true }));
70
+
71
+ await debugLog(`stdin=${raw.length}b`);
72
+
73
+ if (!raw.trim()) {
74
+ await debugLog('empty stdin, exiting');
75
+ return;
76
+ }
77
+
78
+ let input: HookInput;
79
+ try {
80
+ input = JSON.parse(raw);
81
+ } catch (e) {
82
+ await debugLog(`parse error: ${e}`);
83
+ return;
84
+ }
85
+
86
+ await debugLog(`tool=${input.tool_name} response=${input.tool_response?.length || 0} items`);
87
+
88
+ const { tool_name, tool_response } = input;
89
+ if (!tool_name || !tool_response) return;
90
+
91
+ // Extract short tool name and execution metrics
92
+ const shortName = extractToolShortName(tool_name);
93
+ const summary = extractExecutionSummary(tool_response);
94
+
95
+ // Build and emit status line to stderr (visible in Claude Code)
96
+ const status = buildStatusLine(shortName, summary);
97
+ process.stderr.write(`${status}\n`);
98
+
99
+ // Write metrics to temp file for status line script to read
100
+ const metricsFile = '/tmp/gmtr-last-op.json';
101
+ const metrics = {
102
+ tool: shortName,
103
+ model: summary?.qwen_model || null,
104
+ time_ms: summary?.execution_time_ms || null,
105
+ tokens_saved: summary?.tokens_saved || null,
106
+ cache_hit: summary?.cache_hit ?? null,
107
+ result_count: summary?.results_count || null,
108
+ timestamp: Date.now(),
109
+ };
110
+ const { writeFileSync, appendFileSync, readFileSync } = await import('fs');
111
+ try {
112
+ writeFileSync(metricsFile, JSON.stringify(metrics));
113
+ } catch {
114
+ // Non-critical — status line just won't show metrics
115
+ }
116
+
117
+ // Append to operation history (for sparkline/counts in status line)
118
+ const historyFile = '/tmp/gmtr-op-history.jsonl';
119
+ try {
120
+ const historyEntry = JSON.stringify({
121
+ tool: shortName,
122
+ model: summary?.qwen_model || null,
123
+ time_ms: summary?.execution_time_ms || 0,
124
+ tokens_saved: summary?.tokens_saved || 0,
125
+ cache_hit: summary?.cache_hit ?? false,
126
+ timestamp: Date.now(),
127
+ });
128
+ appendFileSync(historyFile, historyEntry + '\n');
129
+ } catch {
130
+ // Non-critical
131
+ }
132
+
133
+ // Extract and cache GMTR server stats from tool response (entity/obs counts etc.)
134
+ try {
135
+ // tool_response is already a parsed array: [{ type: "text", text: "..." }]
136
+ let resultData: Record<string, unknown> | null = null;
137
+ const textItem = tool_response.find((item: { type: string; text?: string }) => item.type === 'text' && item.text);
138
+ if (textItem?.text) {
139
+ try { resultData = JSON.parse(textItem.text); } catch {}
140
+ }
141
+
142
+ if (resultData) {
143
+ // Look for entity_count, observation_count etc. in search results
144
+ const statsFile = '/tmp/gmtr-stats.json';
145
+ let stats: Record<string, unknown> = {};
146
+ try { stats = JSON.parse(readFileSync(statsFile, 'utf8')); } catch {}
147
+
148
+ // Track search count
149
+ if (shortName.includes('search') || shortName === 'gmtr_execute_intent') {
150
+ stats.search_count = ((stats.search_count as number) || 0) + 1;
151
+ }
152
+
153
+ // Track entity/observation counts if returned in results
154
+ if (resultData.total_entities !== undefined) stats.entity_count = resultData.total_entities;
155
+ if (resultData.total_observations !== undefined) stats.observation_count = resultData.total_observations;
156
+ if (resultData.entity_count !== undefined) stats.entity_count = resultData.entity_count;
157
+
158
+ // Capture classifier progression data from execution_summary (Issue #34)
159
+ if (summary) {
160
+ if (summary.classifier_level !== undefined) stats.classifier_level = summary.classifier_level;
161
+ if (summary.classifier_model !== undefined) stats.classifier_model = summary.classifier_model;
162
+ if (summary.total_classifications !== undefined) stats.total_classifications = summary.total_classifications;
163
+ if (summary.total_feedback !== undefined) stats.total_feedback = summary.total_feedback;
164
+ if (summary.feedback_rate !== undefined) stats.feedback_rate = summary.feedback_rate;
165
+ if (summary.accuracy !== undefined) stats.accuracy = summary.accuracy;
166
+ }
167
+
168
+ // Track local classification count (increments on every GMTR call)
169
+ stats.local_call_count = ((stats.local_call_count as number) || 0) + 1;
170
+
171
+ stats.status = 'connected';
172
+ stats.last_seen = Date.now();
173
+
174
+ writeFileSync(statsFile, JSON.stringify(stats));
175
+ }
176
+ } catch {
177
+ // Non-critical
178
+ }
179
+ }
180
+
181
+ main().catch(() => {});
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * StopOrchestrator.hook.ts — Stop event handler (thin client)
4
+ *
5
+ * Fires when the user stops Claude. Reads the transcript and sends
6
+ * classification feedback to the gramatr server via HTTP.
7
+ *
8
+ * In the thin client, all other Stop handlers (TabState, RebuildSkill,
9
+ * AlgorithmEnrichment, DocCrossRefIntegrity) are server-side.
10
+ */
11
+
12
+ import { readFileSync } from 'fs';
13
+ import { parseTranscript } from './lib/transcript-parser';
14
+ import { getGmtrDir } from './lib/paths';
15
+
16
+ interface HookInput {
17
+ session_id: string;
18
+ transcript_path: string;
19
+ }
20
+
21
+ async function main() {
22
+ let input = '';
23
+ try {
24
+ for await (const chunk of process.stdin) {
25
+ input += chunk;
26
+ }
27
+ } catch { /* timeout ok */ }
28
+
29
+ if (!input.trim()) { process.exit(0); }
30
+
31
+ const hookInput: HookInput = JSON.parse(input);
32
+ if (!hookInput.transcript_path) { process.exit(0); }
33
+
34
+ // Parse transcript for classification feedback
35
+ const parsed = parseTranscript(hookInput.transcript_path);
36
+
37
+ // Read server URL and token for direct HTTP feedback
38
+ const gmtrDir = getGmtrDir();
39
+ let serverUrl = process.env.GMTR_URL || 'https://api.gramatr.com/mcp';
40
+ let token = '';
41
+
42
+ try {
43
+ const gmtrJson = JSON.parse(readFileSync(`${process.env.HOME}/.gmtr.json`, 'utf8'));
44
+ token = gmtrJson.token || '';
45
+ } catch { /* no token */ }
46
+
47
+ if (!token || !parsed) { process.exit(0); }
48
+
49
+ // Send classification feedback via MCP
50
+ try {
51
+ const baseUrl = serverUrl.replace(/\/mcp\/?$/, '');
52
+ await fetch(`${baseUrl}/mcp`, {
53
+ method: 'POST',
54
+ headers: {
55
+ 'Content-Type': 'application/json',
56
+ 'Accept': 'application/json, text/event-stream',
57
+ 'Authorization': `Bearer ${token}`,
58
+ },
59
+ body: JSON.stringify({
60
+ jsonrpc: '2.0',
61
+ id: 1,
62
+ method: 'tools/call',
63
+ params: {
64
+ name: 'gmtr_classification_feedback',
65
+ arguments: {
66
+ original_prompt: parsed.userMessages?.[0] || '',
67
+ session_id: hookInput.session_id,
68
+ },
69
+ },
70
+ }),
71
+ signal: AbortSignal.timeout(5000),
72
+ });
73
+ } catch { /* best effort */ }
74
+
75
+ process.exit(0);
76
+ }
77
+
78
+ main().catch(() => process.exit(0));
@@ -0,0 +1,105 @@
1
+ /**
2
+ * gmtr-tool-tracker-utils.ts — Pure utility functions for the GMTR Tool Tracker hook.
3
+ *
4
+ * Extracted from GMTRToolTracker.hook.ts for testability. These are pure functions
5
+ * with no Bun dependencies, no I/O, no side effects.
6
+ */
7
+
8
+ // ── Types ──
9
+
10
+ export interface ExecutionSummary {
11
+ execution_time_ms?: number;
12
+ qwen_calls?: number;
13
+ qwen_time_ms?: number;
14
+ qwen_model?: string;
15
+ tokens_saved?: number;
16
+ savings_ratio?: number;
17
+ cache_hit?: boolean;
18
+ action?: string;
19
+ results_count?: number;
20
+ visual_query_detected?: boolean;
21
+ visual_results_count?: number;
22
+ classifier_level?: number;
23
+ classifier_model?: string;
24
+ total_classifications?: number;
25
+ total_feedback?: number;
26
+ feedback_rate?: number;
27
+ accuracy?: number;
28
+ }
29
+
30
+ // ── Helpers ──
31
+
32
+ export function extractToolShortName(fullName: string): string {
33
+ // mcp__gramatr__search_semantic -> search_semantic
34
+ const parts = fullName.split('__');
35
+ return parts[parts.length - 1] || fullName;
36
+ }
37
+
38
+ export function formatTokens(n: number): string {
39
+ if (n >= 1000) return `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}K`;
40
+ return n.toString();
41
+ }
42
+
43
+ export function formatMs(ms: number): string {
44
+ if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
45
+ return `${Math.round(ms)}ms`;
46
+ }
47
+
48
+ export function extractExecutionSummary(toolResponse: Array<{ type: string; text?: string }>): ExecutionSummary | null {
49
+ try {
50
+ for (const item of toolResponse) {
51
+ if (item.type === 'text' && item.text) {
52
+ try {
53
+ const inner = JSON.parse(item.text);
54
+ if (inner.execution_summary) return inner.execution_summary;
55
+ if (inner.execution_time_ms !== undefined) return inner;
56
+ } catch {
57
+ // text wasn't JSON, skip
58
+ }
59
+ }
60
+ }
61
+ return null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ export function buildStatusLine(toolShortName: string, summary: ExecutionSummary | null): string {
68
+ const parts: string[] = [`[GMTR] ${toolShortName}`];
69
+
70
+ if (summary) {
71
+ if (summary.execution_time_ms) {
72
+ parts.push(formatMs(summary.execution_time_ms));
73
+ }
74
+
75
+ if (summary.qwen_calls && summary.qwen_calls > 0) {
76
+ const model = summary.qwen_model || 'qwen2.5:14b';
77
+ const time = summary.qwen_time_ms ? ` ${formatMs(summary.qwen_time_ms)}` : '';
78
+ parts.push(`${model}${time}`);
79
+ }
80
+
81
+ if (summary.tokens_saved && summary.tokens_saved > 0) {
82
+ const pct = summary.savings_ratio
83
+ ? ` (${Math.round(summary.savings_ratio * 100)}%)`
84
+ : '';
85
+ parts.push(`saved ${formatTokens(summary.tokens_saved)} tokens${pct}`);
86
+ }
87
+
88
+ if (summary.cache_hit !== undefined) {
89
+ parts.push(`cache:${summary.cache_hit ? 'HIT' : 'MISS'}`);
90
+ }
91
+
92
+ if (summary.visual_query_detected) {
93
+ const count = summary.visual_results_count || 0;
94
+ parts.push(`visual:${count}`);
95
+ }
96
+
97
+ if (summary.results_count !== undefined) {
98
+ parts.push(`${summary.results_count} results`);
99
+ }
100
+ } else {
101
+ parts.push('OK');
102
+ }
103
+
104
+ return parts.join(' | ');
105
+ }