@jhizzard/termdeck 1.0.2 → 1.0.4
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
|
|
5
5
|
"bin": {
|
|
6
6
|
"termdeck": "./packages/cli/src/index.js"
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
// existing values. Done BEFORE any database work so a later pg connect
|
|
11
11
|
// or migration failure doesn't lose the user's typed-in keys.
|
|
12
12
|
// 3. Connect via `pg` using the direct URL
|
|
13
|
-
// 4. Apply
|
|
13
|
+
// 4. Apply all bundled Mnestra migrations in order (currently 17 — count
|
|
14
|
+
// grows over time; audit-upgrade probes for any not yet applied and
|
|
15
|
+
// runs them idempotently against existing installs)
|
|
14
16
|
// 5. Update ~/.termdeck/config.yaml — set rag.enabled: false (MCP-only
|
|
15
17
|
// default; opt into TermDeck-side RAG via dashboard toggle) and point
|
|
16
18
|
// at ${VAR} refs (only after migrations apply cleanly — otherwise the
|
|
@@ -75,7 +77,7 @@ const HELP = [
|
|
|
75
77
|
' saved values if a complete set already exists in secrets.env.',
|
|
76
78
|
' 2. Writes ~/.termdeck/secrets.env IMMEDIATELY (merge-aware) so a later',
|
|
77
79
|
' pg connect or migration failure does not lose what you typed in.',
|
|
78
|
-
' 3. Connects to Postgres and applies
|
|
80
|
+
' 3. Connects to Postgres and applies all bundled Mnestra schema + RPC migrations.',
|
|
79
81
|
' 4. Updates ~/.termdeck/config.yaml — sets rag.enabled: false (MCP-only',
|
|
80
82
|
' default) and references ${VAR} keys for credentials.',
|
|
81
83
|
' 5. Verifies the Mnestra store is reachable via memory_status_aggregation().',
|
|
@@ -182,7 +184,8 @@ This wizard configures TermDeck's Tier 2 memory layer (Mnestra) by:
|
|
|
182
184
|
4. Asking for an Anthropic API key (optional, summaries)
|
|
183
185
|
5. Writing ~/.termdeck/secrets.env (before any database work, so a
|
|
184
186
|
pg failure cannot lose what you typed in)
|
|
185
|
-
6. Connecting to Postgres + applying
|
|
187
|
+
6. Connecting to Postgres + applying all bundled SQL migrations
|
|
188
|
+
(audit-upgrade detects + applies any missing on existing installs)
|
|
186
189
|
7. Updating ~/.termdeck/config.yaml — rag.enabled: false (MCP-only
|
|
187
190
|
default; toggle in dashboard later) with \${VAR} refs (only after
|
|
188
191
|
migrations apply cleanly)
|
|
@@ -567,6 +570,265 @@ function refreshBundledHookIfNewer(opts = {}) {
|
|
|
567
570
|
return { status: 'refreshed', from: installed, to: bundled, backup };
|
|
568
571
|
}
|
|
569
572
|
|
|
573
|
+
// Sprint 51.7 T1 — wizard wire-up bug fix.
|
|
574
|
+
//
|
|
575
|
+
// Moved upstream of `pgRunner.connect` and the migration-replay loop so
|
|
576
|
+
// DB-side failures (Class A schema drift, network blips, partial state)
|
|
577
|
+
// cannot strand the hook upgrade. Joshua's 2026-05-03 Phase B run threw at
|
|
578
|
+
// `applyMigrations()` on `001_mnestra_tables.sql` (the `match_memories`
|
|
579
|
+
// CREATE OR REPLACE return-type drift on petvetbid — existing function had
|
|
580
|
+
// columns in a different order, Postgres rejected with "cannot change return
|
|
581
|
+
// type of existing function"). Outer catch at the old call site fired and
|
|
582
|
+
// returned exit 5; the refresh at the old wire-up never ran. Brad's
|
|
583
|
+
// jizzard-brain reproduced the same symptom under v1.0.2.
|
|
584
|
+
//
|
|
585
|
+
// Hook refresh is a LOCAL filesystem operation. It has no dependency on DB
|
|
586
|
+
// success, so it should run as part of the initial local-setup phase next
|
|
587
|
+
// to `writeSecretsFile`, not buried after a 17-migration replay. This also
|
|
588
|
+
// means the wizard ALWAYS lands the bundled hook on disk after a successful
|
|
589
|
+
// `npm install -g @jhizzard/termdeck@latest && termdeck init --mnestra`,
|
|
590
|
+
// even when the DB phase fails — a meaningful upgrade-path improvement
|
|
591
|
+
// because the hook fix is independently valuable.
|
|
592
|
+
//
|
|
593
|
+
// `--dry-run` exercises this path with `dryRun: true` so the wizard
|
|
594
|
+
// truthfully reports what WOULD happen on a live run (Sprint 51.6 Phase B
|
|
595
|
+
// dry-run probe couldn't catch the wire-up bug because dry-run early-
|
|
596
|
+
// returned BEFORE the old refresh location at line 677).
|
|
597
|
+
//
|
|
598
|
+
// Stderr instrumentation is gated behind `TERMDECK_DEBUG_WIREUP=1` so
|
|
599
|
+
// production users never see noise; the gate is broadly useful for future
|
|
600
|
+
// wire-up bisects (any developer can re-run the wizard with the env var
|
|
601
|
+
// set and get a deterministic trace).
|
|
602
|
+
// Sprint 51.8 — settings.json wiring migration (Brad's v1.0.3 follow-up).
|
|
603
|
+
//
|
|
604
|
+
// Sprint 51.7 fixed the Class M wire-up bug — `runHookRefresh()` runs
|
|
605
|
+
// upstream of the DB phase so the v2 hook FILE always lands. But the
|
|
606
|
+
// settings.json wiring half of `installSessionEndHook` (in
|
|
607
|
+
// `packages/stack-installer/src/index.js`) was never lifted into the
|
|
608
|
+
// wizard. Result: anyone whose `~/.claude/settings.json` was wired by a
|
|
609
|
+
// pre-Sprint-48 stack-installer (= `@jhizzard/termdeck-stack@<=0.5.0`,
|
|
610
|
+
// matching anyone who first ran `termdeck init --mnestra` on
|
|
611
|
+
// v1.0.0/v1.0.1) gets the v2 hook FILE post-1.0.3, but the file is still
|
|
612
|
+
// wired under `Stop`. The v2 hook does not gate on event type, so it
|
|
613
|
+
// fires every assistant turn and writes N `session_summary` rows in
|
|
614
|
+
// `memory_items` per session (Brad's 2026-05-04 jizzard-brain repro).
|
|
615
|
+
//
|
|
616
|
+
// `_mergeSessionEndHookEntry` is a 1:1 hoist of the same-named function
|
|
617
|
+
// in `packages/stack-installer/src/index.js:451`. We can't `require()`
|
|
618
|
+
// across to it because the published `@jhizzard/termdeck` tarball ships
|
|
619
|
+
// only `packages/stack-installer/assets/hooks/**`, not `.../src/**` —
|
|
620
|
+
// the settings.json migration logic is unreachable at runtime from the
|
|
621
|
+
// wizard's own tarball. Hoisting is correct here; the function is pure
|
|
622
|
+
// (~50 LOC, no I/O), the upstream is exhaustively covered by
|
|
623
|
+
// `tests/stack-installer-hook-merge.test.js`, and a duplicate copy
|
|
624
|
+
// avoids cross-package version-coupling.
|
|
625
|
+
//
|
|
626
|
+
// Why we run this on every wizard pass, not just first install: the
|
|
627
|
+
// whole point is to self-heal old Stop wirings on upgrade. Idempotent —
|
|
628
|
+
// if SessionEnd is already correct, this no-ops and prints
|
|
629
|
+
// `already wired`. Brad's framing: "settings.json invariants the wizard
|
|
630
|
+
// must enforce" (INSTALLER-PITFALLS.md ledger #16). The wizard is the
|
|
631
|
+
// canonical place to enforce them because it's the only path users
|
|
632
|
+
// actually run after upgrading the package.
|
|
633
|
+
|
|
634
|
+
const SETTINGS_JSON_PATH = path.join(require('os').homedir(), '.claude', 'settings.json');
|
|
635
|
+
const HOOK_COMMAND = 'node ~/.claude/hooks/memory-session-end.js';
|
|
636
|
+
const HOOK_TIMEOUT_SECONDS = 30;
|
|
637
|
+
|
|
638
|
+
function _isSessionEndHookEntry(entry) {
|
|
639
|
+
return entry && typeof entry.command === 'string'
|
|
640
|
+
&& entry.command.includes('memory-session-end.js');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Pure: merges our SessionEnd entry into the given settings object.
|
|
644
|
+
// Idempotent. Returns { settings, status } where status is one of
|
|
645
|
+
// 'already-installed', 'installed', or 'migrated-from-stop'. Mutates
|
|
646
|
+
// the input. Mirrors `_mergeSessionEndHookEntry` in
|
|
647
|
+
// `packages/stack-installer/src/index.js:451` byte-for-byte (modulo
|
|
648
|
+
// constants pulled from this file's scope).
|
|
649
|
+
function _mergeSessionEndHookEntry(settings, opts = {}) {
|
|
650
|
+
const command = opts.command || HOOK_COMMAND;
|
|
651
|
+
const timeout = opts.timeout != null ? opts.timeout : HOOK_TIMEOUT_SECONDS;
|
|
652
|
+
const entry = { type: 'command', command, timeout };
|
|
653
|
+
|
|
654
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
|
|
655
|
+
|
|
656
|
+
// Migrate any pre-Sprint-48 Stop registration of OUR hook to SessionEnd.
|
|
657
|
+
// Only entries matching `_isSessionEndHookEntry` are touched — any
|
|
658
|
+
// unrelated Stop hooks the user has are preserved verbatim.
|
|
659
|
+
let migrated = false;
|
|
660
|
+
if (Array.isArray(settings.hooks.Stop)) {
|
|
661
|
+
for (const group of settings.hooks.Stop) {
|
|
662
|
+
if (!group || !Array.isArray(group.hooks)) continue;
|
|
663
|
+
const before = group.hooks.length;
|
|
664
|
+
group.hooks = group.hooks.filter((e) => !_isSessionEndHookEntry(e));
|
|
665
|
+
if (group.hooks.length !== before) migrated = true;
|
|
666
|
+
}
|
|
667
|
+
settings.hooks.Stop = settings.hooks.Stop.filter(
|
|
668
|
+
(g) => g && Array.isArray(g.hooks) && g.hooks.length > 0
|
|
669
|
+
);
|
|
670
|
+
if (settings.hooks.Stop.length === 0) delete settings.hooks.Stop;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (!Array.isArray(settings.hooks.SessionEnd)) settings.hooks.SessionEnd = [];
|
|
674
|
+
|
|
675
|
+
for (const group of settings.hooks.SessionEnd) {
|
|
676
|
+
if (!group || !Array.isArray(group.hooks)) continue;
|
|
677
|
+
if (group.hooks.some(_isSessionEndHookEntry)) {
|
|
678
|
+
return { settings, status: migrated ? 'migrated-from-stop' : 'already-installed' };
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const emptyMatcher = settings.hooks.SessionEnd.find(
|
|
683
|
+
(g) => g && g.matcher === '' && Array.isArray(g.hooks)
|
|
684
|
+
);
|
|
685
|
+
if (emptyMatcher) {
|
|
686
|
+
emptyMatcher.hooks.push(entry);
|
|
687
|
+
} else {
|
|
688
|
+
settings.hooks.SessionEnd.push({ matcher: '', hooks: [entry] });
|
|
689
|
+
}
|
|
690
|
+
return { settings, status: migrated ? 'migrated-from-stop' : 'installed' };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function _readSettingsJson(filePath) {
|
|
694
|
+
if (!fs.existsSync(filePath)) {
|
|
695
|
+
return { settings: {}, status: 'no-file' };
|
|
696
|
+
}
|
|
697
|
+
try {
|
|
698
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
699
|
+
if (raw.trim() === '') return { settings: {}, status: 'empty' };
|
|
700
|
+
const parsed = JSON.parse(raw);
|
|
701
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
702
|
+
return { settings: {}, status: 'malformed', error: 'top-level must be an object' };
|
|
703
|
+
}
|
|
704
|
+
return { settings: parsed, status: 'ok' };
|
|
705
|
+
} catch (e) {
|
|
706
|
+
return { settings: {}, status: 'malformed', error: e.message };
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function _writeSettingsJson(filePath, settings) {
|
|
711
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
712
|
+
const tmp = filePath + '.tmp';
|
|
713
|
+
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
|
|
714
|
+
fs.renameSync(tmp, filePath);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Apply the Stop→SessionEnd migration to ~/.claude/settings.json (or the
|
|
718
|
+
// path passed via opts.settingsPath, used by tests). Idempotent on
|
|
719
|
+
// already-migrated installs. Best-effort backup before write —
|
|
720
|
+
// timestamped `.bak.<YYYYMMDDhhmmss>` matching the convention used by
|
|
721
|
+
// `refreshBundledHookIfNewer`. Returns a structured status the caller
|
|
722
|
+
// can pretty-print.
|
|
723
|
+
function migrateSettingsJsonHookEntry(opts = {}) {
|
|
724
|
+
const dryRun = !!opts.dryRun;
|
|
725
|
+
const settingsPath = opts.settingsPath || SETTINGS_JSON_PATH;
|
|
726
|
+
|
|
727
|
+
const read = _readSettingsJson(settingsPath);
|
|
728
|
+
if (read.status === 'malformed') {
|
|
729
|
+
return { status: 'malformed', error: read.error, settingsPath };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Snapshot pre-merge JSON so we can detect "no actual change" even
|
|
733
|
+
// when status == 'installed' (e.g. a fresh user with no hook keys at
|
|
734
|
+
// all — we'd add one, which IS a change).
|
|
735
|
+
const before = JSON.stringify(read.settings);
|
|
736
|
+
const merge = _mergeSessionEndHookEntry(read.settings);
|
|
737
|
+
const after = JSON.stringify(merge.settings);
|
|
738
|
+
const noChange = before === after;
|
|
739
|
+
|
|
740
|
+
if (merge.status === 'already-installed' || noChange) {
|
|
741
|
+
return { status: 'already-installed', settingsPath };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (dryRun) {
|
|
745
|
+
return { status: 'would-' + merge.status, settingsPath };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Best-effort backup before write — only when a settings.json
|
|
749
|
+
// existed; brand-new install (no-file → write) doesn't need a backup.
|
|
750
|
+
let backup = null;
|
|
751
|
+
if (read.status === 'ok' || read.status === 'empty') {
|
|
752
|
+
const stamp = new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14);
|
|
753
|
+
backup = `${settingsPath}.bak.${stamp}`;
|
|
754
|
+
try { fs.copyFileSync(settingsPath, backup); }
|
|
755
|
+
catch (_) { backup = null; /* best-effort */ }
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
_writeSettingsJson(settingsPath, merge.settings);
|
|
759
|
+
return { status: merge.status, settingsPath, backup };
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function runSettingsJsonMigration({ dryRun = false } = {}) {
|
|
763
|
+
const debug = !!process.env.TERMDECK_DEBUG_WIREUP;
|
|
764
|
+
step('Reconciling ~/.claude/settings.json hook event mapping (Stop → SessionEnd)...');
|
|
765
|
+
if (debug) {
|
|
766
|
+
process.stderr.write(`[wire-up-debug] runSettingsJsonMigration entry: dryRun=${dryRun} SETTINGS_JSON_PATH=${SETTINGS_JSON_PATH} exists=${fs.existsSync(SETTINGS_JSON_PATH)}\n`);
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
const r = migrateSettingsJsonHookEntry({ dryRun });
|
|
770
|
+
if (debug) process.stderr.write(`[wire-up-debug] runSettingsJsonMigration return: ${JSON.stringify(r)}\n`);
|
|
771
|
+
if (r.status === 'already-installed') {
|
|
772
|
+
ok('already wired (SessionEnd)');
|
|
773
|
+
} else if (r.status === 'installed') {
|
|
774
|
+
ok(r.backup ? `installed (SessionEnd; backup: ${path.basename(r.backup)})` : 'installed (SessionEnd)');
|
|
775
|
+
} else if (r.status === 'migrated-from-stop') {
|
|
776
|
+
ok(r.backup ? `migrated Stop → SessionEnd (was firing on every turn; backup: ${path.basename(r.backup)})` : 'migrated Stop → SessionEnd (was firing on every turn)');
|
|
777
|
+
} else if (r.status === 'would-installed') {
|
|
778
|
+
ok('would install (SessionEnd) (dry-run)');
|
|
779
|
+
} else if (r.status === 'would-migrated-from-stop') {
|
|
780
|
+
ok('would migrate Stop → SessionEnd (dry-run)');
|
|
781
|
+
} else if (r.status === 'malformed') {
|
|
782
|
+
ok(`(skipped: settings.json malformed: ${r.error})`);
|
|
783
|
+
} else {
|
|
784
|
+
ok(`(${r.status})`);
|
|
785
|
+
}
|
|
786
|
+
} catch (err) {
|
|
787
|
+
// Don't abort init for a settings-migration failure — log + continue.
|
|
788
|
+
// Same fail-soft posture as runHookRefresh: the user's wizard goal
|
|
789
|
+
// (DB setup) is independent of settings.json wiring; if we can't
|
|
790
|
+
// write to settings.json (e.g. permission denied), the wizard
|
|
791
|
+
// should still finish the DB work.
|
|
792
|
+
process.stdout.write(` ! settings.json migration failed: ${err.message} (continuing)\n`);
|
|
793
|
+
if (debug) process.stderr.write(`[wire-up-debug] runSettingsJsonMigration threw: ${err && err.stack || err}\n`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function runHookRefresh({ dryRun = false } = {}) {
|
|
798
|
+
const debug = !!process.env.TERMDECK_DEBUG_WIREUP;
|
|
799
|
+
step('Refreshing ~/.claude/hooks/memory-session-end.js (if bundled is newer)...');
|
|
800
|
+
if (debug) {
|
|
801
|
+
const HOME = require('os').homedir();
|
|
802
|
+
const HOOK_DEST = path.join(HOME, '.claude', 'hooks', 'memory-session-end.js');
|
|
803
|
+
const HOOK_SOURCE = path.join(__dirname, '..', '..', 'stack-installer', 'assets', 'hooks', 'memory-session-end.js');
|
|
804
|
+
process.stderr.write(`[wire-up-debug] runHookRefresh entry: dryRun=${dryRun} HOOK_DEST=${HOOK_DEST} HOOK_SOURCE=${HOOK_SOURCE} HOOK_SOURCE_exists=${fs.existsSync(HOOK_SOURCE)} HOOK_DEST_exists=${fs.existsSync(HOOK_DEST)}\n`);
|
|
805
|
+
}
|
|
806
|
+
try {
|
|
807
|
+
const r = refreshBundledHookIfNewer({ dryRun });
|
|
808
|
+
if (debug) process.stderr.write(`[wire-up-debug] runHookRefresh return: ${JSON.stringify(r)}\n`);
|
|
809
|
+
if (r.status === 'refreshed') {
|
|
810
|
+
ok(`refreshed v${r.from ?? 0} → v${r.to} (backup: ${path.basename(r.backup)})`);
|
|
811
|
+
} else if (r.status === 'would-refresh') {
|
|
812
|
+
ok(`would-refresh v${r.from ?? 0} → v${r.to} (dry-run)`);
|
|
813
|
+
} else if (r.status === 'installed') {
|
|
814
|
+
ok(`installed v${r.bundled} (no prior copy)`);
|
|
815
|
+
} else if (r.status === 'would-install') {
|
|
816
|
+
ok(`would-install v${r.bundled} (dry-run, no prior copy)`);
|
|
817
|
+
} else if (r.status === 'up-to-date') {
|
|
818
|
+
ok(`up-to-date (v${r.installed})`);
|
|
819
|
+
} else {
|
|
820
|
+
ok(`(${r.status}${r.message ? ': ' + r.message : ''})`);
|
|
821
|
+
}
|
|
822
|
+
} catch (err) {
|
|
823
|
+
// Don't abort init for a hook-refresh failure — log + continue. The
|
|
824
|
+
// user's wizard goal (DB setup) is independent of hook refresh; even
|
|
825
|
+
// if refresh fails (e.g. permission denied, FS error), the wizard
|
|
826
|
+
// should continue to do the DB work.
|
|
827
|
+
process.stdout.write(` ! hook refresh failed: ${err.message} (continuing)\n`);
|
|
828
|
+
if (debug) process.stderr.write(`[wire-up-debug] runHookRefresh threw: ${err && err.stack || err}\n`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
570
832
|
function printNextSteps() {
|
|
571
833
|
process.stdout.write(`
|
|
572
834
|
Mnestra is configured.
|
|
@@ -637,6 +899,29 @@ async function main(argv) {
|
|
|
637
899
|
return 6;
|
|
638
900
|
}
|
|
639
901
|
|
|
902
|
+
// Sprint 51.7 T1 — refresh ~/.claude/hooks/memory-session-end.js BEFORE the
|
|
903
|
+
// DB phase. Hook refresh is local FS work; coupling it downstream of pg
|
|
904
|
+
// connect + 17-migration replay (the old wire-up at line 677 in v1.0.2)
|
|
905
|
+
// meant ANY DB-side error (Joshua's mig-001 `match_memories` return-type
|
|
906
|
+
// drift, Brad's same on jizzard-brain) silently skipped the upgrade. With
|
|
907
|
+
// refresh here, the user always lands the bundled hook even when the DB
|
|
908
|
+
// phase later fails — decoupled concerns, idempotent re-runs, and the
|
|
909
|
+
// helper handles its own try/catch internally so a refresh failure never
|
|
910
|
+
// strands the wizard.
|
|
911
|
+
runHookRefresh({ dryRun: flags.dryRun });
|
|
912
|
+
|
|
913
|
+
// Sprint 51.8 — reconcile ~/.claude/settings.json wiring. Sprint 51.7
|
|
914
|
+
// landed the v2 hook FILE on disk but never moved the event mapping
|
|
915
|
+
// from `Stop` to `SessionEnd` for users whose settings.json was
|
|
916
|
+
// written by `@jhizzard/termdeck-stack@<=0.5.0`. The v2 hook does not
|
|
917
|
+
// gate on event type, so a Stop wiring fires on every assistant turn
|
|
918
|
+
// and writes N session_summary rows in memory_items per session. This
|
|
919
|
+
// migration is idempotent and runs alongside the file refresh so the
|
|
920
|
+
// wire-up + wiring stay in lockstep on every wizard pass. Brad's
|
|
921
|
+
// 2026-05-04 jizzard-brain repro is the canonical fixture for this
|
|
922
|
+
// class of bug (INSTALLER-PITFALLS.md ledger #16).
|
|
923
|
+
runSettingsJsonMigration({ dryRun: flags.dryRun });
|
|
924
|
+
|
|
640
925
|
step('Connecting to Supabase...');
|
|
641
926
|
if (flags.dryRun) {
|
|
642
927
|
ok('(dry-run, skipped)');
|
|
@@ -664,27 +949,12 @@ async function main(argv) {
|
|
|
664
949
|
await applyMigrations(client, false);
|
|
665
950
|
await runMnestraAudit(client, inputs.projectUrl.projectRef, false);
|
|
666
951
|
writeYamlConfig(false);
|
|
667
|
-
// Sprint 51.
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
// the
|
|
671
|
-
//
|
|
672
|
-
|
|
673
|
-
try {
|
|
674
|
-
const r = refreshBundledHookIfNewer({ dryRun: false });
|
|
675
|
-
if (r.status === 'refreshed') {
|
|
676
|
-
ok(`refreshed v${r.from ?? 0} → v${r.to} (backup: ${path.basename(r.backup)})`);
|
|
677
|
-
} else if (r.status === 'installed') {
|
|
678
|
-
ok(`installed v${r.bundled} (no prior copy)`);
|
|
679
|
-
} else if (r.status === 'up-to-date') {
|
|
680
|
-
ok(`up-to-date (v${r.installed})`);
|
|
681
|
-
} else {
|
|
682
|
-
ok(`(${r.status}${r.message ? ': ' + r.message : ''})`);
|
|
683
|
-
}
|
|
684
|
-
} catch (err) {
|
|
685
|
-
// Don't abort init for a hook-refresh failure — log + continue.
|
|
686
|
-
process.stdout.write(` ! hook refresh failed: ${err.message} (continuing)\n`);
|
|
687
|
-
}
|
|
952
|
+
// Sprint 51.7 T1: hook refresh moved upstream — see runHookRefresh()
|
|
953
|
+
// call site near writeSecretsFile. The old wire-up here was reachable
|
|
954
|
+
// only when every DB step succeeded, which Sprint 51.6 Phase B proved
|
|
955
|
+
// was the bug (mig-001 `match_memories` return-type drift threw and
|
|
956
|
+
// stranded the upgrade for both Joshua and Brad).
|
|
957
|
+
|
|
688
958
|
// v0.6.9: post-write outcome verification. Confirms each migration's
|
|
689
959
|
// expected schema bits actually landed — including memory_items.
|
|
690
960
|
// source_session_id (the v0.6.5 column whose absence cascaded into
|
|
@@ -732,3 +1002,10 @@ if (require.main === module) {
|
|
|
732
1002
|
module.exports = main;
|
|
733
1003
|
// Sprint 51.6 T3 — exported for tests/init-mnestra-hook-refresh.test.js.
|
|
734
1004
|
module.exports.refreshBundledHookIfNewer = refreshBundledHookIfNewer;
|
|
1005
|
+
// Sprint 51.8 — exported for tests/init-mnestra-settings-migration.test.js.
|
|
1006
|
+
module.exports.migrateSettingsJsonHookEntry = migrateSettingsJsonHookEntry;
|
|
1007
|
+
module.exports._mergeSessionEndHookEntry = _mergeSessionEndHookEntry;
|
|
1008
|
+
module.exports._isSessionEndHookEntry = _isSessionEndHookEntry;
|
|
1009
|
+
module.exports.SETTINGS_JSON_PATH = SETTINGS_JSON_PATH;
|
|
1010
|
+
module.exports.HOOK_COMMAND = HOOK_COMMAND;
|
|
1011
|
+
module.exports.HOOK_TIMEOUT_SECONDS = HOOK_TIMEOUT_SECONDS;
|
|
@@ -51,7 +51,17 @@
|
|
|
51
51
|
* a new transcript parser, a default PROJECT_MAP change. Comment-only
|
|
52
52
|
* tweaks do not need a bump.
|
|
53
53
|
*
|
|
54
|
-
*
|
|
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
|
|
55
65
|
*
|
|
56
66
|
* Required env vars (validated at entry, after the secrets.env fallback):
|
|
57
67
|
* - SUPABASE_URL e.g. https://<project-ref>.supabase.co
|
|
@@ -440,11 +450,117 @@ function selectTranscriptParser(sessionType) {
|
|
|
440
450
|
return { parser: parseAutoDetect, sessionType: 'auto' };
|
|
441
451
|
}
|
|
442
452
|
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
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).
|
|
448
564
|
function buildSummary(transcriptPath, sessionType) {
|
|
449
565
|
let raw;
|
|
450
566
|
try { raw = readFileSync(transcriptPath, 'utf8'); }
|
|
@@ -468,7 +584,18 @@ function buildSummary(transcriptPath, sessionType) {
|
|
|
468
584
|
tail.map((m) => `[${m.role}] ${m.content}`).join('\n');
|
|
469
585
|
// OpenAI text-embedding-3-small accepts up to 8192 tokens (~32K chars).
|
|
470
586
|
// 7000 chars is a safe headroom that survives multibyte expansion.
|
|
471
|
-
|
|
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
|
+
};
|
|
472
599
|
}
|
|
473
600
|
|
|
474
601
|
async function embedText(text, openaiKey) {
|
|
@@ -569,7 +696,15 @@ async function postMemorySession({
|
|
|
569
696
|
summary, summaryEmbedding,
|
|
570
697
|
project, sessionId,
|
|
571
698
|
transcriptPath, messagesCount,
|
|
572
|
-
endedAt
|
|
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,
|
|
573
708
|
}) {
|
|
574
709
|
if (!sessionId) {
|
|
575
710
|
log('memory-sessions-skip: sessionId missing — cannot satisfy session_id NOT NULL/UNIQUE.');
|
|
@@ -595,14 +730,16 @@ async function postMemorySession({
|
|
|
595
730
|
? `[${summaryEmbedding.join(',')}]`
|
|
596
731
|
: null,
|
|
597
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,
|
|
598
738
|
ended_at: (endedAt instanceof Date ? endedAt : new Date()).toISOString(),
|
|
739
|
+
duration_minutes: typeof durationMinutes === 'number' ? durationMinutes : null,
|
|
599
740
|
messages_count: typeof messagesCount === 'number' ? messagesCount : 0,
|
|
741
|
+
facts_extracted: typeof factsExtracted === 'number' ? factsExtracted : 0,
|
|
600
742
|
transcript_path: transcriptPath || null,
|
|
601
|
-
// started_at, duration_minutes, facts_extracted, files_changed, topics
|
|
602
|
-
// intentionally omitted — column defaults apply on petvetbid; nullable
|
|
603
|
-
// on canonical (post-mig-017). Future sprint may parse per-message
|
|
604
|
-
// timestamps to derive started_at + duration; v1.0.2 ships the
|
|
605
|
-
// minimum viable row.
|
|
606
743
|
}),
|
|
607
744
|
});
|
|
608
745
|
if (!res.ok) {
|
|
@@ -668,7 +805,14 @@ async function processStdinPayload(input) {
|
|
|
668
805
|
|
|
669
806
|
const built = buildSummary(transcriptPath, sessionType);
|
|
670
807
|
if (!built) return;
|
|
671
|
-
const {
|
|
808
|
+
const {
|
|
809
|
+
summary,
|
|
810
|
+
messagesCount,
|
|
811
|
+
startedAt: parsedStartedAt,
|
|
812
|
+
endedAt: parsedEndedAt,
|
|
813
|
+
durationMinutes,
|
|
814
|
+
factsExtracted,
|
|
815
|
+
} = built;
|
|
672
816
|
|
|
673
817
|
const embedding = await embedText(summary, env.openaiKey);
|
|
674
818
|
if (!embedding) return;
|
|
@@ -686,6 +830,13 @@ async function processStdinPayload(input) {
|
|
|
686
830
|
// Sprint 51.6 T3: companion memory_sessions write. Independent of the
|
|
687
831
|
// memory_items write — a memory_items failure shouldn't suppress the
|
|
688
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.
|
|
689
840
|
const sessionOk = await postMemorySession({
|
|
690
841
|
supabaseUrl: env.supabaseUrl,
|
|
691
842
|
supabaseKey: env.supabaseKey,
|
|
@@ -695,11 +846,14 @@ async function processStdinPayload(input) {
|
|
|
695
846
|
sessionId,
|
|
696
847
|
transcriptPath,
|
|
697
848
|
messagesCount,
|
|
698
|
-
endedAt: new Date(),
|
|
849
|
+
endedAt: parsedEndedAt ? new Date(parsedEndedAt) : new Date(),
|
|
850
|
+
startedAt: parsedStartedAt,
|
|
851
|
+
durationMinutes,
|
|
852
|
+
factsExtracted,
|
|
699
853
|
});
|
|
700
854
|
|
|
701
855
|
if (itemOk || sessionOk) {
|
|
702
|
-
log(`ingested: project="${project}" session=${sessionId} bytes=${summary.length} messages=${messagesCount} sessionType=${sessionType} sourceAgent=${normalizeSourceAgent(sourceAgent)} memory_items=${itemOk ? 'ok' : 'fail'} memory_sessions=${sessionOk ? 'ok' : 'fail'}`);
|
|
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'}`);
|
|
703
857
|
}
|
|
704
858
|
}
|
|
705
859
|
|
|
@@ -736,5 +890,9 @@ if (require.main === module) {
|
|
|
736
890
|
// Sprint 50 T2 — source_agent provenance plumbing.
|
|
737
891
|
normalizeSourceAgent,
|
|
738
892
|
ALLOWED_SOURCE_AGENTS,
|
|
893
|
+
// Sprint 51.7 T2 — transcript-metadata extractor for memory_sessions.
|
|
894
|
+
parseTranscriptMetadata,
|
|
895
|
+
FACT_TOOL_NAMES,
|
|
896
|
+
extractContentBlocks,
|
|
739
897
|
};
|
|
740
898
|
}
|