@jhizzard/termdeck-stack 0.6.1 → 0.6.3
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/assets/hooks/memory-session-end.js +312 -11
- package/package.json +1 -1
- package/src/index.js +74 -2
|
@@ -36,7 +36,32 @@
|
|
|
36
36
|
* (last ~30 message excerpts).
|
|
37
37
|
* 7. Embeds the summary via OpenAI text-embedding-3-small.
|
|
38
38
|
* 8. POSTs ONE row to Supabase /rest/v1/memory_items with source_type='session_summary'.
|
|
39
|
-
* 9.
|
|
39
|
+
* 9. (Sprint 51.6 T3) POSTs ONE row to Supabase /rest/v1/memory_sessions with
|
|
40
|
+
* Prefer: resolution=merge-duplicates so SessionEnd-fires-twice resolves
|
|
41
|
+
* to a single row. Requires Mnestra migration 017 on canonical installs;
|
|
42
|
+
* petvetbid already has the rich schema from rag-system bootstrap.
|
|
43
|
+
* 10. Logs every step to ~/.claude/hooks/memory-hook.log.
|
|
44
|
+
*
|
|
45
|
+
* Version stamp (Sprint 51.6 T3 — hook upgrade gap fix):
|
|
46
|
+
* The marker `@termdeck/stack-installer-hook v<N>` below is read by both
|
|
47
|
+
* stack-installer's installSessionEndHook (version-aware overwrite under
|
|
48
|
+
* --yes) and `termdeck init --mnestra` (refreshBundledHookIfNewer step).
|
|
49
|
+
* Bump the integer whenever a change to this file should overwrite an
|
|
50
|
+
* already-installed copy on the user's machine — e.g. a new write path,
|
|
51
|
+
* a new transcript parser, a default PROJECT_MAP change. Comment-only
|
|
52
|
+
* tweaks do not need a bump.
|
|
53
|
+
*
|
|
54
|
+
* v2 (Sprint 51.7 T2 — metadata completeness + wire-up insurance):
|
|
55
|
+
* - parseTranscriptMetadata() now populates memory_sessions.started_at /
|
|
56
|
+
* duration_minutes / facts_extracted from per-message timestamps and
|
|
57
|
+
* memory_remember tool_use counts, closing the v1 "minimum viable row"
|
|
58
|
+
* gap Codex flagged at Sprint 51.6 Phase B.
|
|
59
|
+
* - Stamp bump load-bearing as INSURANCE for the Sprint 51.6 wire-up bug
|
|
60
|
+
* (T1 fix landing in same v1.0.3 wave): an installed-v1 user upgrading
|
|
61
|
+
* to bundled-v2 always passes the `installed >= bundled` short-circuit
|
|
62
|
+
* at init-mnestra.js:550 and reaches the refresh path.
|
|
63
|
+
*
|
|
64
|
+
* @termdeck/stack-installer-hook v2
|
|
40
65
|
*
|
|
41
66
|
* Required env vars (validated at entry, after the secrets.env fallback):
|
|
42
67
|
* - SUPABASE_URL e.g. https://<project-ref>.supabase.co
|
|
@@ -73,13 +98,43 @@ function resolveSecretsPath() {
|
|
|
73
98
|
|| join(os.homedir(), '.termdeck', 'secrets.env');
|
|
74
99
|
}
|
|
75
100
|
|
|
76
|
-
// PROJECT_MAP —
|
|
77
|
-
// Patterns match against the cwd reported by Claude Code at
|
|
101
|
+
// PROJECT_MAP — most-specific-first ordering (Sprint 41 design).
|
|
102
|
+
// Patterns match against the cwd reported by Claude Code at SessionEnd.
|
|
78
103
|
// First match wins; falls through to "global".
|
|
104
|
+
//
|
|
105
|
+
// Sprint 51.6 (T1 side finding b): a previous version shipped this array
|
|
106
|
+
// empty, which caused every session to tag as "global" — orphaning rows
|
|
107
|
+
// from project-scoped memory_recall queries. The default below restores
|
|
108
|
+
// the most-specific-first taxonomy from Sprint 41 T1, generalized for
|
|
109
|
+
// universal shipping. Users still extend in place by editing this array.
|
|
110
|
+
//
|
|
111
|
+
// Patterns NOT specific to Joshua's filesystem (e.g. /\/PVB\//i, /\/DOR\//i)
|
|
112
|
+
// are kept because they're benign on other machines — the regex simply
|
|
113
|
+
// doesn't fire on cwds that don't contain those segments. The chopin-
|
|
114
|
+
// nashville catch-all stays LAST (structural invariant) so a TermDeck cwd
|
|
115
|
+
// inside ChopinNashville/SideHustles/ resolves to "termdeck", not the
|
|
116
|
+
// catch-all.
|
|
79
117
|
const PROJECT_MAP = [
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
118
|
+
// ── Active code projects (most-specific FIRST) ──
|
|
119
|
+
{ pattern: /\/SideHustles\/TermDeck\/termdeck/i, project: 'termdeck' },
|
|
120
|
+
{ pattern: /\/Graciella\/engram(\/|$)/i, project: 'mnestra' },
|
|
121
|
+
{ pattern: /\/Graciella\/rumen(\/|$)/i, project: 'rumen' },
|
|
122
|
+
{ pattern: /\/Graciella\/rag-system(\/|$)/i, project: 'rag-system' },
|
|
123
|
+
{ pattern: /\/ChopinInBohemia\/podium(\/|$)/i, project: 'podium' },
|
|
124
|
+
{ pattern: /\/ChopinInBohemia(\/|$)/i, project: 'chopin-in-bohemia' },
|
|
125
|
+
{ pattern: /\/SideHustles\/SchedulingApp(\/|$)/i, project: 'chopin-scheduler' },
|
|
126
|
+
{ pattern: /\/ChopinNashville\/SchedulingApp(\/|$)/i, project: 'chopin-scheduler' },
|
|
127
|
+
{ pattern: /\/Graciella\/PVB(\/|$)|\/PVB\/pvb(\/|$)/i, project: 'pvb' },
|
|
128
|
+
{ pattern: /\/Unagi\/gorgias-ticket-monitor(\/|$)/i, project: 'claimguard' },
|
|
129
|
+
{ pattern: /\/ChopinNashville\/SideHustles\/ClaimGuard(\/|$)/i, project: 'claimguard' },
|
|
130
|
+
{ pattern: /\/Documents\/DOR(\/|$)/i, project: 'dor' },
|
|
131
|
+
{ pattern: /\/Graciella\/joshuaizzard-dev(\/|$)/i, project: 'portfolio' },
|
|
132
|
+
{ pattern: /\/Graciella\/imessage-reader(\/|$)/i, project: 'imessage-reader' },
|
|
133
|
+
|
|
134
|
+
// ── chopin-nashville catch-all (MUST be LAST among /ChopinNashville/ matchers).
|
|
135
|
+
// Sprint 35 + 41 lesson: any /ChopinNashville/-matching pattern placed below
|
|
136
|
+
// this entry gets shadowed and the row mis-tags as 'chopin-nashville'.
|
|
137
|
+
{ pattern: /\/ChopinNashville(\/|$)/i, project: 'chopin-nashville' },
|
|
83
138
|
];
|
|
84
139
|
|
|
85
140
|
const MIN_TRANSCRIPT_BYTES = parseInt(process.env.TERMDECK_HOOK_MIN_BYTES || '5000', 10);
|
|
@@ -395,6 +450,117 @@ function selectTranscriptParser(sessionType) {
|
|
|
395
450
|
return { parser: parseAutoDetect, sessionType: 'auto' };
|
|
396
451
|
}
|
|
397
452
|
|
|
453
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
454
|
+
// Sprint 51.7 T2 — transcript metadata extractor for memory_sessions.
|
|
455
|
+
//
|
|
456
|
+
// The v1 bundled hook (Sprint 51.6 T3) intentionally shipped the "minimum
|
|
457
|
+
// viable row" — postMemorySession set started_at, duration_minutes, and
|
|
458
|
+
// facts_extracted to NULL/0 because v1 omitted transcript parsing for
|
|
459
|
+
// per-message timestamps. The legacy rag-system writer
|
|
460
|
+
// (~/Documents/Graciella/rag-system/src/scripts/process-session.ts) populated
|
|
461
|
+
// those fields by parsing the transcript JSONL passed to it on stdin, and
|
|
462
|
+
// petvetbid's 289 baseline rows carried the rich shape from that writer.
|
|
463
|
+
// v2 closes the gap in pure Node so the bundled hook reaches parity without
|
|
464
|
+
// the rag-system dependency (Class E hidden-dependency rule).
|
|
465
|
+
//
|
|
466
|
+
// Heuristic for facts_extracted: count distinct `tool_use` blocks whose
|
|
467
|
+
// `name` matches a memory_remember MCP tool. Conservative by design — a
|
|
468
|
+
// regex like /Remember:/ inside summary text would over-match quoted user
|
|
469
|
+
// content (e.g., "the user typed 'Remember:' in their prompt"). Counting
|
|
470
|
+
// tool_use blocks instead measures what was actually written into the store
|
|
471
|
+
// during the session, which is the semantic the rag-system writer used.
|
|
472
|
+
//
|
|
473
|
+
// Tool name variants observed in real transcripts (T4-CODEX 11:09 ET pre-
|
|
474
|
+
// audit confirmed both prefixes are live in `~/.claude/projects/`):
|
|
475
|
+
// - `memory_remember` (bare; CC native + future-proofing)
|
|
476
|
+
// - `mcp__mnestra__memory_remember` (current Mnestra MCP, post-rename)
|
|
477
|
+
// - `mcp__memory__memory_remember` (legacy MCP server name from when
|
|
478
|
+
// the project was called "memory")
|
|
479
|
+
// Counting all three avoids undercounting on existing user transcripts.
|
|
480
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
const FACT_TOOL_NAMES = new Set([
|
|
483
|
+
'memory_remember',
|
|
484
|
+
'mcp__mnestra__memory_remember',
|
|
485
|
+
'mcp__memory__memory_remember',
|
|
486
|
+
]);
|
|
487
|
+
|
|
488
|
+
// Sprint 51.7 T2 / T4-CODEX 11:13 ET catch: each adapter shipped by this
|
|
489
|
+
// hook stores message content under a different key shape, and we have to
|
|
490
|
+
// match all of them or facts_extracted under-counts whenever a non-Claude
|
|
491
|
+
// session writes to memory_sessions. Mirror the shapes already documented
|
|
492
|
+
// at the top of TRANSCRIPT_PARSERS:
|
|
493
|
+
//
|
|
494
|
+
// - Claude Code (current): msg.message.content[]
|
|
495
|
+
// - Grok (Sprint 50 T1): msg.content[] (flat, AI SDK provider shape)
|
|
496
|
+
// - Codex (response_item): msg.payload.content[] when msg.type === 'response_item'
|
|
497
|
+
//
|
|
498
|
+
// Gemini's single-JSON envelope doesn't apply per-line — its content lives
|
|
499
|
+
// inside a top-level messages array, and each entry's content is a flat
|
|
500
|
+
// array OR a string. extractContentBlocks() handles flat arrays; strings
|
|
501
|
+
// are skipped (no tool_use can hide inside a string).
|
|
502
|
+
function extractContentBlocks(msg) {
|
|
503
|
+
if (!msg || typeof msg !== 'object') return null;
|
|
504
|
+
if (msg.message && Array.isArray(msg.message.content)) return msg.message.content;
|
|
505
|
+
if (Array.isArray(msg.content)) return msg.content;
|
|
506
|
+
if (msg.type === 'response_item' && msg.payload && Array.isArray(msg.payload.content)) {
|
|
507
|
+
return msg.payload.content;
|
|
508
|
+
}
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function parseTranscriptMetadata(rawJsonl) {
|
|
513
|
+
if (typeof rawJsonl !== 'string' || rawJsonl.length === 0) {
|
|
514
|
+
return { startedAt: null, endedAt: null, durationMinutes: null, factsExtracted: 0 };
|
|
515
|
+
}
|
|
516
|
+
const lines = rawJsonl.split('\n').filter(Boolean);
|
|
517
|
+
let earliestTs = null;
|
|
518
|
+
let latestTs = null;
|
|
519
|
+
let factsExtracted = 0;
|
|
520
|
+
|
|
521
|
+
for (const line of lines) {
|
|
522
|
+
let msg;
|
|
523
|
+
try { msg = JSON.parse(line); } catch (_) { continue; }
|
|
524
|
+
if (!msg || typeof msg !== 'object') continue;
|
|
525
|
+
|
|
526
|
+
// Timestamp: top-level `timestamp` is the canonical Claude Code shape.
|
|
527
|
+
// Fall back to `msg.message.timestamp` for any future / alt-shape that
|
|
528
|
+
// nests it (Codex/Gemini/Grok adapters preserve the top-level form, so
|
|
529
|
+
// this is mostly forward-compat).
|
|
530
|
+
const ts = msg.timestamp || (msg.message && msg.message.timestamp);
|
|
531
|
+
if (typeof ts === 'string' || typeof ts === 'number') {
|
|
532
|
+
const t = Date.parse(ts);
|
|
533
|
+
if (!Number.isNaN(t)) {
|
|
534
|
+
if (earliestTs === null || t < earliestTs) earliestTs = t;
|
|
535
|
+
if (latestTs === null || t > latestTs) latestTs = t;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// facts_extracted: count tool_use blocks matching a memory_remember
|
|
540
|
+
// MCP tool name. See FACT_TOOL_NAMES + extractContentBlocks above.
|
|
541
|
+
const blocks = extractContentBlocks(msg);
|
|
542
|
+
if (blocks) {
|
|
543
|
+
for (const b of blocks) {
|
|
544
|
+
if (b && b.type === 'tool_use' && typeof b.name === 'string' && FACT_TOOL_NAMES.has(b.name)) {
|
|
545
|
+
factsExtracted += 1;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const startedAt = earliestTs !== null ? new Date(earliestTs).toISOString() : null;
|
|
552
|
+
const endedAt = latestTs !== null ? new Date(latestTs).toISOString() : null;
|
|
553
|
+
const durationMinutes = (earliestTs !== null && latestTs !== null)
|
|
554
|
+
? Math.max(0, Math.round((latestTs - earliestTs) / 60000))
|
|
555
|
+
: null;
|
|
556
|
+
return { startedAt, endedAt, durationMinutes, factsExtracted };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Sprint 51.6 T3 → 51.7 T2: `buildSummary` now also returns parser-derived
|
|
560
|
+
// metadata (startedAt, endedAt, durationMinutes, factsExtracted) merged into
|
|
561
|
+
// the result object. parseTranscriptMetadata reuses the same raw string —
|
|
562
|
+
// no second readFileSync. Returns null when the transcript is unreadable or
|
|
563
|
+
// has fewer than 5 messages (skip semantics unchanged from v1).
|
|
398
564
|
function buildSummary(transcriptPath, sessionType) {
|
|
399
565
|
let raw;
|
|
400
566
|
try { raw = readFileSync(transcriptPath, 'utf8'); }
|
|
@@ -418,7 +584,18 @@ function buildSummary(transcriptPath, sessionType) {
|
|
|
418
584
|
tail.map((m) => `[${m.role}] ${m.content}`).join('\n');
|
|
419
585
|
// OpenAI text-embedding-3-small accepts up to 8192 tokens (~32K chars).
|
|
420
586
|
// 7000 chars is a safe headroom that survives multibyte expansion.
|
|
421
|
-
|
|
587
|
+
|
|
588
|
+
// Sprint 51.7 T2: merge transcript-derived metadata so the caller (
|
|
589
|
+
// processStdinPayload → postMemorySession) can populate the
|
|
590
|
+
// memory_sessions.started_at/duration_minutes/facts_extracted fields the
|
|
591
|
+
// v1 hook left NULL/0.
|
|
592
|
+
const metadata = parseTranscriptMetadata(raw);
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
summary: summary.slice(0, 7000),
|
|
596
|
+
messagesCount: messages.length,
|
|
597
|
+
...metadata,
|
|
598
|
+
};
|
|
422
599
|
}
|
|
423
600
|
|
|
424
601
|
async function embedText(text, openaiKey) {
|
|
@@ -494,6 +671,89 @@ async function postMemoryItem({ supabaseUrl, supabaseKey, content, embedding, pr
|
|
|
494
671
|
}
|
|
495
672
|
}
|
|
496
673
|
|
|
674
|
+
// Sprint 51.6 T3 — companion write to memory_sessions.
|
|
675
|
+
//
|
|
676
|
+
// History: the bundled hook never wrote memory_sessions until v1.0.2. Joshua's
|
|
677
|
+
// PRIOR personal rag-system hook spawned process-session.ts which inserted
|
|
678
|
+
// memory_sessions rows; the Sprint 38 P0 rewrite replaced that hook with a
|
|
679
|
+
// Mnestra-direct hook that only wrote memory_items. Result: from 2026-05-02
|
|
680
|
+
// 13:24 ET (when bundled overwrote personal) until v1.0.2, no memory_sessions
|
|
681
|
+
// rows accumulated. Sprint 51.6 T1+T2+T3 documented the gap; this function
|
|
682
|
+
// closes it.
|
|
683
|
+
//
|
|
684
|
+
// Schema target: Mnestra migration 017 brings canonical engram in line with
|
|
685
|
+
// petvetbid's rag-system flavor (session_id, summary_embedding, started_at,
|
|
686
|
+
// ended_at, duration_minutes, messages_count, transcript_path, etc). The
|
|
687
|
+
// bundled hook writes the rich shape on every install — fresh-canonical
|
|
688
|
+
// (post-mig-017) and petvetbid alike.
|
|
689
|
+
//
|
|
690
|
+
// Idempotency: Prefer: resolution=merge-duplicates relies on the
|
|
691
|
+
// memory_sessions_session_id_key unique constraint. Mig 017 adds it where
|
|
692
|
+
// absent. SessionEnd-fires-twice (e.g. /exit then PTY close) resolves to a
|
|
693
|
+
// single row.
|
|
694
|
+
async function postMemorySession({
|
|
695
|
+
supabaseUrl, supabaseKey,
|
|
696
|
+
summary, summaryEmbedding,
|
|
697
|
+
project, sessionId,
|
|
698
|
+
transcriptPath, messagesCount,
|
|
699
|
+
endedAt,
|
|
700
|
+
// Sprint 51.7 T2 — transcript-derived metadata (closes Sprint 51.6's
|
|
701
|
+
// started_at/duration_minutes/facts_extracted=NULL gap). All optional;
|
|
702
|
+
// null/null/0 fallback preserves the v1 minimum-viable-row shape when the
|
|
703
|
+
// transcript carries no timestamps (e.g. legacy fixtures, pre-CC-2.x
|
|
704
|
+
// payloads, or hand-fed test inputs).
|
|
705
|
+
startedAt = null,
|
|
706
|
+
durationMinutes = null,
|
|
707
|
+
factsExtracted = 0,
|
|
708
|
+
}) {
|
|
709
|
+
if (!sessionId) {
|
|
710
|
+
log('memory-sessions-skip: sessionId missing — cannot satisfy session_id NOT NULL/UNIQUE.');
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
try {
|
|
714
|
+
// Sprint 51.6 T3 / T4-CODEX audit 20:23 ET: PostgREST requires both
|
|
715
|
+
// `Prefer: resolution=merge-duplicates` AND `?on_conflict=<column>`
|
|
716
|
+
// on the URL to trigger an UPSERT. Without `on_conflict=session_id`
|
|
717
|
+
// a duplicate fire would error against memory_sessions_session_id_key.
|
|
718
|
+
const res = await fetch(`${supabaseUrl}/rest/v1/memory_sessions?on_conflict=session_id`, {
|
|
719
|
+
method: 'POST',
|
|
720
|
+
headers: {
|
|
721
|
+
'Content-Type': 'application/json',
|
|
722
|
+
'apikey': supabaseKey,
|
|
723
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
724
|
+
'Prefer': 'resolution=merge-duplicates,return=minimal',
|
|
725
|
+
},
|
|
726
|
+
body: JSON.stringify({
|
|
727
|
+
session_id: sessionId,
|
|
728
|
+
summary,
|
|
729
|
+
summary_embedding: Array.isArray(summaryEmbedding)
|
|
730
|
+
? `[${summaryEmbedding.join(',')}]`
|
|
731
|
+
: null,
|
|
732
|
+
project,
|
|
733
|
+
// Sprint 51.7 T2: started_at + duration_minutes + facts_extracted now
|
|
734
|
+
// populated from parseTranscriptMetadata when transcript timestamps
|
|
735
|
+
// are present. files_changed and topics remain unpopulated (would
|
|
736
|
+
// require diff parsing the bundled hook doesn't have; deferred).
|
|
737
|
+
started_at: typeof startedAt === 'string' ? startedAt : null,
|
|
738
|
+
ended_at: (endedAt instanceof Date ? endedAt : new Date()).toISOString(),
|
|
739
|
+
duration_minutes: typeof durationMinutes === 'number' ? durationMinutes : null,
|
|
740
|
+
messages_count: typeof messagesCount === 'number' ? messagesCount : 0,
|
|
741
|
+
facts_extracted: typeof factsExtracted === 'number' ? factsExtracted : 0,
|
|
742
|
+
transcript_path: transcriptPath || null,
|
|
743
|
+
}),
|
|
744
|
+
});
|
|
745
|
+
if (!res.ok) {
|
|
746
|
+
const body = await res.text().catch(() => '');
|
|
747
|
+
log(`memory-sessions-insert-failed: HTTP ${res.status} ${body.slice(0, 200)}`);
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
return true;
|
|
751
|
+
} catch (e) {
|
|
752
|
+
log(`memory-sessions-insert-exception: ${e.message}`);
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
497
757
|
async function processStdinPayload(input) {
|
|
498
758
|
let data;
|
|
499
759
|
try { data = JSON.parse(input); }
|
|
@@ -543,13 +803,21 @@ async function processStdinPayload(input) {
|
|
|
543
803
|
const project = detectProject(cwd);
|
|
544
804
|
debug(`project="${project}", session=${sessionId}, sessionType=${sessionType}`);
|
|
545
805
|
|
|
546
|
-
const
|
|
547
|
-
if (!
|
|
806
|
+
const built = buildSummary(transcriptPath, sessionType);
|
|
807
|
+
if (!built) return;
|
|
808
|
+
const {
|
|
809
|
+
summary,
|
|
810
|
+
messagesCount,
|
|
811
|
+
startedAt: parsedStartedAt,
|
|
812
|
+
endedAt: parsedEndedAt,
|
|
813
|
+
durationMinutes,
|
|
814
|
+
factsExtracted,
|
|
815
|
+
} = built;
|
|
548
816
|
|
|
549
817
|
const embedding = await embedText(summary, env.openaiKey);
|
|
550
818
|
if (!embedding) return;
|
|
551
819
|
|
|
552
|
-
const
|
|
820
|
+
const itemOk = await postMemoryItem({
|
|
553
821
|
supabaseUrl: env.supabaseUrl,
|
|
554
822
|
supabaseKey: env.supabaseKey,
|
|
555
823
|
content: summary,
|
|
@@ -559,7 +827,34 @@ async function processStdinPayload(input) {
|
|
|
559
827
|
sourceAgent,
|
|
560
828
|
});
|
|
561
829
|
|
|
562
|
-
|
|
830
|
+
// Sprint 51.6 T3: companion memory_sessions write. Independent of the
|
|
831
|
+
// memory_items write — a memory_items failure shouldn't suppress the
|
|
832
|
+
// memory_sessions row, and vice versa. Both errors fail-soft.
|
|
833
|
+
//
|
|
834
|
+
// Sprint 51.7 T2: prefer parser-derived `endedAt` (last-message
|
|
835
|
+
// timestamp) over hook-fire-time when the transcript carried timestamps.
|
|
836
|
+
// Matches the rag-system writer's semantics — `ended_at` is "when the
|
|
837
|
+
// conversation last had activity," not "when the SessionEnd hook
|
|
838
|
+
// happened to fire." Falls back to `new Date()` when the parser found
|
|
839
|
+
// no timestamps, preserving v1 behavior.
|
|
840
|
+
const sessionOk = await postMemorySession({
|
|
841
|
+
supabaseUrl: env.supabaseUrl,
|
|
842
|
+
supabaseKey: env.supabaseKey,
|
|
843
|
+
summary,
|
|
844
|
+
summaryEmbedding: embedding,
|
|
845
|
+
project,
|
|
846
|
+
sessionId,
|
|
847
|
+
transcriptPath,
|
|
848
|
+
messagesCount,
|
|
849
|
+
endedAt: parsedEndedAt ? new Date(parsedEndedAt) : new Date(),
|
|
850
|
+
startedAt: parsedStartedAt,
|
|
851
|
+
durationMinutes,
|
|
852
|
+
factsExtracted,
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
if (itemOk || sessionOk) {
|
|
856
|
+
log(`ingested: project="${project}" session=${sessionId} bytes=${summary.length} messages=${messagesCount} sessionType=${sessionType} sourceAgent=${normalizeSourceAgent(sourceAgent)} startedAt=${parsedStartedAt || 'null'} durationMin=${durationMinutes === null ? 'null' : durationMinutes} factsExtracted=${factsExtracted} memory_items=${itemOk ? 'ok' : 'fail'} memory_sessions=${sessionOk ? 'ok' : 'fail'}`);
|
|
857
|
+
}
|
|
563
858
|
}
|
|
564
859
|
|
|
565
860
|
// Module-export contract for testability. When run as a script (require.main === module),
|
|
@@ -579,6 +874,8 @@ if (require.main === module) {
|
|
|
579
874
|
buildSummary,
|
|
580
875
|
embedText,
|
|
581
876
|
postMemoryItem,
|
|
877
|
+
// Sprint 51.6 T3 — memory_sessions write companion.
|
|
878
|
+
postMemorySession,
|
|
582
879
|
processStdinPayload,
|
|
583
880
|
LOG_FILE,
|
|
584
881
|
// Sprint 45 T4 — adapter-pluggable transcript-parser surface.
|
|
@@ -593,5 +890,9 @@ if (require.main === module) {
|
|
|
593
890
|
// Sprint 50 T2 — source_agent provenance plumbing.
|
|
594
891
|
normalizeSourceAgent,
|
|
595
892
|
ALLOWED_SOURCE_AGENTS,
|
|
893
|
+
// Sprint 51.7 T2 — transcript-metadata extractor for memory_sessions.
|
|
894
|
+
parseTranscriptMetadata,
|
|
895
|
+
FACT_TOOL_NAMES,
|
|
896
|
+
extractContentBlocks,
|
|
596
897
|
};
|
|
597
898
|
}
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -525,6 +525,61 @@ function _compareHookFiles(srcPath, destPath) {
|
|
|
525
525
|
return a.equals(b) ? 'identical' : 'different';
|
|
526
526
|
}
|
|
527
527
|
|
|
528
|
+
// Sprint 51.6 T3 — version-aware overwrite gate. The bundled hook carries a
|
|
529
|
+
// `// @termdeck/stack-installer-hook v<N>` marker; bumping <N> means "the
|
|
530
|
+
// next install should overwrite a stale copy on the user's machine even
|
|
531
|
+
// under --yes." Without this, --yes preserved the existing hook (the safe
|
|
532
|
+
// default for hand-edited files) and never landed bundled fixes — the gap
|
|
533
|
+
// Codex surfaced in Sprint 51.6 (item #2 of the 5-part fix).
|
|
534
|
+
const HOOK_SIGNATURE_REGEX = /@termdeck\/stack-installer-hook\s+v(\d+)/;
|
|
535
|
+
|
|
536
|
+
function _readHookSignatureVersion(filepath) {
|
|
537
|
+
try {
|
|
538
|
+
const head = fs.readFileSync(filepath, 'utf8').slice(0, 4096);
|
|
539
|
+
const m = head.match(HOOK_SIGNATURE_REGEX);
|
|
540
|
+
return m ? parseInt(m[1], 10) : null;
|
|
541
|
+
} catch (_) { return null; }
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Sprint 51.6 T4-CODEX audit 20:23 ET — safety gate: only auto-overwrite an
|
|
545
|
+
// unsigned installed hook when it was clearly TermDeck-managed (carries one
|
|
546
|
+
// of the docstring markers from a prior bundled cut). A genuinely custom
|
|
547
|
+
// user hook with no TermDeck fingerprint stays put under --yes; the user
|
|
548
|
+
// gets prompted (interactive) or must `--force-overwrite`. Markers are
|
|
549
|
+
// matched in the first 4KB so a long custom file with TermDeck mentions
|
|
550
|
+
// in the body doesn't false-positive.
|
|
551
|
+
const TERMDECK_MANAGED_MARKERS = [
|
|
552
|
+
/TermDeck session-end memory hook/,
|
|
553
|
+
/@jhizzard\/termdeck-stack/,
|
|
554
|
+
/Vendored into ~\/\.claude\/hooks\/memory-session-end\.js by @jhizzard/i,
|
|
555
|
+
];
|
|
556
|
+
|
|
557
|
+
function _looksTermdeckManaged(filepath) {
|
|
558
|
+
try {
|
|
559
|
+
const head = fs.readFileSync(filepath, 'utf8').slice(0, 4096);
|
|
560
|
+
return TERMDECK_MANAGED_MARKERS.some((m) => m.test(head));
|
|
561
|
+
} catch (_) { return false; }
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Returns true when the bundled hook's version stamp is strictly newer than
|
|
565
|
+
// the installed one (or the installed file is unsigned BUT visibly TermDeck-
|
|
566
|
+
// managed — older installs pre-Sprint-51.6 had no marker, treat them as v0).
|
|
567
|
+
// Returns false when the bundled hook itself is unsigned (safety: a missing
|
|
568
|
+
// source marker means "don't auto-overwrite") OR the installed file is
|
|
569
|
+
// unsigned and looks like a custom user hook (no TermDeck fingerprint).
|
|
570
|
+
// Used to gate --yes overwrite under installSessionEndHook.
|
|
571
|
+
function _hookSignatureUpgradeAvailable(sourcePath, destPath) {
|
|
572
|
+
const bundled = _readHookSignatureVersion(sourcePath);
|
|
573
|
+
if (bundled === null) return false; // bundled unsigned — never auto-overwrite
|
|
574
|
+
const installed = _readHookSignatureVersion(destPath);
|
|
575
|
+
if (installed === null) {
|
|
576
|
+
// Installed has no version stamp. Only auto-overwrite if it looks
|
|
577
|
+
// TermDeck-managed; otherwise preserve as a possible user-custom hook.
|
|
578
|
+
return _looksTermdeckManaged(destPath);
|
|
579
|
+
}
|
|
580
|
+
return bundled > installed;
|
|
581
|
+
}
|
|
582
|
+
|
|
528
583
|
async function promptYesNo({ question, defaultYes = true }) {
|
|
529
584
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
530
585
|
const suffix = defaultYes ? '(Y/n)' : '(y/N)';
|
|
@@ -583,8 +638,12 @@ async function installSessionEndHook(opts = {}) {
|
|
|
583
638
|
statusLine(`${ANSI.dim}=${ANSI.reset}`, 'hook file', 'already present, identical contents');
|
|
584
639
|
fileStatus = 'already-current';
|
|
585
640
|
} else {
|
|
586
|
-
// different
|
|
587
|
-
|
|
641
|
+
// different. Sprint 51.6 T3: under --yes, consult the version stamp —
|
|
642
|
+
// a strictly newer bundled stamp (or an unsigned existing file) means
|
|
643
|
+
// we should refresh; same-or-older stamp keeps existing. This closes
|
|
644
|
+
// the upgrade gap where bundled fixes never reached users' machines.
|
|
645
|
+
const overwrite = opts.assumeYes
|
|
646
|
+
? _hookSignatureUpgradeAvailable(sourcePath, destPath)
|
|
588
647
|
: opts.forceOverwrite ? true
|
|
589
648
|
: await promptOverwrite();
|
|
590
649
|
if (!overwrite) {
|
|
@@ -594,6 +653,12 @@ async function installSessionEndHook(opts = {}) {
|
|
|
594
653
|
statusLine(`${ANSI.yellow}↩${ANSI.reset}`, '(dry-run)', `would overwrite ${destPath}`);
|
|
595
654
|
fileStatus = 'would-overwrite';
|
|
596
655
|
} else {
|
|
656
|
+
// Sprint 51.6 T3: timestamped backup before overwrite so a hand-
|
|
657
|
+
// edited PROJECT_MAP or comment is recoverable. Matches the pattern
|
|
658
|
+
// ~/.claude/hooks/memory-session-end.js.bak.<YYYYMMDDhhmmss> Joshua
|
|
659
|
+
// already had on disk from prior installs.
|
|
660
|
+
const stamp = new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14);
|
|
661
|
+
try { fs.copyFileSync(destPath, `${destPath}.bak.${stamp}`); } catch (_) { /* best-effort */ }
|
|
597
662
|
fs.copyFileSync(sourcePath, destPath);
|
|
598
663
|
fs.chmodSync(destPath, 0o644);
|
|
599
664
|
statusLine(`${ANSI.green}↻${ANSI.reset}`, 'hook file', `overwrote ${destPath}`);
|
|
@@ -788,8 +853,15 @@ module.exports._readSettingsJson = _readSettingsJson;
|
|
|
788
853
|
module.exports._writeSettingsJson = _writeSettingsJson;
|
|
789
854
|
module.exports._isSessionEndHookEntry = _isSessionEndHookEntry;
|
|
790
855
|
module.exports._compareHookFiles = _compareHookFiles;
|
|
856
|
+
// Sprint 51.6 T3 — version-aware hook refresh helpers. Exported so init-mnestra
|
|
857
|
+
// (and tests) can gate refresh decisions on the same logic the installer uses.
|
|
858
|
+
module.exports._readHookSignatureVersion = _readHookSignatureVersion;
|
|
859
|
+
module.exports._hookSignatureUpgradeAvailable = _hookSignatureUpgradeAvailable;
|
|
860
|
+
module.exports.HOOK_SIGNATURE_REGEX = HOOK_SIGNATURE_REGEX;
|
|
791
861
|
module.exports.installSessionEndHook = installSessionEndHook;
|
|
792
862
|
module.exports.HOOK_COMMAND = HOOK_COMMAND;
|
|
863
|
+
module.exports.HOOK_SOURCE = HOOK_SOURCE;
|
|
864
|
+
module.exports.HOOK_DEST = HOOK_DEST;
|
|
793
865
|
module.exports.HOOK_TIMEOUT_SECONDS = HOOK_TIMEOUT_SECONDS;
|
|
794
866
|
module.exports.HOOK_SOURCE = HOOK_SOURCE;
|
|
795
867
|
module.exports._mcpInternals = _mcpInternals;
|