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,16 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { buildClaudeHooksFile } from '../core/install.ts';
4
+
5
+ function main(): void {
6
+ const clientDir = process.argv[2];
7
+ if (!clientDir) {
8
+ throw new Error('Usage: render-claude-hooks.ts <client-dir>');
9
+ }
10
+
11
+ const includeOptionalUx = process.env.GMTR_ENABLE_OPTIONAL_CLAUDE_UX === '1';
12
+ const hooks = buildClaudeHooksFile(clientDir, { includeOptionalUx });
13
+ process.stdout.write(`${JSON.stringify(hooks.hooks, null, 2)}\n`);
14
+ }
15
+
16
+ main();
@@ -0,0 +1,437 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gramatr Status Line — TypeScript rewrite of statusline.sh (#158)
4
+ *
5
+ * Single source of truth: gramatr API for server metrics, local for git/env.
6
+ * Replaces 1,383-line bash script with type-safe, testable TypeScript.
7
+ *
8
+ * Data sources:
9
+ * API → entity count, token savings, classifier stats, learning signals, skills, version
10
+ * Local → git status, context window, Anthropic usage, session label, location/weather (cached)
11
+ * Stdin → Claude Code JSON (context_window, session_id, model, workspace)
12
+ */
13
+
14
+ import { readFileSync, existsSync, writeFileSync, statSync } from 'fs';
15
+ import { execSync } from 'child_process';
16
+ import { join } from 'path';
17
+
18
+ // ── Types ──────────────────────────────────────────────────────────────────
19
+
20
+ interface CCInput {
21
+ workspace?: { current_dir?: string };
22
+ cwd?: string;
23
+ session_id?: string;
24
+ model?: { display_name?: string };
25
+ version?: string;
26
+ cost?: { total_duration_ms?: number };
27
+ context_window?: {
28
+ context_window_size?: number;
29
+ used_percentage?: number;
30
+ remaining_percentage?: number;
31
+ total_input_tokens?: number;
32
+ total_output_tokens?: number;
33
+ };
34
+ }
35
+
36
+ interface GramatrStats {
37
+ server_version: string;
38
+ entity_count: number;
39
+ observation_count: number;
40
+ search_count: number;
41
+ tokens_saved_total: number;
42
+ tokens_saved_7d: number;
43
+ classifications_total: number;
44
+ classifications_7d: number;
45
+ operations_1h: number;
46
+ operations_24h: number;
47
+ classifier: {
48
+ level: number;
49
+ model: string;
50
+ accuracy: number;
51
+ feedback_rate: number;
52
+ total_classifications: number;
53
+ };
54
+ learning: {
55
+ latest: number | null;
56
+ avg_1d: number | null;
57
+ avg_1w: number | null;
58
+ avg_1mo: number | null;
59
+ count: number;
60
+ };
61
+ skills_count: number;
62
+ }
63
+
64
+ interface GitInfo {
65
+ projectId: string; // org/repo from remote, fallback to directory name
66
+ branch: string;
67
+ stashCount: number;
68
+ modified: number;
69
+ untracked: number;
70
+ ahead: number;
71
+ behind: number;
72
+ lastCommitAge: string;
73
+ lastCommitColor: string;
74
+ }
75
+
76
+
77
+
78
+ // ── Config ─────────────────────────────────────────────────────────────────
79
+
80
+ const HOME = process.env.HOME || '';
81
+ const GMTR_DIR = process.env.GMTR_DIR || join(HOME, 'gmtr-client');
82
+ const STATE_DIR = join(GMTR_DIR, '.state');
83
+ const SETTINGS_PATH = join(HOME, '.claude', 'settings.json');
84
+
85
+ function getGramatrUrl(): string {
86
+ try {
87
+ const claude = JSON.parse(readFileSync(join(HOME, '.claude.json'), 'utf8'));
88
+ return claude?.mcpServers?.gramatr?.url?.replace('/mcp', '') || 'https://api.gramatr.com';
89
+ } catch {
90
+ return 'https://api.gramatr.com';
91
+ }
92
+ }
93
+
94
+ function getAuthToken(): string {
95
+ // Check settings.json first, then env var
96
+ try {
97
+ const settings = JSON.parse(readFileSync(join(GMTR_DIR, 'settings.json'), 'utf8'));
98
+ if (settings?.auth?.token) return settings.auth.token;
99
+ } catch { /* no settings file */ }
100
+ return process.env.GMTR_TOKEN || process.env.AIOS_MCP_TOKEN || '';
101
+ }
102
+
103
+ // ── Cache Helper ───────────────────────────────────────────────────────────
104
+
105
+ function readCache<T>(path: string, ttlMs: number): T | null {
106
+ try {
107
+ if (!existsSync(path)) return null;
108
+ const stat = statSync(path);
109
+ if (Date.now() - stat.mtimeMs > ttlMs) return null;
110
+ return JSON.parse(readFileSync(path, 'utf8'));
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ function writeCache(path: string, data: unknown): void {
117
+ try {
118
+ writeFileSync(path, JSON.stringify(data));
119
+ } catch { /* non-critical */ }
120
+ }
121
+
122
+ // ── Colors (24-bit ANSI) ───────────────────────────────────────────────────
123
+
124
+ // Gray Matter palette — from design-tokens.json (locked 2026-03-28)
125
+ const c = {
126
+ reset: '\x1b[0m',
127
+ bold: '\x1b[1m',
128
+ dim: '\x1b[2m',
129
+ // Text hierarchy (warm neutrals)
130
+ text: '\x1b[38;2;236;236;236m', // #ECECEC — primary text
131
+ textSec: '\x1b[38;2;136;136;136m', // #888888 — secondary
132
+ textMuted: '\x1b[38;2;102;102;102m', // #666666 — muted/labels
133
+ // Brand
134
+ primary: '\x1b[38;2;59;130;246m', // #3B82F6 — blue accent
135
+ accent: '\x1b[38;2;96;165;250m', // #60A5FA — lighter blue
136
+ // Structural
137
+ border: '\x1b[38;2;51;51;51m', // #333333 — separators
138
+ surface: '\x1b[38;2;26;26;26m', // #1A1A1A — surface
139
+ // Semantic
140
+ success: '\x1b[38;2;74;222;128m', // #4ADE80
141
+ warning: '\x1b[38;2;250;204;21m', // #FACC15
142
+ error: '\x1b[38;2;248;113;113m', // #F87171
143
+ // Bg
144
+ barBg: '\x1b[48;2;26;26;26m', // #1A1A1A — context bar empty
145
+ };
146
+
147
+ function ageColor(age: string): string {
148
+ if (age.endsWith('m') && parseInt(age) < 30) return c.success;
149
+ if (age.endsWith('h') && parseInt(age) < 2) return c.accent;
150
+ if (age.endsWith('d') && parseInt(age) < 2) return c.warning;
151
+ return c.error;
152
+ }
153
+
154
+ // ── Data Fetchers ──────────────────────────────────────────────────────────
155
+
156
+ async function fetchGramatrStats(): Promise<GramatrStats | null> {
157
+ const cached = readCache<GramatrStats>(join(STATE_DIR, 'api-stats-cache.json'), 60_000);
158
+ if (cached) return cached;
159
+
160
+ const url = getGramatrUrl();
161
+ const token = getAuthToken();
162
+ if (!token) return null;
163
+
164
+ try {
165
+ const resp = await fetch(`${url}/api/v1/stats/statusline`, {
166
+ headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' },
167
+ signal: AbortSignal.timeout(5000),
168
+ });
169
+ if (!resp.ok) {
170
+ // Fallback: read from local files if API not available yet
171
+ return readLocalStats();
172
+ }
173
+ const data = await resp.json() as GramatrStats;
174
+ writeCache(join(STATE_DIR, 'api-stats-cache.json'), data);
175
+ return data;
176
+ } catch {
177
+ // API unavailable — fall back to local files
178
+ return readLocalStats();
179
+ }
180
+ }
181
+
182
+ function readLocalStats(): GramatrStats | null {
183
+ // Backward-compatible: read from /tmp/ files (same as bash version)
184
+ try {
185
+ const stats = existsSync('/tmp/gmtr-stats.json')
186
+ ? JSON.parse(readFileSync('/tmp/gmtr-stats.json', 'utf8'))
187
+ : {};
188
+ const savings = existsSync('/tmp/gmtr-classification-savings.json')
189
+ ? JSON.parse(readFileSync('/tmp/gmtr-classification-savings.json', 'utf8'))
190
+ : {};
191
+
192
+ // Sum tokens from history
193
+ let totalTokensSaved = 0;
194
+ let ops1h = 0;
195
+ let ops24h = 0;
196
+ const now = Date.now();
197
+ if (existsSync('/tmp/gmtr-op-history.jsonl')) {
198
+ const lines = readFileSync('/tmp/gmtr-op-history.jsonl', 'utf8').trim().split('\n');
199
+ for (const line of lines) {
200
+ try {
201
+ const entry = JSON.parse(line);
202
+ totalTokensSaved += entry.tokens_saved || 0;
203
+ const age = now - (entry.timestamp || 0);
204
+ if (age < 3600_000) ops1h++;
205
+ if (age < 86400_000) ops24h++;
206
+ } catch { /* skip bad lines */ }
207
+ }
208
+ }
209
+
210
+ return {
211
+ server_version: savings.server_version || '',
212
+ entity_count: stats.entity_count || 0,
213
+ observation_count: stats.observation_count || 0,
214
+ search_count: stats.search_count || 0,
215
+ tokens_saved_total: 0,
216
+ tokens_saved_7d: 0,
217
+ classifications_total: 0,
218
+ classifications_7d: 0,
219
+ operations_1h: ops1h,
220
+ operations_24h: ops24h,
221
+ classifier: {
222
+ level: stats.classifier_level || 0,
223
+ model: stats.classifier_model || savings.qwen_model || '',
224
+ accuracy: stats.accuracy || 0,
225
+ feedback_rate: stats.feedback_rate || 0,
226
+ total_classifications: stats.total_classifications || 0,
227
+ },
228
+ learning: { latest: null, avg_1d: null, avg_1w: null, avg_1mo: null, count: 0 },
229
+ skills_count: 0,
230
+ };
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+
236
+ function getGitInfo(cwd: string): GitInfo | null {
237
+ try {
238
+ const run = (cmd: string) => execSync(cmd, { cwd, timeout: 3000, encoding: 'utf8' }).trim();
239
+
240
+ // Check if git repo
241
+ try { run('git rev-parse --git-dir'); } catch { return null; }
242
+
243
+ // Resolve project ID: git remote → org/repo, fallback to directory name
244
+ let projectId = run('git rev-parse --show-toplevel').split('/').pop() || '';
245
+ try {
246
+ const remote = run('git remote get-url origin');
247
+ const match = remote.match(/[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?$/);
248
+ if (match) projectId = `${match[1]}/${match[2]}`;
249
+ } catch { /* no remote — use directory name */ }
250
+ const branch = run('git branch --show-current') || 'detached';
251
+
252
+ let stashCount = 0;
253
+ try { stashCount = run('git stash list').split('\n').filter(Boolean).length; } catch {}
254
+
255
+ let modified = 0, untracked = 0;
256
+ try {
257
+ const status = run('git status --porcelain');
258
+ for (const line of status.split('\n').filter(Boolean)) {
259
+ if (line.startsWith('??')) untracked++;
260
+ else modified++;
261
+ }
262
+ } catch {}
263
+
264
+ let ahead = 0, behind = 0;
265
+ try {
266
+ const counts = run('git rev-list --left-right --count HEAD...@{u}');
267
+ const [a, b] = counts.split('\t').map(Number);
268
+ ahead = a || 0;
269
+ behind = b || 0;
270
+ } catch {}
271
+
272
+ let lastCommitAge = '?';
273
+ let lastCommitColor = c.slate400;
274
+ try {
275
+ const epoch = parseInt(run('git log -1 --format=%ct'));
276
+ const seconds = Math.floor(Date.now() / 1000) - epoch;
277
+ if (seconds < 3600) {
278
+ lastCommitAge = `${Math.floor(seconds / 60)}m`;
279
+ } else if (seconds < 86400) {
280
+ lastCommitAge = `${Math.floor(seconds / 3600)}h`;
281
+ } else {
282
+ lastCommitAge = `${Math.floor(seconds / 86400)}d`;
283
+ }
284
+ lastCommitColor = ageColor(lastCommitAge);
285
+ } catch {}
286
+
287
+ return { projectId, branch, stashCount, modified, untracked, ahead, behind, lastCommitAge, lastCommitColor };
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+
293
+ // ── Rendering ──────────────────────────────────────────────────────────────
294
+
295
+ function formatNumber(n: number): string {
296
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
297
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
298
+ return String(n);
299
+ }
300
+
301
+ function contextBar(pct: number, width: number): string {
302
+ const filled = Math.round((pct / 100) * width);
303
+ let bar = '';
304
+ for (let i = 0; i < width; i++) {
305
+ if (i < filled) {
306
+ const ratio = i / width;
307
+ if (ratio < 0.4) bar += `${c.success}⛁`;
308
+ else if (ratio < 0.6) bar += `${c.accent}⛁`;
309
+ else if (ratio < 0.8) bar += `${c.warning}⛁`;
310
+ else bar += `${c.error}⛁`;
311
+ } else {
312
+ bar += `${c.barBg} ${c.reset}`;
313
+ }
314
+ }
315
+ return bar + c.reset;
316
+ }
317
+
318
+ function separator(width: number): string {
319
+ return ` ${c.border}${'─'.repeat(Math.min(width - 4, 72))}${c.reset}`;
320
+ }
321
+
322
+ interface Heartbeat {
323
+ effort: string;
324
+ intent: string;
325
+ confidence: string;
326
+ totalMs: number;
327
+ memoryDelivered: number;
328
+ stale: boolean;
329
+ }
330
+
331
+ function getHeartbeat(): Heartbeat {
332
+ try {
333
+ const data = JSON.parse(readFileSync('/tmp/gmtr-classification-savings.json', 'utf8'));
334
+ const age = Date.now() - (data.timestamp || 0);
335
+ const stale = age > 300_000;
336
+ const st = data.stage_timing || {};
337
+ return {
338
+ effort: data.effort || '',
339
+ intent: data.intent || '',
340
+ confidence: data.confidence ? `${Math.round(data.confidence * 100)}%` : '',
341
+ totalMs: st.total_ms || data.qwen_time_ms || 0,
342
+ memoryDelivered: data.memory_delivered || 0,
343
+ stale,
344
+ };
345
+ } catch {
346
+ return { effort: '', intent: '', confidence: '', totalMs: 0, memoryDelivered: 0, stale: true };
347
+ }
348
+ }
349
+
350
+ function render(input: CCInput, gmtr: GramatrStats | null, git: GitInfo | null): string {
351
+ const lines: string[] = [];
352
+ const termWidth = parseInt(process.env.COLUMNS || '80', 10);
353
+ const barWidth = Math.min(termWidth - 20, 55);
354
+ const version = gmtr?.server_version ? `v${gmtr.server_version}` : '';
355
+ const statusIcon = gmtr ? `${c.success}●${c.reset}` : `${c.error}○${c.reset}`;
356
+ const hb = getHeartbeat();
357
+
358
+ // ── LINE 1: BRAND + HEARTBEAT ──
359
+ let heartbeatText = '';
360
+ if (!hb.stale && hb.effort) {
361
+ heartbeatText = `${c.text}${hb.effort}/${hb.intent}${c.reset} ${c.accent}${hb.confidence}${c.reset} ${c.textMuted}${hb.totalMs}ms${c.reset}`;
362
+ if (hb.memoryDelivered > 0) {
363
+ heartbeatText += ` ${c.success}◇${hb.memoryDelivered} memories${c.reset}`;
364
+ }
365
+ }
366
+ const brandLine = ` ${c.border}──${c.reset} ${statusIcon} ` +
367
+ `${c.primary}grāmatr${c.reset} ` +
368
+ `${c.textMuted}${version}${c.reset}` +
369
+ (heartbeatText ? ` ${c.border}│${c.reset} ${heartbeatText}` : '') +
370
+ ` ${c.border}│${c.reset} ${c.textMuted}gramatr.com${c.reset}`;
371
+ lines.push(brandLine);
372
+
373
+ // ── LINE 2: CONTEXT ──
374
+ const ctxPct = input.context_window?.used_percentage || 0;
375
+ lines.push(separator(termWidth));
376
+ lines.push(` ${c.accent}◉${c.reset} ${c.textMuted}CONTEXT:${c.reset} ${contextBar(ctxPct, barWidth)} ${c.text}${Math.round(ctxPct)}%${c.reset}`);
377
+
378
+ // ── LINE 3: PROJECT ──
379
+ if (git) {
380
+ lines.push(separator(termWidth));
381
+ lines.push(
382
+ ` ${c.primary}◈${c.reset} ${c.text}${git.projectId}${c.reset}` +
383
+ ` ${c.border}│${c.reset} ${c.accent}${git.branch}${c.reset}`
384
+ );
385
+ }
386
+
387
+ // ── LINE 5: GRAMATR INTELLIGENCE ──
388
+ if (gmtr) {
389
+ lines.push(separator(termWidth));
390
+ const cl = gmtr.classifier;
391
+ const level = cl.level > 0 ? `L${cl.level}` : '—';
392
+ const savedWeek = formatNumber(gmtr.tokens_saved_7d || 0);
393
+ const savedTotal = formatNumber(gmtr.tokens_saved_total || 0);
394
+ lines.push(
395
+ ` ${c.primary}◎${c.reset}` +
396
+ ` ${c.accent}⬢${formatNumber(gmtr.entity_count)}${c.reset}${c.textMuted} entities${c.reset}` +
397
+ ` ${c.border}│${c.reset} ${c.accent}◇${formatNumber(gmtr.observation_count)}${c.reset}${c.textMuted} obs${c.reset}` +
398
+ ` ${c.border}│${c.reset} ${c.primary}${level}${c.reset}` +
399
+ ` ${c.border}│${c.reset} ${c.textMuted}saved:${c.reset} ${c.success}${savedWeek}/wk${c.reset} ${c.text}${savedTotal}/all${c.reset}`
400
+ );
401
+ }
402
+
403
+ lines.push(separator(termWidth));
404
+ return lines.join('\n');
405
+ }
406
+
407
+ // ── Main ───────────────────────────────────────────────────────────────────
408
+
409
+ async function main() {
410
+ // Read CC JSON from stdin
411
+ let input: CCInput = {};
412
+ try {
413
+ const chunks: Buffer[] = [];
414
+ for await (const chunk of process.stdin) {
415
+ chunks.push(Buffer.from(chunk));
416
+ }
417
+ const raw = Buffer.concat(chunks).toString('utf8');
418
+ if (raw.trim()) input = JSON.parse(raw);
419
+ } catch { /* no stdin or bad JSON */ }
420
+
421
+ const cwd = input.workspace?.current_dir || input.cwd || process.cwd();
422
+
423
+ // Fetch gramatr stats
424
+ const gmtr = await fetchGramatrStats();
425
+
426
+ // Git is sync (fast, <100ms)
427
+ const git = getGitInfo(cwd);
428
+
429
+ // Render and output
430
+ const output = render(input, gmtr, git);
431
+ process.stdout.write(output + '\n');
432
+ }
433
+
434
+ main().catch(() => {
435
+ // Never crash — output minimal fallback
436
+ process.stdout.write(` gramatr │ status unavailable\n`);
437
+ });