@claude-transcripts/cli 0.4.0 → 0.6.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.
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- // packages/shared/src/model/cli-project.ts
2
+ // packages/shared/src/blueprint/cli-project.ts
3
3
  var isFlag = (a) => a.name.startsWith("--");
4
4
  function cliArgType(a) {
5
5
  if (a.type)
@@ -380,7 +380,7 @@ ${lines.join(`
380
380
  `;
381
381
  }
382
382
 
383
- // packages/shared/src/model/cli.ts
383
+ // packages/shared/src/blueprint/cli.ts
384
384
  var WEBAPI = {
385
385
  name: "--webapi",
386
386
  type: "string",
@@ -717,470 +717,61 @@ var CLI_SPEC = {
717
717
  ]
718
718
  };
719
719
 
720
- // packages/shared/src/couch-url.ts
721
- var DEFAULT_COUCHDB_HOST = "127.0.0.1";
722
- var DEFAULT_COUCHDB_PORT = "7652";
723
- function trimTrailingSlash(url) {
724
- return url.replace(/\/+$/, "");
725
- }
726
- function resolveCouchUrl(env = {}) {
727
- const explicit = env.COUCHDB_URL?.trim();
728
- if (explicit) {
729
- const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(explicit) ? explicit : `http://${explicit}`;
730
- return trimTrailingSlash(withScheme);
731
- }
732
- const host = env.COUCHDB_HOST || DEFAULT_COUCHDB_HOST;
733
- const port = env.COUCHDB_PORT || DEFAULT_COUCHDB_PORT;
734
- return `http://${host}:${port}`;
735
- }
736
- // packages/shared/src/highlight.ts
737
- var HIGHLIGHT_PRE = "\uE000";
738
- var HIGHLIGHT_POST = "\uE001";
739
- function stripHighlightMarks(marked) {
740
- return marked.replaceAll(HIGHLIGHT_PRE, "").replaceAll(HIGHLIGHT_POST, "");
741
- }
742
- // packages/shared/src/migrations/designs.ts
743
- var CHUNK_ENTRIES_MAP = `function (doc) {
744
- if (doc.type !== "chunk" || !doc.entries) return;
745
- for (var i = 0; i < doc.entries.length; i++) {
746
- var e = doc.entries[i];
747
- if (!e || !e.role) continue;
748
- emit([doc.session_id, doc.byte_start, i], {
749
- role: e.role,
750
- timestamp: e.timestamp || "",
751
- text: e.text || "",
752
- toolUses: e.toolUses || null,
753
- toolUseId: e.toolUseId || null,
754
- isError: e.isError || false,
755
- isSidechain: e.isSidechain || false,
756
- kind: e.kind || null
757
- });
758
- }
759
- }`;
760
- var CHUNKS_DESIGN = {
761
- _id: "_design/chunks",
762
- language: "javascript",
763
- views: {
764
- by_session: {
765
- map: 'function(doc){if(doc.type==="chunk"){emit([doc.session_id,doc.byte_start],{byte_start:doc.byte_start,byte_end:doc.byte_end,entry_count:doc.entry_count});}}'
766
- },
767
- entry_count_by_session: {
768
- map: 'function(doc){if(doc.type==="chunk"){emit(doc.session_id,doc.entry_count||0);}}',
769
- reduce: "_sum"
770
- },
771
- entries_by_session: { map: CHUNK_ENTRIES_MAP, reduce: "_count" }
772
- }
773
- };
774
- var INITIAL_DESIGNS = [
720
+ // packages/shared/src/blueprint/actions.ts
721
+ var ACTIONS = [
775
722
  {
776
- _id: "_design/sessions",
777
- language: "javascript",
778
- views: {
779
- by_date: {
780
- map: 'function(doc){if(doc.type==="summary"){var t=doc.timestamp.split(/[-T:]/);emit([parseInt(t[0]),parseInt(t[1]),parseInt(t[2])],{session_id:doc.session_id,event_count:doc.event_count,prompt_count:doc.prompt_count,error_count:doc.error_count,cwd:doc.cwd});}}',
781
- reduce: "_count"
782
- },
783
- by_cwd: {
784
- map: 'function(doc){if(doc.type==="summary"){emit([doc.cwd,doc.timestamp],{session_id:doc.session_id,event_count:doc.event_count,prompt_count:doc.prompt_count});}}',
785
- reduce: "_count"
786
- }
787
- }
723
+ key: "write-event-marker",
724
+ summary: "Append a light append-only event marker doc",
725
+ implemented: true
788
726
  },
789
727
  {
790
- _id: "_design/events",
791
- language: "javascript",
792
- views: {
793
- by_session: {
794
- map: 'function(doc){if(doc.type==="event"){emit([doc.session_id,doc.timestamp],{event:doc.event,tool_name:doc.tool_name||null,input_preview:doc.input_preview||doc.prompt_preview||doc.error_preview||null});}}'
795
- },
796
- by_type: {
797
- map: 'function(doc){if(doc.type==="event"){var t=doc.timestamp.split(/[-T:]/);emit([doc.event,parseInt(t[0]),parseInt(t[1]),parseInt(t[2])],1);}}',
798
- reduce: "_count"
799
- }
800
- }
728
+ key: "update-counts",
729
+ summary: "Bump per-session counters (events/prompts/errors/tools)",
730
+ implemented: true
801
731
  },
802
732
  {
803
- _id: "_design/tools",
804
- language: "javascript",
805
- views: {
806
- usage: {
807
- map: 'function(doc){if(doc.type==="event"&&doc.tool_name){var t=doc.timestamp.split(/[-T:]/);emit([doc.tool_name,parseInt(t[0]),parseInt(t[1]),parseInt(t[2])],1);}}',
808
- reduce: "_count"
809
- },
810
- failures: {
811
- map: 'function(doc){if(doc.event==="PostToolUseFailure"){emit([doc.tool_name,doc.timestamp],{session_id:doc.session_id,error_preview:doc.error_preview,cwd:doc.cwd});}}'
812
- },
813
- errors: {
814
- map: 'function(doc){if(doc.event==="PostToolUseFailure"||doc.error){emit([doc.tool_name||"unknown",doc.timestamp],{session_id:doc.session_id,error_preview:doc.error_preview||doc.error,cwd:doc.cwd});}}'
815
- }
816
- }
733
+ key: "flush-transcript-chunk",
734
+ summary: "Tail the transcript \u2192 append chunk docs",
735
+ implemented: true
817
736
  },
737
+ { key: "write-summary", summary: "Write summary:<id> at session end", implemented: true },
818
738
  {
819
- _id: "_design/activity",
820
- language: "javascript",
821
- views: {
822
- timeline: {
823
- map: 'function(doc){if(doc.type==="event"){var t=doc.timestamp.split(/[-T:]/);emit([parseInt(t[0]),parseInt(t[1]),parseInt(t[2]),parseInt(t[3])],1);}}',
824
- reduce: "_count"
825
- }
826
- }
739
+ key: "upload-blobs",
740
+ summary: "Upload summary.json + transcript.jsonl to S3",
741
+ implemented: true
827
742
  },
828
- CHUNKS_DESIGN,
829
743
  {
830
- _id: "_design/session_meta",
831
- language: "javascript",
832
- views: {
833
- start_meta: {
834
- map: 'function(doc){if(doc.event==="SessionStart"){emit(doc.session_id,{timestamp:doc.timestamp,model:doc.model||"",cwd:doc.cwd||"",hostname:doc.hostname||""});}}'
835
- },
836
- tokens_by_date: {
837
- map: 'function(doc){if(doc.type==="summary"&&doc.token_usage){var t=doc.timestamp.split(/[-T:]/);var u=doc.token_usage;emit([parseInt(t[0]),parseInt(t[1]),parseInt(t[2])],{input:u.input||0,output:u.output||0,cacheCreation:u.cacheCreation||0,cacheRead:u.cacheRead||0,total:u.total||0,sessions:1});}}',
838
- reduce: "_sum"
839
- }
840
- }
841
- }
842
- ];
843
- // packages/shared/src/migrations/session-index.ts
844
- var AGGREGATE_MAP = `function (doc) {
845
- if (!doc.session_id) return;
846
- // A \`chunk\` doc contributes transcript coverage ONLY \u2014 no timestamps, no counts \u2014
847
- // so every other metric keeps the exact meaning it had before chunks were indexed.
848
- // \`chunkEntries\` counts turns the chunk view can actually serve (byte-range-only
849
- // chunks carry no \`entries[]\`), while \`chunkBytes\` tracks raw byte coverage.
850
- if (doc.type === "chunk") {
851
- emit(doc.session_id, {
852
- ended: 0, events: 0, prompts: 0, errors: 0, started: 0, tools: {},
853
- first: "", last: "", model: "", cwd: "", hostname: "",
854
- modelAt: "", cwdAt: "", hostnameAt: "", summary: null,
855
- chunkEntries: doc.entries ? doc.entries.length : 0,
856
- chunkBytes: doc.byte_end || 0
857
- });
858
- return;
859
- }
860
- if (doc.type !== "event" && doc.type !== "summary") return;
861
- var isSummary = doc.type === "summary";
862
- var ts = doc.timestamp || "";
863
- var model = (doc.event === "SessionStart" || isSummary) ? (doc.model || "") : "";
864
- var tools = {};
865
- if (doc.event === "PostToolUse" && doc.tool_name) tools[doc.tool_name] = 1;
866
- emit(doc.session_id, {
867
- ended: isSummary ? 1 : 0,
868
- events: doc.type === "event" ? 1 : 0,
869
- prompts: doc.event === "UserPromptSubmit" ? 1 : 0,
870
- errors: doc.event === "PostToolUseFailure" ? 1 : 0,
871
- started: doc.event === "SessionStart" ? 1 : 0,
872
- tools: tools,
873
- first: ts,
874
- last: ts,
875
- model: model,
876
- cwd: doc.cwd || "",
877
- hostname: doc.hostname || "",
878
- modelAt: model ? ts : "",
879
- cwdAt: doc.cwd ? ts : "",
880
- hostnameAt: doc.hostname ? ts : "",
881
- summary: isSummary ? {
882
- event_count: doc.event_count || 0,
883
- prompt_count: doc.prompt_count || 0,
884
- error_count: doc.error_count || 0,
885
- tool_counts: doc.tool_counts || {},
886
- end_reason: doc.end_reason || "",
887
- transcript_bytes: doc.transcript_bytes || 0,
888
- token_usage: doc.token_usage || null,
889
- timestamp: doc.timestamp || "",
890
- source: doc.source || ""
891
- } : null,
892
- chunkEntries: 0,
893
- chunkBytes: 0
894
- });
895
- }`;
896
- var AGGREGATE_REDUCE = `function (keys, values, rereduce) {
897
- var acc = { ended:0, events:0, prompts:0, errors:0, started:0, tools:{}, first:"", last:"", model:"", cwd:"", hostname:"", modelAt:"", cwdAt:"", hostnameAt:"", summary:null, chunkEntries:0, chunkBytes:0 };
898
- // Keep the earliest-stamped value; an unstamped one sorts last, ties go to the smaller value.
899
- function earliest(v, f) {
900
- var val = v[f];
901
- if (!val) return;
902
- var at = v[f + "At"] || "", a = at || "\\uffff", b = acc[f + "At"] || "\\uffff";
903
- if (acc[f] === "" || a < b || (a === b && val < acc[f])) { acc[f] = val; acc[f + "At"] = at; }
904
- }
905
- for (var i = 0; i < values.length; i++) {
906
- var v = values[i];
907
- if (!v) continue;
908
- acc.chunkEntries += v.chunkEntries || 0;
909
- if ((v.chunkBytes || 0) > acc.chunkBytes) acc.chunkBytes = v.chunkBytes;
910
- acc.ended += v.ended || 0;
911
- acc.events += v.events || 0;
912
- acc.prompts += v.prompts || 0;
913
- acc.errors += v.errors || 0;
914
- acc.started += v.started || 0;
915
- if (v.last && v.last > acc.last) acc.last = v.last;
916
- if (v.first && (acc.first === "" || v.first < acc.first)) acc.first = v.first;
917
- earliest(v, "model");
918
- earliest(v, "cwd");
919
- earliest(v, "hostname");
920
- // A session has one \`summary:<id>\` doc, so there is only ever one to keep.
921
- if (v.summary) acc.summary = v.summary;
922
- var t = v.tools || {};
923
- for (var k in t) { if (t.hasOwnProperty(k)) acc.tools[k] = (acc.tools[k] || 0) + t[k]; }
924
- }
925
- var names = Object.keys(acc.tools).sort(), tools = {};
926
- for (var j = 0; j < names.length; j++) tools[names[j]] = acc.tools[names[j]];
927
- acc.tools = tools;
928
- return acc;
929
- }`;
930
- var EVENT_TIMES_MAP = `function (doc) {
931
- if (!doc.session_id || !doc.timestamp) return;
932
- if (doc.type !== "event" && doc.type !== "summary") return;
933
- emit(doc.session_id, doc.timestamp);
934
- }`;
935
- var SESSION_INDEX_DESIGN = {
936
- _id: "_design/session_index",
937
- language: "javascript",
938
- views: {
939
- aggregate: { map: AGGREGATE_MAP, reduce: AGGREGATE_REDUCE },
940
- event_times: { map: EVENT_TIMES_MAP }
941
- }
942
- };
943
-
944
- // packages/shared/src/migrations/speaker-split.ts
945
- var BY_ROLE_MAP = `function (doc) {
946
- if (doc.type !== "chunk" || !doc.entries) return;
947
- for (var i = 0; i < doc.entries.length; i++) {
948
- var e = doc.entries[i];
949
- if (!e || !e.role) continue;
950
- emit([doc.session_id, e.role, doc.byte_start, i], {
951
- role: e.role,
952
- timestamp: e.timestamp || "",
953
- text: e.text || "",
954
- toolUses: e.toolUses || null,
955
- toolUseId: e.toolUseId || null,
956
- isError: e.isError || false
957
- });
958
- }
959
- }`;
960
- var BY_ROLE_TIME_MAP = `function (doc) {
961
- if (doc.type !== "chunk" || !doc.entries) return;
962
- for (var i = 0; i < doc.entries.length; i++) {
963
- var e = doc.entries[i];
964
- if (!e || !e.role) continue;
965
- emit([e.role, e.timestamp || "", doc.session_id, doc.byte_start, i], {
966
- sessionId: doc.session_id,
967
- cwd: doc.cwd || "",
968
- role: e.role,
969
- timestamp: e.timestamp || "",
970
- text: e.text || ""
971
- });
972
- }
973
- }`;
974
- var SPEAKER_SPLIT_DESIGN = {
975
- _id: "_design/speaker_split",
976
- language: "javascript",
977
- views: {
978
- by_role: { map: BY_ROLE_MAP, reduce: "_count" },
979
- by_role_time: { map: BY_ROLE_TIME_MAP, reduce: "_count" }
980
- }
981
- };
982
-
983
- // packages/shared/src/migrations/registry.ts
984
- var initialSchema = {
985
- id: 1,
986
- name: "initial-schema",
987
- async up(ctx) {
988
- for (const design of INITIAL_DESIGNS) {
989
- ctx.log(`+ ${design._id}`);
990
- const { _rev, ...body } = design;
991
- await ctx.putDoc(design._id, body);
992
- }
993
- },
994
- async down(ctx) {
995
- for (const design of INITIAL_DESIGNS) {
996
- ctx.log(`- ${design._id}`);
997
- await ctx.deleteDoc(design._id);
998
- }
999
- }
1000
- };
1001
- var sessionIndexView = {
1002
- id: 2,
1003
- name: "session-index-view",
1004
- async up(ctx) {
1005
- ctx.log(`+ ${SESSION_INDEX_DESIGN._id}`);
1006
- const { _rev, ...body } = SESSION_INDEX_DESIGN;
1007
- await ctx.putDoc(SESSION_INDEX_DESIGN._id, body);
1008
- },
1009
- async down(ctx) {
1010
- ctx.log(`- ${SESSION_INDEX_DESIGN._id}`);
1011
- await ctx.deleteDoc(SESSION_INDEX_DESIGN._id);
1012
- }
1013
- };
1014
- var sessionIndexSource = {
1015
- id: 3,
1016
- name: "session-index-source",
1017
- async up(ctx) {
1018
- ctx.log(`~ ${SESSION_INDEX_DESIGN._id} (emit summary.source)`);
1019
- const { _rev, ...body } = SESSION_INDEX_DESIGN;
1020
- await ctx.putDoc(SESSION_INDEX_DESIGN._id, body);
1021
- },
1022
- async down(ctx) {
1023
- ctx.log(`~ ${SESSION_INDEX_DESIGN._id} (source field left in place \u2014 additive)`);
1024
- }
1025
- };
1026
- var speakerSplitView = {
1027
- id: 4,
1028
- name: "speaker-split-view",
1029
- async up(ctx) {
1030
- ctx.log(`+ ${SPEAKER_SPLIT_DESIGN._id}`);
1031
- const { _rev, ...body } = SPEAKER_SPLIT_DESIGN;
1032
- await ctx.putDoc(SPEAKER_SPLIT_DESIGN._id, body);
1033
- },
1034
- async down(ctx) {
1035
- ctx.log(`- ${SPEAKER_SPLIT_DESIGN._id}`);
1036
- await ctx.deleteDoc(SPEAKER_SPLIT_DESIGN._id);
1037
- }
1038
- };
1039
- var speakerSplitTimeView = {
1040
- id: 5,
1041
- name: "speaker-split-time-view",
1042
- async up(ctx) {
1043
- ctx.log(`~ ${SPEAKER_SPLIT_DESIGN._id} (add by_role_time)`);
1044
- const { _rev, ...body } = SPEAKER_SPLIT_DESIGN;
1045
- await ctx.putDoc(SPEAKER_SPLIT_DESIGN._id, body);
1046
- },
1047
- async down(ctx) {
1048
- ctx.log(`~ ${SPEAKER_SPLIT_DESIGN._id} (by_role_time left in place \u2014 additive)`);
1049
- }
1050
- };
1051
- var chunkEntriesView = {
1052
- id: 6,
1053
- name: "chunk-entries-view",
1054
- async up(ctx) {
1055
- ctx.log(`~ ${CHUNKS_DESIGN._id} (add entries_by_session)`);
1056
- const { _rev, ...body } = CHUNKS_DESIGN;
1057
- await ctx.putDoc(CHUNKS_DESIGN._id, body);
1058
- },
1059
- async down(ctx) {
1060
- ctx.log(`~ ${CHUNKS_DESIGN._id} (entries_by_session left in place \u2014 additive)`);
1061
- }
1062
- };
1063
- var sessionIndexChunkCoverage = {
1064
- id: 7,
1065
- name: "session-index-chunk-coverage",
1066
- async up(ctx) {
1067
- ctx.log(`~ ${SESSION_INDEX_DESIGN._id} (emit chunk coverage)`);
1068
- const { _rev, ...body } = SESSION_INDEX_DESIGN;
1069
- await ctx.putDoc(SESSION_INDEX_DESIGN._id, body);
1070
- },
1071
- async down(ctx) {
1072
- ctx.log(`~ ${SESSION_INDEX_DESIGN._id} (chunk coverage left in place \u2014 additive)`);
1073
- }
1074
- };
1075
- var chunkEntryKind = {
1076
- id: 8,
1077
- name: "chunk-entry-kind",
1078
- async up(ctx) {
1079
- ctx.log(`~ ${CHUNKS_DESIGN._id} (emit entry kind)`);
1080
- const { _rev, ...body } = CHUNKS_DESIGN;
1081
- await ctx.putDoc(CHUNKS_DESIGN._id, body);
1082
- },
1083
- async down(ctx) {
1084
- ctx.log(`~ ${CHUNKS_DESIGN._id} (kind left in place \u2014 additive)`);
1085
- }
1086
- };
1087
- var sessionIndexEventTimes = {
1088
- id: 9,
1089
- name: "session-index-event-times",
1090
- async up(ctx) {
1091
- ctx.log(`~ ${SESSION_INDEX_DESIGN._id} (add event_times view)`);
1092
- const { _rev, ...body } = SESSION_INDEX_DESIGN;
1093
- await ctx.putDoc(SESSION_INDEX_DESIGN._id, body);
1094
- },
1095
- async down(ctx) {
1096
- ctx.log(`~ ${SESSION_INDEX_DESIGN._id} (event_times left in place \u2014 additive)`);
1097
- }
1098
- };
1099
- var sessionIndexDeterministic = {
1100
- id: 10,
1101
- name: "session-index-deterministic",
1102
- async up(ctx) {
1103
- ctx.log(`~ ${SESSION_INDEX_DESIGN._id} (earliest model/cwd/hostname)`);
1104
- const { _rev, ...body } = SESSION_INDEX_DESIGN;
1105
- await ctx.putDoc(SESSION_INDEX_DESIGN._id, body);
1106
- },
1107
- async down(ctx) {
1108
- ctx.log(`~ ${SESSION_INDEX_DESIGN._id} (deterministic pick left in place)`);
1109
- }
1110
- };
1111
- var MIGRATIONS = [
1112
- initialSchema,
1113
- sessionIndexView,
1114
- sessionIndexSource,
1115
- speakerSplitView,
1116
- speakerSplitTimeView,
1117
- chunkEntriesView,
1118
- sessionIndexChunkCoverage,
1119
- chunkEntryKind,
1120
- sessionIndexEventTimes,
1121
- sessionIndexDeterministic
1122
- ];
1123
- function latestVersion() {
1124
- return MIGRATIONS.reduce((max, m) => Math.max(max, m.id), 0);
1125
- }
1126
- function docTransformsBetween(from, to, migrations = MIGRATIONS) {
1127
- return migrations.filter((m) => m.id > from && m.id <= to && m.transformsDocs === true).sort((a, b) => a.id - b.id).map((m) => ({ id: m.id, name: m.name }));
1128
- }
1129
- // packages/shared/src/model/actions.ts
1130
- var ACTIONS = [
1131
- {
1132
- key: "write-event-marker",
1133
- summary: "Append a light append-only event marker doc",
1134
- implemented: true
1135
- },
1136
- {
1137
- key: "update-counts",
1138
- summary: "Bump per-session counters (events/prompts/errors/tools)",
1139
- implemented: true
1140
- },
1141
- {
1142
- key: "flush-transcript-chunk",
1143
- summary: "Tail the transcript \u2192 append chunk docs",
1144
- implemented: true
1145
- },
1146
- { key: "write-summary", summary: "Write summary:<id> at session end", implemented: true },
1147
- {
1148
- key: "upload-blobs",
1149
- summary: "Upload summary.json + transcript.jsonl to S3",
1150
- implemented: true
1151
- },
1152
- {
1153
- key: "seed-session-start",
1154
- summary: "Reset counters + seed the chunk offset at start",
1155
- implemented: true
1156
- },
1157
- {
1158
- key: "announce-recording",
1159
- summary: "Tell the user, in the transcript, whether and where this session is recorded",
1160
- implemented: true
1161
- },
1162
- {
1163
- key: "inject-recall-policy",
1164
- summary: "Prime the session with the recall policy + how much history this cwd has",
1165
- implemented: true
1166
- },
1167
- { key: "enrich-metadata", summary: "Post additional session metadata", implemented: false },
1168
- {
1169
- key: "extract-feature",
1170
- summary: "Map-reduce feature extraction (urls/repos/PRs/\u2026)",
1171
- implemented: false
1172
- },
1173
- { key: "detect-memory-write", summary: "Flag Edit/Write to memory paths", implemented: false },
1174
- { key: "app-log", summary: "Emit an operational log record (app-logs DB)", implemented: false },
1175
- {
1176
- key: "index-for-search",
1177
- summary: "Push derived content to the search backend",
1178
- implemented: false
1179
- },
1180
- {
1181
- key: "reconcile-session",
1182
- summary: "Finalize a stale running session from chunks/S3",
1183
- implemented: false
744
+ key: "seed-session-start",
745
+ summary: "Reset counters + seed the chunk offset at start",
746
+ implemented: true
747
+ },
748
+ {
749
+ key: "announce-recording",
750
+ summary: "Tell the user, in the transcript, whether and where this session is recorded",
751
+ implemented: true
752
+ },
753
+ {
754
+ key: "inject-recall-policy",
755
+ summary: "Prime the session with the recall policy + how much history this cwd has",
756
+ implemented: true
757
+ },
758
+ { key: "enrich-metadata", summary: "Post additional session metadata", implemented: false },
759
+ {
760
+ key: "extract-feature",
761
+ summary: "Map-reduce feature extraction (urls/repos/PRs/\u2026)",
762
+ implemented: false
763
+ },
764
+ { key: "detect-memory-write", summary: "Flag Edit/Write to memory paths", implemented: false },
765
+ { key: "app-log", summary: "Emit an operational log record (app-logs DB)", implemented: false },
766
+ {
767
+ key: "index-for-search",
768
+ summary: "Push derived content to the search backend",
769
+ implemented: false
770
+ },
771
+ {
772
+ key: "reconcile-session",
773
+ summary: "Finalize a stale running session from chunks/S3",
774
+ implemented: false
1184
775
  }
1185
776
  ];
1186
777
  var BINDINGS = [
@@ -1213,7 +804,7 @@ var BINDINGS = [
1213
804
  { event: "PostCompact", actions: ["write-event-marker"] },
1214
805
  { event: "SessionEnd", actions: ["flush-transcript-chunk", "write-summary", "upload-blobs"] }
1215
806
  ];
1216
- // packages/shared/src/model/env.ts
807
+ // packages/shared/src/blueprint/env.ts
1217
808
  var ENV_VARS = [
1218
809
  {
1219
810
  name: "IMAGE_NS",
@@ -1223,6 +814,7 @@ var ENV_VARS = [
1223
814
  { name: "COUCHDB_TAG", scope: "image", default: "3", description: "CouchDB image tag" },
1224
815
  { name: "GARAGE_TAG", scope: "image", default: "v2.3.0", description: "Garage image tag" },
1225
816
  { name: "GARAGE_UI_TAG", scope: "image", default: "1.1.0", description: "Garage UI image tag" },
817
+ { name: "FOSSIL_TAG", scope: "image", default: "2.28", description: "Fossil image tag" },
1226
818
  { name: "MEILI_TAG", scope: "image", default: "v1.10", description: "Meilisearch image tag" },
1227
819
  {
1228
820
  name: "MEILI_UI_TAG",
@@ -1259,6 +851,12 @@ var ENV_VARS = [
1259
851
  default: "7657",
1260
852
  description: "Meilisearch UI host port"
1261
853
  },
854
+ {
855
+ name: "FOSSIL_PORT",
856
+ scope: "port",
857
+ default: "7658",
858
+ description: "Fossil web UI + sync host port"
859
+ },
1262
860
  { name: "WEBAPI_HOST", scope: "host", default: "127.0.0.1", description: "webapi bind host" },
1263
861
  { name: "WEBUI_HOST", scope: "host", default: "127.0.0.1", description: "webui bind host" },
1264
862
  { name: "CT_STATIC_DIR", scope: "host", description: "prebuilt SPA dir (prod image only)" },
@@ -1293,6 +891,12 @@ var ENV_VARS = [
1293
891
  default: "http://127.0.0.1:7656",
1294
892
  description: "Meilisearch endpoint URL"
1295
893
  },
894
+ {
895
+ name: "FOSSIL_URL",
896
+ scope: "endpoint",
897
+ default: "http://127.0.0.1:7658",
898
+ description: "Fossil server base URL (serves each repository at /<name>/)"
899
+ },
1296
900
  {
1297
901
  name: "MEILI_API_KEY",
1298
902
  scope: "secret",
@@ -1300,7 +904,7 @@ var ENV_VARS = [
1300
904
  }
1301
905
  ];
1302
906
 
1303
- // packages/shared/src/model/hooks.ts
907
+ // packages/shared/src/blueprint/hooks.ts
1304
908
  var HOOK_TYPES = [
1305
909
  {
1306
910
  event: "SessionStart",
@@ -1537,7 +1141,7 @@ function hookLaunch(command) {
1537
1141
  }
1538
1142
  var PLUGIN_HOOK_COMMAND = hookLaunch('bun run "${CLAUDE_PLUGIN_ROOT}/scripts/dispatch.ts"');
1539
1143
 
1540
- // packages/shared/src/model/recall.ts
1144
+ // packages/shared/src/blueprint/recall.ts
1541
1145
  var RECALL_MODES = ["off", "suggest", "auto"];
1542
1146
  var RECALL_SCOPES = ["project", "host", "all"];
1543
1147
  var DEFAULT_RECALL = {
@@ -1628,30 +1232,59 @@ function joinOr(items) {
1628
1232
  return `${items.slice(0, -1).join(", ")}, or ${items[items.length - 1]}`;
1629
1233
  }
1630
1234
 
1631
- // packages/shared/src/model/routes.ts
1235
+ // packages/shared/src/blueprint/routes.ts
1632
1236
  var ROUTES = [
1633
- { path: "/", serves: "machine-readable app manifest (agent entrypoint)" },
1634
- { path: "/health", serves: "liveness + store reachability" },
1635
- { path: "/api", serves: "app JSON API (the gateway)" },
1636
- { path: "/api/docs", serves: "Scalar API reference (renders the OpenAPI spec)" },
1637
- { path: "/api/openapi.json", serves: "OpenAPI spec (contract source of truth)" },
1638
- { path: "/api/couch/*", serves: "read-only CouchDB proxy" },
1639
- { path: "/api/s3/*", serves: "read-only S3 proxy" },
1640
- { path: "/api/sessions", serves: "session list / detail / transcript / turns" },
1641
- { path: "/api/turns", serves: "turns across all sessions, time-ordered by speaker" },
1642
- { path: "/api/search", serves: "full-text search over sessions + conversation content" },
1643
1237
  {
1644
- path: "/api/ingest",
1238
+ path: "/",
1239
+ serves: "machine-readable app manifest (agent entrypoint)",
1240
+ nav: { label: "App manifest", section: "Reference" }
1241
+ },
1242
+ {
1243
+ path: "/health",
1244
+ serves: "liveness + store reachability",
1245
+ nav: { label: "Health", section: "Reference" }
1246
+ },
1247
+ { path: "/api", serves: "app JSON API (the gateway)" },
1248
+ {
1249
+ path: "/api/docs",
1250
+ serves: "Scalar API reference (renders the OpenAPI spec)",
1251
+ nav: { label: "API reference", section: "Reference" }
1252
+ },
1253
+ {
1254
+ path: "/api/openapi.json",
1255
+ serves: "OpenAPI spec (contract source of truth)",
1256
+ nav: { label: "OpenAPI spec", section: "Reference" }
1257
+ },
1258
+ { path: "/api/couch/*", serves: "read-only CouchDB proxy" },
1259
+ { path: "/api/s3/*", serves: "read-only S3 proxy" },
1260
+ { path: "/api/fossil/*", serves: "read-only Fossil JSON API proxy" },
1261
+ { path: "/api/sessions", serves: "session list / detail / transcript / turns" },
1262
+ { path: "/api/turns", serves: "turns across all sessions, time-ordered by speaker" },
1263
+ { path: "/api/search", serves: "full-text search over sessions + conversation content" },
1264
+ {
1265
+ path: "/api/ingest",
1645
1266
  serves: "curated write surface (summary/events/chunks/transcript, reset)"
1646
1267
  },
1647
1268
  { path: "/api/migrate", serves: "schema migration status / up / down" },
1648
- { path: "/api/model", serves: "app model introspection (services/hooks/actions/env)" },
1269
+ {
1270
+ path: "/api/blueprint",
1271
+ serves: "app blueprint introspection (services/hooks/actions/env)",
1272
+ nav: { label: "App blueprint", section: "Reference" }
1273
+ },
1649
1274
  { path: "/app", serves: "webui SPA" },
1650
- { path: "/docs", serves: "rendered technical docs" },
1651
- { path: "/cli/download", serves: "the bundled CLI binary" }
1275
+ {
1276
+ path: "/docs",
1277
+ serves: "rendered technical docs",
1278
+ nav: { label: "Technical docs", section: "Reference" }
1279
+ },
1280
+ {
1281
+ path: "/cli/download",
1282
+ serves: "the bundled CLI binary",
1283
+ nav: { label: "Download CLI", section: "Install" }
1284
+ }
1652
1285
  ];
1653
1286
 
1654
- // packages/shared/src/model/services.ts
1287
+ // packages/shared/src/blueprint/services.ts
1655
1288
  var RELEASE_IMAGE_NS = "ghcr.io/vredchenko";
1656
1289
  var SERVICES = [
1657
1290
  {
@@ -1687,6 +1320,7 @@ var SERVICES = [
1687
1320
  ],
1688
1321
  adminUiServiceKey: "couchdbFauxton",
1689
1322
  adminUiPath: "/_utils/",
1323
+ holds: "databases",
1690
1324
  volumes: [{ host: "./data/couchdb", container: "/opt/couchdb/data" }],
1691
1325
  containerEnv: {
1692
1326
  COUCHDB_USER: "${COUCHDB_USER:-admin}",
@@ -1709,6 +1343,7 @@ var SERVICES = [
1709
1343
  { internal: 3900, hostEnv: "GARAGE_S3_PORT", defaultHost: 7653, label: "S3 API" },
1710
1344
  { internal: 3903, hostEnv: "GARAGE_ADMIN_PORT", defaultHost: 7654, label: "admin API" }
1711
1345
  ],
1346
+ holds: "buckets",
1712
1347
  volumes: [
1713
1348
  { host: "./data/garage/meta", container: "/var/lib/garage/meta" },
1714
1349
  { host: "./data/garage/data", container: "/var/lib/garage/data" },
@@ -1742,6 +1377,24 @@ var SERVICES = [
1742
1377
  S3_REGION: "garage"
1743
1378
  }
1744
1379
  },
1380
+ {
1381
+ key: "fossil",
1382
+ name: "Fossil",
1383
+ role: "backing",
1384
+ image: {
1385
+ name: "fossil",
1386
+ tagEnv: "FOSSIL_TAG",
1387
+ defaultTag: "2.28",
1388
+ build: { context: "./fossil" }
1389
+ },
1390
+ ports: [{ internal: 8080, hostEnv: "FOSSIL_PORT", defaultHost: 7658, label: "web UI + sync" }],
1391
+ adminUiServiceKey: "fossil",
1392
+ holds: "repositories",
1393
+ volumes: [{ host: "./data/fossil", container: "/museum" }],
1394
+ containerEnv: { FOSSIL_REPOSITORIES: "${FOSSIL_REPOSITORIES:-claude-transcripts-sessions}" },
1395
+ tcpHealth: { port: 8080 },
1396
+ notes: "Version control (repos + web UI) from one static binary. Read by the webapi (read-only JSON API proxy); nothing writes it yet. Seeds the repositories named in config on start (anonymous read-only); serves every <name>.fossil under /museum."
1397
+ },
1745
1398
  {
1746
1399
  key: "meilisearch",
1747
1400
  name: "Meilisearch",
@@ -1754,6 +1407,7 @@ var SERVICES = [
1754
1407
  },
1755
1408
  ports: [{ internal: 7700, hostEnv: "MEILI_PORT", defaultHost: 7656 }],
1756
1409
  adminUiServiceKey: "meilisearch",
1410
+ holds: "indexes",
1757
1411
  volumes: [{ host: "./data/meilisearch", container: "/meili_data" }],
1758
1412
  containerEnv: { MEILI_ENV: "development" },
1759
1413
  notes: "Derived search index (optional/removable). Built-in UI on /."
@@ -1779,21 +1433,22 @@ var SERVICES = [
1779
1433
  image: { name: "app", tagEnv: "APP_TAG", defaultTag: "latest" },
1780
1434
  ports: [{ internal: 7650, hostEnv: "WEBAPI_PORT", defaultHost: 7650 }],
1781
1435
  profiles: ["app"],
1782
- dependsOn: ["couchdb", "garage", "meilisearch"],
1436
+ dependsOn: ["couchdb", "garage", "meilisearch", "fossil"],
1783
1437
  envFile: "../.env",
1784
1438
  containerEnv: {
1785
1439
  WEBAPI_HOST: "0.0.0.0",
1786
1440
  WEBAPI_PORT: "7650",
1787
1441
  COUCHDB_URL: "http://couchdb:5984",
1788
1442
  S3_ENDPOINT: "http://garage:3900",
1789
- MEILI_HOST: "http://meilisearch:7700"
1443
+ MEILI_HOST: "http://meilisearch:7700",
1444
+ FOSSIL_URL: "http://fossil:8080"
1790
1445
  },
1791
1446
  httpHealth: { path: "/health", port: 7650 },
1792
1447
  notes: "webapi + webui SPA + Scalar + bundled CLI; serves /app + /api."
1793
1448
  }
1794
1449
  ];
1795
1450
 
1796
- // packages/shared/src/model/topology.ts
1451
+ // packages/shared/src/blueprint/topology.ts
1797
1452
  var TOPOLOGY_NODES = [
1798
1453
  {
1799
1454
  key: "claude-code",
@@ -1898,6 +1553,18 @@ var TOPOLOGY_NODES = [
1898
1553
  requiresFeature: "meilisearch",
1899
1554
  summary: "Rebuildable full-text index. Optional \u2014 losing it costs search, not history."
1900
1555
  },
1556
+ {
1557
+ key: "fossil",
1558
+ serviceKey: "fossil",
1559
+ label: "Fossil",
1560
+ caption: "version control",
1561
+ role: "store",
1562
+ icon: "fossil",
1563
+ rank: 3,
1564
+ lane: "store",
1565
+ level: "compact",
1566
+ summary: "Repositories, wiki and tickets with a web UI, from one binary. Read through the gateway; nothing writes it yet."
1567
+ },
1901
1568
  {
1902
1569
  key: "couchdb-fauxton",
1903
1570
  serviceKey: "couchdb",
@@ -1928,187 +1595,624 @@ var TOPOLOGY_NODES = [
1928
1595
  requiresFeature: "meilisearch",
1929
1596
  summary: "Index browser for Meilisearch."
1930
1597
  }
1931
- ];
1932
- var TOPOLOGY_EDGES = [
1933
- {
1934
- from: "claude-code",
1935
- to: "hook",
1936
- kind: "emits",
1937
- label: "each event",
1938
- level: "compact",
1939
- note: "hooks/scripts/dispatch.ts pipes the payload to `claude-transcripts hook run` and always exits 0."
1940
- },
1941
- {
1942
- from: "hook",
1943
- to: "couchdb",
1944
- kind: "direct-write",
1945
- label: "events + summary",
1946
- level: "compact",
1947
- ref: "design/decisions/0016-webapi-is-the-io-gateway.md",
1948
- note: "Written directly, not through the gateway, so a webapi outage never loses a session."
1949
- },
1950
- {
1951
- from: "hook",
1952
- to: "garage",
1953
- kind: "direct-write",
1954
- label: "transcript",
1955
- level: "compact",
1956
- requiresFeature: "s3Blobs",
1957
- note: "The same bypass; transcript bytes live only in S3 (ADR 0014)."
1958
- },
1959
- { from: "webui", to: "webapi", kind: "http-client", label: "HTTP", level: "compact" },
1960
- { from: "cli", to: "webapi", kind: "http-client", label: "HTTP", level: "compact" },
1961
- { from: "agents", to: "webapi", kind: "http-client", label: "HTTP", level: "compact" },
1962
- {
1963
- from: "webapi",
1964
- to: "couchdb",
1965
- kind: "read-only-proxy",
1966
- label: "reads \xB7 /api/couch",
1967
- level: "compact"
1598
+ ];
1599
+ var TOPOLOGY_EDGES = [
1600
+ {
1601
+ from: "claude-code",
1602
+ to: "hook",
1603
+ kind: "emits",
1604
+ label: "each event",
1605
+ level: "compact",
1606
+ note: "hooks/scripts/dispatch.ts pipes the payload to `claude-transcripts hook run` and always exits 0."
1607
+ },
1608
+ {
1609
+ from: "hook",
1610
+ to: "couchdb",
1611
+ kind: "direct-write",
1612
+ label: "events + summary",
1613
+ level: "compact",
1614
+ ref: "design/decisions/0016-webapi-is-the-io-gateway.md",
1615
+ note: "Written directly, not through the gateway, so a webapi outage never loses a session."
1616
+ },
1617
+ {
1618
+ from: "hook",
1619
+ to: "garage",
1620
+ kind: "direct-write",
1621
+ label: "transcript",
1622
+ level: "compact",
1623
+ requiresFeature: "s3Blobs",
1624
+ note: "The same bypass; transcript bytes live only in S3 (ADR 0014)."
1625
+ },
1626
+ { from: "webui", to: "webapi", kind: "http-client", label: "HTTP", level: "compact" },
1627
+ { from: "cli", to: "webapi", kind: "http-client", label: "HTTP", level: "compact" },
1628
+ { from: "agents", to: "webapi", kind: "http-client", label: "HTTP", level: "compact" },
1629
+ {
1630
+ from: "webapi",
1631
+ to: "couchdb",
1632
+ kind: "read-only-proxy",
1633
+ label: "reads \xB7 /api/couch",
1634
+ level: "compact"
1635
+ },
1636
+ {
1637
+ from: "webapi",
1638
+ to: "garage",
1639
+ kind: "read-only-proxy",
1640
+ label: "reads \xB7 /api/s3",
1641
+ level: "compact",
1642
+ requiresFeature: "s3Blobs"
1643
+ },
1644
+ {
1645
+ from: "webapi",
1646
+ to: "fossil",
1647
+ kind: "read-only-proxy",
1648
+ label: "reads \xB7 /api/fossil",
1649
+ level: "compact",
1650
+ note: "Fossil's JSON API, allowlisted to read-only commands: over GET it would otherwise write."
1651
+ },
1652
+ {
1653
+ from: "webapi",
1654
+ to: "meilisearch",
1655
+ kind: "indexes",
1656
+ label: "search",
1657
+ level: "compact",
1658
+ requiresFeature: "meilisearch"
1659
+ },
1660
+ {
1661
+ from: "webapi",
1662
+ to: "couchdb",
1663
+ kind: "curated-write",
1664
+ label: "/api/ingest",
1665
+ level: "expanded",
1666
+ note: "The curated write surface that owns the document shapes; writes are never proxied."
1667
+ },
1668
+ {
1669
+ from: "couchdb",
1670
+ to: "webapi",
1671
+ kind: "changes-follower",
1672
+ label: "_changes",
1673
+ level: "expanded",
1674
+ note: "Exists because of the hook bypass: hook-written docs get no write-time validation."
1675
+ },
1676
+ { from: "couchdb-fauxton", to: "couchdb", kind: "admin-of", level: "expanded" },
1677
+ { from: "garage-ui", to: "garage", kind: "admin-of", level: "expanded" },
1678
+ { from: "meilisearch-ui", to: "meilisearch", kind: "admin-of", level: "expanded" }
1679
+ ];
1680
+ var TOPOLOGY_GROUPS = [
1681
+ {
1682
+ key: "clients",
1683
+ title: "read / query",
1684
+ members: ["webui", "cli", "agents"],
1685
+ level: "compact"
1686
+ },
1687
+ {
1688
+ key: "stores",
1689
+ title: "your infrastructure",
1690
+ members: ["couchdb", "garage", "meilisearch", "fossil"],
1691
+ level: "compact"
1692
+ }
1693
+ ];
1694
+ var TOPOLOGY = {
1695
+ nodes: TOPOLOGY_NODES,
1696
+ edges: TOPOLOGY_EDGES,
1697
+ groups: TOPOLOGY_GROUPS
1698
+ };
1699
+
1700
+ // packages/shared/src/blueprint/user-settings.ts
1701
+ var DEFAULT_USER_SETTINGS = {
1702
+ sessionListPageSize: 100,
1703
+ transcriptPageSize: 100,
1704
+ transcriptAutoLoadMax: 2000
1705
+ };
1706
+ var LIMITS = {
1707
+ sessionListPageSize: 500,
1708
+ transcriptPageSize: 500,
1709
+ transcriptAutoLoadMax: 50000
1710
+ };
1711
+ function resolveUserSettings(fromConfig) {
1712
+ const c = fromConfig ?? {};
1713
+ return {
1714
+ sessionListPageSize: clamp(c.sessionListPageSize, "sessionListPageSize"),
1715
+ transcriptPageSize: clamp(c.transcriptPageSize, "transcriptPageSize"),
1716
+ transcriptAutoLoadMax: clamp(c.transcriptAutoLoadMax, "transcriptAutoLoadMax")
1717
+ };
1718
+ }
1719
+ function clamp(value, key) {
1720
+ const fallback = DEFAULT_USER_SETTINGS[key];
1721
+ if (typeof value !== "number" || !Number.isFinite(value))
1722
+ return fallback;
1723
+ const rounded = Math.floor(value);
1724
+ if (rounded < 1)
1725
+ return fallback;
1726
+ return Math.min(rounded, LIMITS[key]);
1727
+ }
1728
+
1729
+ // packages/shared/src/blueprint/build.ts
1730
+ var STALE_TEMPLATE_LINKS = {
1731
+ couchdbFauxton: "http://127.0.0.1:7652/_utils/",
1732
+ garageWebui: "http://127.0.0.1:7655/",
1733
+ meilisearch: "http://127.0.0.1:7656/"
1734
+ };
1735
+ function buildServicesMenu(services, fromConfig) {
1736
+ const derived = {};
1737
+ for (const s of services) {
1738
+ if (!s.adminUiServiceKey)
1739
+ continue;
1740
+ const port = s.resolvedPorts?.[0]?.host;
1741
+ if (!port)
1742
+ continue;
1743
+ const path = s.adminUiPath ?? "/";
1744
+ derived[s.adminUiServiceKey] = `http://127.0.0.1:${port}${path}`;
1745
+ }
1746
+ const configured = Object.entries(fromConfig).filter(([k, v]) => STALE_TEMPLATE_LINKS[k] !== v);
1747
+ return { ...derived, ...Object.fromEntries(configured) };
1748
+ }
1749
+ var FOSSIL_REPOSITORY_NAME = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/;
1750
+ function resolveRepositories(config) {
1751
+ const repositories = {
1752
+ sessions: "claude-transcripts-sessions",
1753
+ ...config.fossil?.repositories ?? {}
1754
+ };
1755
+ for (const [key, name] of Object.entries(repositories)) {
1756
+ if (!FOSSIL_REPOSITORY_NAME.test(name)) {
1757
+ throw new Error(`config: fossil.repositories.${key} = ${JSON.stringify(name)} \u2014 use letters, digits, '-' and '_' (not starting with '-')`);
1758
+ }
1759
+ }
1760
+ return repositories;
1761
+ }
1762
+ function buildAppBlueprint(config, env = {}) {
1763
+ const version = env.CT_VERSION ?? "0.0.0-dev";
1764
+ const services = SERVICES.map((s) => ({
1765
+ ...s,
1766
+ resolvedPorts: (s.ports ?? []).map((p) => ({
1767
+ internal: p.internal,
1768
+ host: env[p.hostEnv] ? Number(env[p.hostEnv]) : p.defaultHost,
1769
+ label: p.label
1770
+ }))
1771
+ }));
1772
+ return {
1773
+ identity: {
1774
+ codename: "claude-transcripts",
1775
+ slug: config.app?.name ?? "claude-transcripts",
1776
+ title: "Claude Transcripts",
1777
+ version,
1778
+ repository: "https://github.com/vredchenko/claude-transcripts"
1779
+ },
1780
+ services,
1781
+ stores: {
1782
+ databases: { ...config.couchdb.databases },
1783
+ buckets: { ...config.s3.buckets },
1784
+ indexes: { ...config.meilisearch?.indexes },
1785
+ repositories: resolveRepositories(config)
1786
+ },
1787
+ hooks: HOOK_TYPES,
1788
+ actions: ACTIONS,
1789
+ bindings: BINDINGS,
1790
+ routes: ROUTES,
1791
+ topology: TOPOLOGY,
1792
+ env: ENV_VARS,
1793
+ features: config.features,
1794
+ servicesMenu: buildServicesMenu(services, config.servicesMenu),
1795
+ system: config.system,
1796
+ recall: resolveRecall(config.recall, env),
1797
+ userSettings: resolveUserSettings(config.userSettings),
1798
+ cliSpec: CLI_SPEC
1799
+ };
1800
+ }
1801
+ // packages/shared/src/blueprint/k8s.ts
1802
+ var WORKLOAD_ROLES = new Set(["backing", "admin-ui", "app"]);
1803
+ // packages/shared/src/blueprint/project.ts
1804
+ function toStoreEnv(model) {
1805
+ return { FOSSIL_REPOSITORIES: Object.values(model.stores.repositories).join(",") };
1806
+ }
1807
+ // packages/shared/src/couch-url.ts
1808
+ var DEFAULT_COUCHDB_HOST = "127.0.0.1";
1809
+ var DEFAULT_COUCHDB_PORT = "7652";
1810
+ function trimTrailingSlash(url) {
1811
+ return url.replace(/\/+$/, "");
1812
+ }
1813
+ function resolveCouchUrl(env = {}) {
1814
+ const explicit = env.COUCHDB_URL?.trim();
1815
+ if (explicit) {
1816
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(explicit) ? explicit : `http://${explicit}`;
1817
+ return trimTrailingSlash(withScheme);
1818
+ }
1819
+ const host = env.COUCHDB_HOST || DEFAULT_COUCHDB_HOST;
1820
+ const port = env.COUCHDB_PORT || DEFAULT_COUCHDB_PORT;
1821
+ return `http://${host}:${port}`;
1822
+ }
1823
+ // packages/shared/src/highlight.ts
1824
+ var HIGHLIGHT_PRE = "\uE000";
1825
+ var HIGHLIGHT_POST = "\uE001";
1826
+ function stripHighlightMarks(marked) {
1827
+ return marked.replaceAll(HIGHLIGHT_PRE, "").replaceAll(HIGHLIGHT_POST, "");
1828
+ }
1829
+ // packages/shared/src/migrations/designs.ts
1830
+ var CHUNK_ENTRIES_MAP = `function (doc) {
1831
+ if (doc.type !== "chunk" || !doc.entries) return;
1832
+ for (var i = 0; i < doc.entries.length; i++) {
1833
+ var e = doc.entries[i];
1834
+ if (!e || !e.role) continue;
1835
+ emit([doc.session_id, doc.byte_start, i], {
1836
+ role: e.role,
1837
+ timestamp: e.timestamp || "",
1838
+ text: e.text || "",
1839
+ toolUses: e.toolUses || null,
1840
+ toolUseId: e.toolUseId || null,
1841
+ isError: e.isError || false,
1842
+ isSidechain: e.isSidechain || false,
1843
+ kind: e.kind || null
1844
+ });
1845
+ }
1846
+ }`;
1847
+ var CHUNKS_DESIGN = {
1848
+ _id: "_design/chunks",
1849
+ language: "javascript",
1850
+ views: {
1851
+ by_session: {
1852
+ map: 'function(doc){if(doc.type==="chunk"){emit([doc.session_id,doc.byte_start],{byte_start:doc.byte_start,byte_end:doc.byte_end,entry_count:doc.entry_count});}}'
1853
+ },
1854
+ entry_count_by_session: {
1855
+ map: 'function(doc){if(doc.type==="chunk"){emit(doc.session_id,doc.entry_count||0);}}',
1856
+ reduce: "_sum"
1857
+ },
1858
+ entries_by_session: { map: CHUNK_ENTRIES_MAP, reduce: "_count" }
1859
+ }
1860
+ };
1861
+ var INITIAL_DESIGNS = [
1862
+ {
1863
+ _id: "_design/sessions",
1864
+ language: "javascript",
1865
+ views: {
1866
+ by_date: {
1867
+ map: 'function(doc){if(doc.type==="summary"){var t=doc.timestamp.split(/[-T:]/);emit([parseInt(t[0]),parseInt(t[1]),parseInt(t[2])],{session_id:doc.session_id,event_count:doc.event_count,prompt_count:doc.prompt_count,error_count:doc.error_count,cwd:doc.cwd});}}',
1868
+ reduce: "_count"
1869
+ },
1870
+ by_cwd: {
1871
+ map: 'function(doc){if(doc.type==="summary"){emit([doc.cwd,doc.timestamp],{session_id:doc.session_id,event_count:doc.event_count,prompt_count:doc.prompt_count});}}',
1872
+ reduce: "_count"
1873
+ }
1874
+ }
1875
+ },
1876
+ {
1877
+ _id: "_design/events",
1878
+ language: "javascript",
1879
+ views: {
1880
+ by_session: {
1881
+ map: 'function(doc){if(doc.type==="event"){emit([doc.session_id,doc.timestamp],{event:doc.event,tool_name:doc.tool_name||null,input_preview:doc.input_preview||doc.prompt_preview||doc.error_preview||null});}}'
1882
+ },
1883
+ by_type: {
1884
+ map: 'function(doc){if(doc.type==="event"){var t=doc.timestamp.split(/[-T:]/);emit([doc.event,parseInt(t[0]),parseInt(t[1]),parseInt(t[2])],1);}}',
1885
+ reduce: "_count"
1886
+ }
1887
+ }
1888
+ },
1889
+ {
1890
+ _id: "_design/tools",
1891
+ language: "javascript",
1892
+ views: {
1893
+ usage: {
1894
+ map: 'function(doc){if(doc.type==="event"&&doc.tool_name){var t=doc.timestamp.split(/[-T:]/);emit([doc.tool_name,parseInt(t[0]),parseInt(t[1]),parseInt(t[2])],1);}}',
1895
+ reduce: "_count"
1896
+ },
1897
+ failures: {
1898
+ map: 'function(doc){if(doc.event==="PostToolUseFailure"){emit([doc.tool_name,doc.timestamp],{session_id:doc.session_id,error_preview:doc.error_preview,cwd:doc.cwd});}}'
1899
+ },
1900
+ errors: {
1901
+ map: 'function(doc){if(doc.event==="PostToolUseFailure"||doc.error){emit([doc.tool_name||"unknown",doc.timestamp],{session_id:doc.session_id,error_preview:doc.error_preview||doc.error,cwd:doc.cwd});}}'
1902
+ }
1903
+ }
1904
+ },
1905
+ {
1906
+ _id: "_design/activity",
1907
+ language: "javascript",
1908
+ views: {
1909
+ timeline: {
1910
+ map: 'function(doc){if(doc.type==="event"){var t=doc.timestamp.split(/[-T:]/);emit([parseInt(t[0]),parseInt(t[1]),parseInt(t[2]),parseInt(t[3])],1);}}',
1911
+ reduce: "_count"
1912
+ }
1913
+ }
1914
+ },
1915
+ CHUNKS_DESIGN,
1916
+ {
1917
+ _id: "_design/session_meta",
1918
+ language: "javascript",
1919
+ views: {
1920
+ start_meta: {
1921
+ map: 'function(doc){if(doc.event==="SessionStart"){emit(doc.session_id,{timestamp:doc.timestamp,model:doc.model||"",cwd:doc.cwd||"",hostname:doc.hostname||""});}}'
1922
+ },
1923
+ tokens_by_date: {
1924
+ map: 'function(doc){if(doc.type==="summary"&&doc.token_usage){var t=doc.timestamp.split(/[-T:]/);var u=doc.token_usage;emit([parseInt(t[0]),parseInt(t[1]),parseInt(t[2])],{input:u.input||0,output:u.output||0,cacheCreation:u.cacheCreation||0,cacheRead:u.cacheRead||0,total:u.total||0,sessions:1});}}',
1925
+ reduce: "_sum"
1926
+ }
1927
+ }
1928
+ }
1929
+ ];
1930
+ // packages/shared/src/migrations/session-index.ts
1931
+ var AGGREGATE_MAP = `function (doc) {
1932
+ if (!doc.session_id) return;
1933
+ // A \`chunk\` doc contributes transcript coverage ONLY \u2014 no timestamps, no counts \u2014
1934
+ // so every other metric keeps the exact meaning it had before chunks were indexed.
1935
+ // \`chunkEntries\` counts turns the chunk view can actually serve (byte-range-only
1936
+ // chunks carry no \`entries[]\`), while \`chunkBytes\` tracks raw byte coverage.
1937
+ if (doc.type === "chunk") {
1938
+ emit(doc.session_id, {
1939
+ ended: 0, events: 0, prompts: 0, errors: 0, started: 0, tools: {},
1940
+ first: "", last: "", model: "", cwd: "", hostname: "",
1941
+ modelAt: "", cwdAt: "", hostnameAt: "", summary: null,
1942
+ chunkEntries: doc.entries ? doc.entries.length : 0,
1943
+ chunkBytes: doc.byte_end || 0
1944
+ });
1945
+ return;
1946
+ }
1947
+ if (doc.type !== "event" && doc.type !== "summary") return;
1948
+ var isSummary = doc.type === "summary";
1949
+ var ts = doc.timestamp || "";
1950
+ var model = (doc.event === "SessionStart" || isSummary) ? (doc.model || "") : "";
1951
+ var tools = {};
1952
+ if (doc.event === "PostToolUse" && doc.tool_name) tools[doc.tool_name] = 1;
1953
+ emit(doc.session_id, {
1954
+ ended: isSummary ? 1 : 0,
1955
+ events: doc.type === "event" ? 1 : 0,
1956
+ prompts: doc.event === "UserPromptSubmit" ? 1 : 0,
1957
+ errors: doc.event === "PostToolUseFailure" ? 1 : 0,
1958
+ started: doc.event === "SessionStart" ? 1 : 0,
1959
+ tools: tools,
1960
+ first: ts,
1961
+ last: ts,
1962
+ model: model,
1963
+ cwd: doc.cwd || "",
1964
+ hostname: doc.hostname || "",
1965
+ modelAt: model ? ts : "",
1966
+ cwdAt: doc.cwd ? ts : "",
1967
+ hostnameAt: doc.hostname ? ts : "",
1968
+ summary: isSummary ? {
1969
+ event_count: doc.event_count || 0,
1970
+ prompt_count: doc.prompt_count || 0,
1971
+ error_count: doc.error_count || 0,
1972
+ tool_counts: doc.tool_counts || {},
1973
+ end_reason: doc.end_reason || "",
1974
+ transcript_bytes: doc.transcript_bytes || 0,
1975
+ token_usage: doc.token_usage || null,
1976
+ timestamp: doc.timestamp || "",
1977
+ source: doc.source || ""
1978
+ } : null,
1979
+ chunkEntries: 0,
1980
+ chunkBytes: 0
1981
+ });
1982
+ }`;
1983
+ var AGGREGATE_REDUCE = `function (keys, values, rereduce) {
1984
+ var acc = { ended:0, events:0, prompts:0, errors:0, started:0, tools:{}, first:"", last:"", model:"", cwd:"", hostname:"", modelAt:"", cwdAt:"", hostnameAt:"", summary:null, chunkEntries:0, chunkBytes:0 };
1985
+ // Keep the earliest-stamped value; an unstamped one sorts last, ties go to the smaller value.
1986
+ function earliest(v, f) {
1987
+ var val = v[f];
1988
+ if (!val) return;
1989
+ var at = v[f + "At"] || "", a = at || "\\uffff", b = acc[f + "At"] || "\\uffff";
1990
+ if (acc[f] === "" || a < b || (a === b && val < acc[f])) { acc[f] = val; acc[f + "At"] = at; }
1991
+ }
1992
+ for (var i = 0; i < values.length; i++) {
1993
+ var v = values[i];
1994
+ if (!v) continue;
1995
+ acc.chunkEntries += v.chunkEntries || 0;
1996
+ if ((v.chunkBytes || 0) > acc.chunkBytes) acc.chunkBytes = v.chunkBytes;
1997
+ acc.ended += v.ended || 0;
1998
+ acc.events += v.events || 0;
1999
+ acc.prompts += v.prompts || 0;
2000
+ acc.errors += v.errors || 0;
2001
+ acc.started += v.started || 0;
2002
+ if (v.last && v.last > acc.last) acc.last = v.last;
2003
+ if (v.first && (acc.first === "" || v.first < acc.first)) acc.first = v.first;
2004
+ earliest(v, "model");
2005
+ earliest(v, "cwd");
2006
+ earliest(v, "hostname");
2007
+ // A session has one \`summary:<id>\` doc, so there is only ever one to keep.
2008
+ if (v.summary) acc.summary = v.summary;
2009
+ var t = v.tools || {};
2010
+ for (var k in t) { if (t.hasOwnProperty(k)) acc.tools[k] = (acc.tools[k] || 0) + t[k]; }
2011
+ }
2012
+ var names = Object.keys(acc.tools).sort(), tools = {};
2013
+ for (var j = 0; j < names.length; j++) tools[names[j]] = acc.tools[names[j]];
2014
+ acc.tools = tools;
2015
+ return acc;
2016
+ }`;
2017
+ var EVENT_TIMES_MAP = `function (doc) {
2018
+ if (!doc.session_id || !doc.timestamp) return;
2019
+ if (doc.type !== "event" && doc.type !== "summary") return;
2020
+ emit(doc.session_id, doc.timestamp);
2021
+ }`;
2022
+ var SESSION_INDEX_DESIGN = {
2023
+ _id: "_design/session_index",
2024
+ language: "javascript",
2025
+ views: {
2026
+ aggregate: { map: AGGREGATE_MAP, reduce: AGGREGATE_REDUCE },
2027
+ event_times: { map: EVENT_TIMES_MAP }
2028
+ }
2029
+ };
2030
+
2031
+ // packages/shared/src/migrations/speaker-split.ts
2032
+ var BY_ROLE_MAP = `function (doc) {
2033
+ if (doc.type !== "chunk" || !doc.entries) return;
2034
+ for (var i = 0; i < doc.entries.length; i++) {
2035
+ var e = doc.entries[i];
2036
+ if (!e || !e.role) continue;
2037
+ emit([doc.session_id, e.role, doc.byte_start, i], {
2038
+ role: e.role,
2039
+ timestamp: e.timestamp || "",
2040
+ text: e.text || "",
2041
+ toolUses: e.toolUses || null,
2042
+ toolUseId: e.toolUseId || null,
2043
+ isError: e.isError || false
2044
+ });
2045
+ }
2046
+ }`;
2047
+ var BY_ROLE_TIME_MAP = `function (doc) {
2048
+ if (doc.type !== "chunk" || !doc.entries) return;
2049
+ for (var i = 0; i < doc.entries.length; i++) {
2050
+ var e = doc.entries[i];
2051
+ if (!e || !e.role) continue;
2052
+ emit([e.role, e.timestamp || "", doc.session_id, doc.byte_start, i], {
2053
+ sessionId: doc.session_id,
2054
+ cwd: doc.cwd || "",
2055
+ role: e.role,
2056
+ timestamp: e.timestamp || "",
2057
+ text: e.text || ""
2058
+ });
2059
+ }
2060
+ }`;
2061
+ var SPEAKER_SPLIT_DESIGN = {
2062
+ _id: "_design/speaker_split",
2063
+ language: "javascript",
2064
+ views: {
2065
+ by_role: { map: BY_ROLE_MAP, reduce: "_count" },
2066
+ by_role_time: { map: BY_ROLE_TIME_MAP, reduce: "_count" }
2067
+ }
2068
+ };
2069
+
2070
+ // packages/shared/src/migrations/registry.ts
2071
+ var initialSchema = {
2072
+ id: 1,
2073
+ name: "initial-schema",
2074
+ async up(ctx) {
2075
+ for (const design of INITIAL_DESIGNS) {
2076
+ ctx.log(`+ ${design._id}`);
2077
+ const { _rev, ...body } = design;
2078
+ await ctx.putDoc(design._id, body);
2079
+ }
1968
2080
  },
1969
- {
1970
- from: "webapi",
1971
- to: "garage",
1972
- kind: "read-only-proxy",
1973
- label: "reads \xB7 /api/s3",
1974
- level: "compact",
1975
- requiresFeature: "s3Blobs"
2081
+ async down(ctx) {
2082
+ for (const design of INITIAL_DESIGNS) {
2083
+ ctx.log(`- ${design._id}`);
2084
+ await ctx.deleteDoc(design._id);
2085
+ }
2086
+ }
2087
+ };
2088
+ var sessionIndexView = {
2089
+ id: 2,
2090
+ name: "session-index-view",
2091
+ async up(ctx) {
2092
+ ctx.log(`+ ${SESSION_INDEX_DESIGN._id}`);
2093
+ const { _rev, ...body } = SESSION_INDEX_DESIGN;
2094
+ await ctx.putDoc(SESSION_INDEX_DESIGN._id, body);
1976
2095
  },
1977
- {
1978
- from: "webapi",
1979
- to: "meilisearch",
1980
- kind: "indexes",
1981
- label: "search",
1982
- level: "compact",
1983
- requiresFeature: "meilisearch"
2096
+ async down(ctx) {
2097
+ ctx.log(`- ${SESSION_INDEX_DESIGN._id}`);
2098
+ await ctx.deleteDoc(SESSION_INDEX_DESIGN._id);
2099
+ }
2100
+ };
2101
+ var sessionIndexSource = {
2102
+ id: 3,
2103
+ name: "session-index-source",
2104
+ async up(ctx) {
2105
+ ctx.log(`~ ${SESSION_INDEX_DESIGN._id} (emit summary.source)`);
2106
+ const { _rev, ...body } = SESSION_INDEX_DESIGN;
2107
+ await ctx.putDoc(SESSION_INDEX_DESIGN._id, body);
1984
2108
  },
1985
- {
1986
- from: "webapi",
1987
- to: "couchdb",
1988
- kind: "curated-write",
1989
- label: "/api/ingest",
1990
- level: "expanded",
1991
- note: "The curated write surface that owns the document shapes; writes are never proxied."
2109
+ async down(ctx) {
2110
+ ctx.log(`~ ${SESSION_INDEX_DESIGN._id} (source field left in place \u2014 additive)`);
2111
+ }
2112
+ };
2113
+ var speakerSplitView = {
2114
+ id: 4,
2115
+ name: "speaker-split-view",
2116
+ async up(ctx) {
2117
+ ctx.log(`+ ${SPEAKER_SPLIT_DESIGN._id}`);
2118
+ const { _rev, ...body } = SPEAKER_SPLIT_DESIGN;
2119
+ await ctx.putDoc(SPEAKER_SPLIT_DESIGN._id, body);
1992
2120
  },
1993
- {
1994
- from: "couchdb",
1995
- to: "webapi",
1996
- kind: "changes-follower",
1997
- label: "_changes",
1998
- level: "expanded",
1999
- note: "Exists because of the hook bypass: hook-written docs get no write-time validation."
2121
+ async down(ctx) {
2122
+ ctx.log(`- ${SPEAKER_SPLIT_DESIGN._id}`);
2123
+ await ctx.deleteDoc(SPEAKER_SPLIT_DESIGN._id);
2124
+ }
2125
+ };
2126
+ var speakerSplitTimeView = {
2127
+ id: 5,
2128
+ name: "speaker-split-time-view",
2129
+ async up(ctx) {
2130
+ ctx.log(`~ ${SPEAKER_SPLIT_DESIGN._id} (add by_role_time)`);
2131
+ const { _rev, ...body } = SPEAKER_SPLIT_DESIGN;
2132
+ await ctx.putDoc(SPEAKER_SPLIT_DESIGN._id, body);
2000
2133
  },
2001
- { from: "couchdb-fauxton", to: "couchdb", kind: "admin-of", level: "expanded" },
2002
- { from: "garage-ui", to: "garage", kind: "admin-of", level: "expanded" },
2003
- { from: "meilisearch-ui", to: "meilisearch", kind: "admin-of", level: "expanded" }
2004
- ];
2005
- var TOPOLOGY_GROUPS = [
2006
- {
2007
- key: "clients",
2008
- title: "read / query",
2009
- members: ["webui", "cli", "agents"],
2010
- level: "compact"
2134
+ async down(ctx) {
2135
+ ctx.log(`~ ${SPEAKER_SPLIT_DESIGN._id} (by_role_time left in place \u2014 additive)`);
2136
+ }
2137
+ };
2138
+ var chunkEntriesView = {
2139
+ id: 6,
2140
+ name: "chunk-entries-view",
2141
+ async up(ctx) {
2142
+ ctx.log(`~ ${CHUNKS_DESIGN._id} (add entries_by_session)`);
2143
+ const { _rev, ...body } = CHUNKS_DESIGN;
2144
+ await ctx.putDoc(CHUNKS_DESIGN._id, body);
2011
2145
  },
2012
- {
2013
- key: "stores",
2014
- title: "your infrastructure",
2015
- members: ["couchdb", "garage", "meilisearch"],
2016
- level: "compact"
2146
+ async down(ctx) {
2147
+ ctx.log(`~ ${CHUNKS_DESIGN._id} (entries_by_session left in place \u2014 additive)`);
2017
2148
  }
2018
- ];
2019
- var TOPOLOGY = {
2020
- nodes: TOPOLOGY_NODES,
2021
- edges: TOPOLOGY_EDGES,
2022
- groups: TOPOLOGY_GROUPS
2023
2149
  };
2024
-
2025
- // packages/shared/src/model/user-settings.ts
2026
- var DEFAULT_USER_SETTINGS = {
2027
- sessionListPageSize: 100,
2028
- transcriptPageSize: 100,
2029
- transcriptAutoLoadMax: 2000
2150
+ var sessionIndexChunkCoverage = {
2151
+ id: 7,
2152
+ name: "session-index-chunk-coverage",
2153
+ async up(ctx) {
2154
+ ctx.log(`~ ${SESSION_INDEX_DESIGN._id} (emit chunk coverage)`);
2155
+ const { _rev, ...body } = SESSION_INDEX_DESIGN;
2156
+ await ctx.putDoc(SESSION_INDEX_DESIGN._id, body);
2157
+ },
2158
+ async down(ctx) {
2159
+ ctx.log(`~ ${SESSION_INDEX_DESIGN._id} (chunk coverage left in place \u2014 additive)`);
2160
+ }
2030
2161
  };
2031
- var LIMITS = {
2032
- sessionListPageSize: 500,
2033
- transcriptPageSize: 500,
2034
- transcriptAutoLoadMax: 50000
2162
+ var chunkEntryKind = {
2163
+ id: 8,
2164
+ name: "chunk-entry-kind",
2165
+ async up(ctx) {
2166
+ ctx.log(`~ ${CHUNKS_DESIGN._id} (emit entry kind)`);
2167
+ const { _rev, ...body } = CHUNKS_DESIGN;
2168
+ await ctx.putDoc(CHUNKS_DESIGN._id, body);
2169
+ },
2170
+ async down(ctx) {
2171
+ ctx.log(`~ ${CHUNKS_DESIGN._id} (kind left in place \u2014 additive)`);
2172
+ }
2035
2173
  };
2036
- function resolveUserSettings(fromConfig) {
2037
- const c = fromConfig ?? {};
2038
- return {
2039
- sessionListPageSize: clamp(c.sessionListPageSize, "sessionListPageSize"),
2040
- transcriptPageSize: clamp(c.transcriptPageSize, "transcriptPageSize"),
2041
- transcriptAutoLoadMax: clamp(c.transcriptAutoLoadMax, "transcriptAutoLoadMax")
2042
- };
2043
- }
2044
- function clamp(value, key) {
2045
- const fallback = DEFAULT_USER_SETTINGS[key];
2046
- if (typeof value !== "number" || !Number.isFinite(value))
2047
- return fallback;
2048
- const rounded = Math.floor(value);
2049
- if (rounded < 1)
2050
- return fallback;
2051
- return Math.min(rounded, LIMITS[key]);
2052
- }
2053
-
2054
- // packages/shared/src/model/build.ts
2055
- var STALE_TEMPLATE_LINKS = {
2056
- couchdbFauxton: "http://127.0.0.1:7652/_utils/",
2057
- garageWebui: "http://127.0.0.1:7655/",
2058
- meilisearch: "http://127.0.0.1:7656/"
2174
+ var sessionIndexEventTimes = {
2175
+ id: 9,
2176
+ name: "session-index-event-times",
2177
+ async up(ctx) {
2178
+ ctx.log(`~ ${SESSION_INDEX_DESIGN._id} (add event_times view)`);
2179
+ const { _rev, ...body } = SESSION_INDEX_DESIGN;
2180
+ await ctx.putDoc(SESSION_INDEX_DESIGN._id, body);
2181
+ },
2182
+ async down(ctx) {
2183
+ ctx.log(`~ ${SESSION_INDEX_DESIGN._id} (event_times left in place \u2014 additive)`);
2184
+ }
2059
2185
  };
2060
- function buildServicesMenu(services, fromConfig) {
2061
- const derived = {};
2062
- for (const s of services) {
2063
- if (!s.adminUiServiceKey)
2064
- continue;
2065
- const port = s.resolvedPorts?.[0]?.host;
2066
- if (!port)
2067
- continue;
2068
- const path = s.adminUiPath ?? "/";
2069
- derived[s.adminUiServiceKey] = `http://127.0.0.1:${port}${path}`;
2186
+ var sessionIndexDeterministic = {
2187
+ id: 10,
2188
+ name: "session-index-deterministic",
2189
+ async up(ctx) {
2190
+ ctx.log(`~ ${SESSION_INDEX_DESIGN._id} (earliest model/cwd/hostname)`);
2191
+ const { _rev, ...body } = SESSION_INDEX_DESIGN;
2192
+ await ctx.putDoc(SESSION_INDEX_DESIGN._id, body);
2193
+ },
2194
+ async down(ctx) {
2195
+ ctx.log(`~ ${SESSION_INDEX_DESIGN._id} (deterministic pick left in place)`);
2070
2196
  }
2071
- const configured = Object.entries(fromConfig).filter(([k, v]) => STALE_TEMPLATE_LINKS[k] !== v);
2072
- return { ...derived, ...Object.fromEntries(configured) };
2197
+ };
2198
+ var MIGRATIONS = [
2199
+ initialSchema,
2200
+ sessionIndexView,
2201
+ sessionIndexSource,
2202
+ speakerSplitView,
2203
+ speakerSplitTimeView,
2204
+ chunkEntriesView,
2205
+ sessionIndexChunkCoverage,
2206
+ chunkEntryKind,
2207
+ sessionIndexEventTimes,
2208
+ sessionIndexDeterministic
2209
+ ];
2210
+ function latestVersion() {
2211
+ return MIGRATIONS.reduce((max, m) => Math.max(max, m.id), 0);
2073
2212
  }
2074
- function buildAppModel(config, env = {}) {
2075
- const version = env.CT_VERSION ?? "0.0.0-dev";
2076
- const services = SERVICES.map((s) => ({
2077
- ...s,
2078
- resolvedPorts: (s.ports ?? []).map((p) => ({
2079
- internal: p.internal,
2080
- host: env[p.hostEnv] ? Number(env[p.hostEnv]) : p.defaultHost,
2081
- label: p.label
2082
- }))
2083
- }));
2084
- return {
2085
- identity: {
2086
- codename: "claude-transcripts",
2087
- slug: config.app?.name ?? "claude-transcripts",
2088
- title: "Claude Transcripts",
2089
- version
2090
- },
2091
- services,
2092
- stores: {
2093
- databases: { ...config.couchdb.databases },
2094
- buckets: { ...config.s3.buckets }
2095
- },
2096
- hooks: HOOK_TYPES,
2097
- actions: ACTIONS,
2098
- bindings: BINDINGS,
2099
- routes: ROUTES,
2100
- topology: TOPOLOGY,
2101
- env: ENV_VARS,
2102
- features: config.features,
2103
- servicesMenu: buildServicesMenu(services, config.servicesMenu),
2104
- system: config.system,
2105
- recall: resolveRecall(config.recall, env),
2106
- userSettings: resolveUserSettings(config.userSettings),
2107
- cliSpec: CLI_SPEC
2108
- };
2213
+ function docTransformsBetween(from, to, migrations = MIGRATIONS) {
2214
+ return migrations.filter((m) => m.id > from && m.id <= to && m.transformsDocs === true).sort((a, b) => a.id - b.id).map((m) => ({ id: m.id, name: m.name }));
2109
2215
  }
2110
- // packages/shared/src/model/k8s.ts
2111
- var WORKLOAD_ROLES = new Set(["backing", "admin-ui", "app"]);
2112
2216
  // packages/shared/src/index.ts
2113
2217
  function usageWeight(u) {
2114
2218
  return (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0);
@@ -3373,9 +3477,9 @@ var EMBEDDED_ASSETS = {
3373
3477
  compose: {
3374
3478
  path: "deploy/docker-compose.yml",
3375
3479
  source: "deploy/docker-compose.yml",
3376
- content: `# GENERATED from the app model (@claude-transcripts/shared) by scripts/gen-compose.ts.
3377
- # Do NOT edit by hand \u2014 run \`bun run gen:compose\` to regenerate, and change the model
3378
- # (packages/shared/src/model/services.ts) instead. Inline edits here are lost silently.
3480
+ content: `# GENERATED from the blueprint (@claude-transcripts/shared) by scripts/gen-compose.ts.
3481
+ # Do NOT edit by hand \u2014 run \`bun run gen:compose\` to regenerate, and change the blueprint
3482
+ # (packages/shared/src/blueprint/services.ts) instead. Inline edits here are lost silently.
3379
3483
  # See docs/operate/containers.md.
3380
3484
  #
3381
3485
  # Claude Transcripts \u2014 backing services + app.
@@ -3466,6 +3570,18 @@ services:
3466
3570
  restart: unless-stopped
3467
3571
  networks:
3468
3572
  - claude-transcripts
3573
+ fossil:
3574
+ image: \${IMAGE_NS}/claude-transcripts-fossil:\${FOSSIL_TAG:-2.28}
3575
+ container_name: claude-transcripts-fossil
3576
+ environment:
3577
+ - FOSSIL_REPOSITORIES=\${FOSSIL_REPOSITORIES:-claude-transcripts-sessions}
3578
+ ports:
3579
+ - 127.0.0.1:\${FOSSIL_PORT:-7658}:8080
3580
+ volumes:
3581
+ - ./data/fossil:/museum
3582
+ restart: unless-stopped
3583
+ networks:
3584
+ - claude-transcripts
3469
3585
  meilisearch:
3470
3586
  image: \${IMAGE_NS}/claude-transcripts-meilisearch:\${MEILI_TAG:-v1.10}
3471
3587
  container_name: claude-transcripts-meilisearch
@@ -3497,6 +3613,7 @@ services:
3497
3613
  - couchdb
3498
3614
  - garage
3499
3615
  - meilisearch
3616
+ - fossil
3500
3617
  env_file: ../.env
3501
3618
  environment:
3502
3619
  - WEBAPI_HOST=0.0.0.0
@@ -3504,6 +3621,7 @@ services:
3504
3621
  - COUCHDB_URL=http://couchdb:5984
3505
3622
  - S3_ENDPOINT=http://garage:3900
3506
3623
  - MEILI_HOST=http://meilisearch:7700
3624
+ - FOSSIL_URL=http://fossil:8080
3507
3625
  ports:
3508
3626
  - 127.0.0.1:\${WEBAPI_PORT:-7650}:7650
3509
3627
  restart: unless-stopped
@@ -3518,7 +3636,7 @@ networks:
3518
3636
  composeUpstream: {
3519
3637
  path: "deploy/docker-compose.upstream.yml",
3520
3638
  source: "deploy/docker-compose.upstream.yml",
3521
- content: `# GENERATED from the app model (@claude-transcripts/shared) by scripts/gen-compose-override.ts.
3639
+ content: `# GENERATED from the blueprint (@claude-transcripts/shared) by scripts/gen-compose-override.ts.
3522
3640
  # Do NOT edit by hand \u2014 run \`bun run gen:compose-override\` to regenerate.
3523
3641
  #
3524
3642
  # Upstream-image dev override: pins each mirrored backing service to its canonical
@@ -3529,7 +3647,7 @@ networks:
3529
3647
  # bun run scripts/stack.ts up --upstream # the runner does this for you
3530
3648
  #
3531
3649
  # Tags still come from .env (COUCHDB_TAG, GARAGE_TAG, \u2026). Override an image here by
3532
- # changing its \`upstream\` in packages/shared/src/model/services.ts. The community
3650
+ # changing its \`upstream\` in packages/shared/src/blueprint/services.ts. The community
3533
3651
  # admin-UI images are best-effort; swap them if your registry differs.
3534
3652
 
3535
3653
  name: claude-transcripts
@@ -3540,6 +3658,10 @@ services:
3540
3658
  image: dxflrs/garage:\${GARAGE_TAG:-v2.3.0}
3541
3659
  garage-ui:
3542
3660
  image: khairul169/garage-webui:\${GARAGE_UI_TAG:-1.1.0}
3661
+ fossil:
3662
+ image: claude-transcripts-fossil:\${FOSSIL_TAG:-2.28}-local
3663
+ build:
3664
+ context: ./fossil
3543
3665
  meilisearch:
3544
3666
  image: getmeili/meilisearch:\${MEILI_TAG:-v1.10}
3545
3667
  meilisearch-ui:
@@ -3570,6 +3692,154 @@ api_bind_addr = "[::]:3900"
3570
3692
 
3571
3693
  [admin]
3572
3694
  api_bind_addr = "[::]:3903"
3695
+ `
3696
+ },
3697
+ fossilDockerfile: {
3698
+ path: "deploy/fossil/Dockerfile",
3699
+ source: "deploy/fossil/Dockerfile",
3700
+ content: `# Fossil SCM \u2014 built from the official release source (https://fossil-scm.org).
3701
+ #
3702
+ # Fossil publishes no container image, only a Dockerfile in its source tree
3703
+ # (www/containers.md). This is that file's recipe, pinned to a release instead of
3704
+ # trunk and verified by checksum: a static, musl-linked binary on an empty base, so
3705
+ # the image holds Fossil and nothing else. Hand-maintained (NOT generated); its
3706
+ # version must equal the \`fossil\` service's \`defaultTag\` in
3707
+ # packages/shared/src/blueprint/services.ts \u2014 a test holds the two together.
3708
+ #
3709
+ # Build context is this directory, and only entrypoint.sh is COPY'd from it, so the
3710
+ # image builds the same from a repo checkout, a user install (which ships both files),
3711
+ # or CI.
3712
+ #
3713
+ # To bump: take the release's tarball link from https://fossil-scm.org/home/uv/download.html,
3714
+ # set FOSSIL_VERSION / FOSSIL_CHECKIN / FOSSIL_SHA256, and the blueprint's defaultTag.
3715
+
3716
+ ## \u2500\u2500 build: a static Fossil binary \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3717
+ FROM alpine:3.22 AS build
3718
+ RUN apk add --no-cache gcc make linux-headers musl-dev \\
3719
+ openssl-dev openssl-libs-static zlib-dev zlib-static
3720
+ ARG FOSSIL_VERSION=2.28
3721
+ # The release check-in (\`fossil version\` prints its prefix) and the sha256 of the
3722
+ # tarball fossil-scm.org serves for it.
3723
+ ARG FOSSIL_CHECKIN=52445a27f116603d784ad753c6acd494e0b02aeaf8502ee0fee0aa30030f4c4e
3724
+ ARG FOSSIL_SHA256=84c18824ca227e7602d2408b663c3747f754ad306ed5c73ddab959d6589538a6
3725
+ WORKDIR /fsl
3726
+ ADD --checksum=sha256:\${FOSSIL_SHA256} \\
3727
+ https://fossil-scm.org/home/tarball/\${FOSSIL_CHECKIN}/fossil-src-\${FOSSIL_VERSION}.tar.gz \\
3728
+ src.tar.gz
3729
+ RUN tar -xzf src.tar.gz --strip-components=1 \\
3730
+ && ./configure --static --json CFLAGS='-Os -s' \\
3731
+ && make -j"$(nproc)" \\
3732
+ && ./fossil version | grep -q "version \${FOSSIL_VERSION} "
3733
+
3734
+ ## \u2500\u2500 os: the few files a scratch image still needs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3735
+ # The musl variant: a static busybox, so the one binary runs on \`scratch\`.
3736
+ FROM busybox:1.37-musl AS os
3737
+ RUN mkdir -p /e /museum \\
3738
+ && echo "root:x:0:0:Admin:/:/false" > /e/passwd \\
3739
+ && echo "root:x:0:root" > /e/group
3740
+
3741
+ ## \u2500\u2500 run \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3742
+ FROM scratch
3743
+ COPY --from=build --chmod=755 /fsl/fossil /bin/fossil
3744
+ COPY --from=os /e/passwd /e/group /etc/
3745
+ COPY --from=os --chmod=1777 /tmp /tmp/
3746
+ COPY --from=os /museum /museum/
3747
+ # Only for the seed script (entrypoint.sh): one static binary, no applet links.
3748
+ COPY --from=os /bin/busybox /bin/busybox
3749
+ COPY --chmod=755 entrypoint.sh /bin/fossil-entrypoint
3750
+ ENV PATH=/bin
3751
+ EXPOSE 8080/tcp
3752
+ # Runs as root, unlike upstream's image (a fixed UID 499). The repo lives on a
3753
+ # bind mount or volume created by whoever runs the stack, and a fixed UID can't write
3754
+ # to a directory it doesn't own. Fossil handles root itself: serving a repository
3755
+ # file owned by a non-root user, it chroots into the repo's directory and drops to
3756
+ # that user (\`chown\` the repo to opt in). See https://fossil-scm.org/home/help/server.
3757
+ # Seeds the repositories named in FOSSIL_REPOSITORIES (from \`fossil.repositories\` in
3758
+ # config/), then execs \`fossil server\` with CMD \u2014 see entrypoint.sh.
3759
+ ENTRYPOINT ["/bin/busybox", "sh", "/bin/fossil-entrypoint"]
3760
+ # Serve the directory, not a file: every \`<name>.fossil\` in /museum is served at
3761
+ # /<name>/, and "/" lists them. Not upstream's \`--create\`, which makes a generic
3762
+ # repository with a generated admin login; ours are made by the seed.
3763
+ # --jsmode bundled: one JS file, fewer requests.
3764
+ CMD ["--repolist", "--jsmode", "bundled", "/museum"]
3765
+ `
3766
+ },
3767
+ fossilEntrypoint: {
3768
+ path: "deploy/fossil/entrypoint.sh",
3769
+ source: "deploy/fossil/entrypoint.sh",
3770
+ content: `#!/bin/sh
3771
+ # Seed, then serve. Run by busybox sh inside the Fossil image (deploy/fossil/Dockerfile).
3772
+ #
3773
+ # FOSSIL_REPOSITORIES names the repositories this instance owns, comma-separated \u2014
3774
+ # projected from \`fossil.repositories\` in config/ (ADR 0031). Each one missing from
3775
+ # the data directory is created here, because Fossil has no HTTP call that creates a
3776
+ # repository: the seed has to run where the binary and the volume are. Existing
3777
+ # repositories are never touched, so a restart is a no-op.
3778
+ #
3779
+ # A seeded repository needs no login to READ: the built-in \`nobody\` user can browse,
3780
+ # clone and download (ADR 0020). It can't write. Fossil's JSON API takes parameters
3781
+ # from the query string, so with write capabilities a plain GET could change the
3782
+ # repository \u2014 and any web page the user visits can send one to a localhost port
3783
+ # (an <img> tag is enough). The admin user exists because Fossil requires one; its
3784
+ # generated password is discarded, and \`fossil user password\` sets one when needed.
3785
+ #
3786
+ # FOSSIL_MUSEUM and FOSSIL_BUSYBOX exist for the tests (entrypoint.test.ts); the image
3787
+ # uses the defaults.
3788
+ set -euf # -f: an entry like \`*\` must stay a (rejected) name, not glob the cwd
3789
+
3790
+ museum="\${FOSSIL_MUSEUM:-/museum}"
3791
+ bb="\${FOSSIL_BUSYBOX-/bin/busybox}"
3792
+
3793
+ # Read-only for anonymous visitors: check-out/browse (o), clone (g), wiki (j), tickets
3794
+ # (r), hyperlinks (h), zip/tarball downloads (z).
3795
+ NOBODY_CAPS="ghjorz"
3796
+
3797
+ # What a new repository holds beyond \`fossil new\` and its users. A placeholder: the
3798
+ # seed's contents (users and roles, wiki, ticket schema, tags, settings) are still to
3799
+ # be defined \u2014 https://github.com/vredchenko/claude-transcripts/issues/210. Runs once,
3800
+ # on the not-yet-served copy, so anything added here must only ever create.
3801
+ seed_content() {
3802
+ : "$1"
3803
+ }
3804
+
3805
+ trim() {
3806
+ s="$1"
3807
+ while :; do
3808
+ case "$s" in
3809
+ [[:space:]]*) s="\${s#?}" ;;
3810
+ *[[:space:]]) s="\${s%?}" ;;
3811
+ *) break ;;
3812
+ esac
3813
+ done
3814
+ printf '%s' "$s"
3815
+ }
3816
+
3817
+ IFS=','
3818
+ for entry in \${FOSSIL_REPOSITORIES:-}; do
3819
+ name="$(trim "$entry")"
3820
+ [ -n "$name" ] || continue
3821
+ # The URL rules \`fossil server\` applies to a repository directory, so a name that
3822
+ # can't be served is refused here rather than created and then unreachable.
3823
+ case "$name" in
3824
+ -* | *[!A-Za-z0-9_-]*)
3825
+ echo "fossil-seed: skipping '$name' (letters, digits, '-' and '_' only)" >&2
3826
+ continue
3827
+ ;;
3828
+ esac
3829
+ repo="$museum/$name.fossil"
3830
+ [ -e "$repo" ] && continue
3831
+ # Built under a name \`--repolist\` doesn't serve, then renamed: a seed interrupted
3832
+ # half-way leaves no half-made repository to be served or skipped forever.
3833
+ $bb rm -f "$repo.new"
3834
+ fossil new --admin-user claude-transcripts --project-name "$name" "$repo.new" >/dev/null
3835
+ fossil user capabilities nobody "$NOBODY_CAPS" -R "$repo.new" >/dev/null
3836
+ seed_content "$repo.new"
3837
+ $bb mv "$repo.new" "$repo"
3838
+ echo "fossil-seed: created $repo (anonymous read-only)"
3839
+ done
3840
+ unset IFS
3841
+
3842
+ exec fossil server "$@"
3573
3843
  `
3574
3844
  },
3575
3845
  appConfig: {
@@ -3602,6 +3872,11 @@ api_bind_addr = "[::]:3903"
3602
3872
  "turns": "claude-transcripts-turns"
3603
3873
  }
3604
3874
  },
3875
+ "fossil": {
3876
+ "repositories": {
3877
+ "sessions": "claude-transcripts-sessions"
3878
+ }
3879
+ },
3605
3880
  "features": {
3606
3881
  "s3Blobs": true,
3607
3882
  "midFlightChunking": true,
@@ -3885,13 +4160,189 @@ async function runExport(argv) {
3885
4160
  import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "fs";
3886
4161
  import { dirname as dirname2 } from "path";
3887
4162
 
4163
+ // packages/cli/src/lib/statusline.ts
4164
+ import { statSync } from "fs";
4165
+ var STALL_AFTER_MS = 60000;
4166
+ var HOOK_SILENT_AFTER_MS = 5 * 60000;
4167
+ function transcriptMtimeMs(path) {
4168
+ if (typeof path !== "string" || !path)
4169
+ return null;
4170
+ try {
4171
+ return statSync(path).mtimeMs;
4172
+ } catch {
4173
+ return null;
4174
+ }
4175
+ }
4176
+ function hookSilent(lastActivityMs, transcriptMs) {
4177
+ if (!transcriptMs || lastActivityMs <= 0)
4178
+ return false;
4179
+ return transcriptMs - lastActivityMs > HOOK_SILENT_AFTER_MS;
4180
+ }
4181
+ function lastWrite(ms, now) {
4182
+ return ms > 0 ? `last write ${ago(ms, now)}` : "no write landed";
4183
+ }
4184
+ function ago(ms, now) {
4185
+ const s = Math.max(0, Math.round((now - ms) / 1000));
4186
+ if (s < 60)
4187
+ return `${s}s ago`;
4188
+ const m = Math.round(s / 60);
4189
+ if (m < 60)
4190
+ return `${m}m ago`;
4191
+ return `${Math.round(m / 60)}h ago`;
4192
+ }
4193
+ function versionLabel(version = VERSION) {
4194
+ return `ct@${version === DEV_VERSION ? "dev" : version}`;
4195
+ }
4196
+ function whereLabel(t) {
4197
+ const host = t.couchUrl.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
4198
+ return `${t.sessionsDb}@${host}`;
4199
+ }
4200
+ function storeState(s, now) {
4201
+ if (s.lastFailureMs > s.lastWriteMs && now - s.lastWriteMs > STALL_AFTER_MS)
4202
+ return "failing";
4203
+ return s.lastWriteMs > 0 ? "healthy" : "idle";
4204
+ }
4205
+ var GLYPH = { rec: "\u25CF", stalled: "\u25D0", ready: "\u25CC", off: "\u25CB" };
4206
+ function sessionLink(webapiUrl, sessionId) {
4207
+ return webapiUrl ? `${webapiUrl.replace(/\/$/, "")}/app/sessions/${sessionId}` : null;
4208
+ }
4209
+ function renderStatusline(state, now = Date.now(), opts = {}) {
4210
+ return formatStatusline(statuslineLine(state, now), opts);
4211
+ }
4212
+ function statuslineLine(state, now = Date.now()) {
4213
+ const version = versionLabel(state.version);
4214
+ if (!state.configured)
4215
+ return { state: "off", version, note: "no instance configured" };
4216
+ const t = state.targets;
4217
+ if (!t)
4218
+ return { state: "off", version, note: "not recording this session" };
4219
+ const c = state.counts;
4220
+ const counted = c ? { events: c.events, tools: Object.values(c.tools).reduce((a, b) => a + b, 0) } : { events: 0 };
4221
+ const line = (state, rest) => ({
4222
+ state,
4223
+ version,
4224
+ ...counted,
4225
+ ...rest
4226
+ });
4227
+ const stores = t.stores?.length ? t.stores : null;
4228
+ if (!stores) {
4229
+ const where = whereLabel(t);
4230
+ const age = lastWrite(t.lastWriteMs, now);
4231
+ if (hookSilent(Math.max(t.lastWriteMs, t.lastFailureMs), state.transcriptMtimeMs)) {
4232
+ return line("stalled", { note: "hook silent", age, where });
4233
+ }
4234
+ if (t.lastFailureMs > t.lastWriteMs && now - t.lastWriteMs > STALL_AFTER_MS) {
4235
+ return line("stalled", { age, where });
4236
+ }
4237
+ if (t.lastWriteMs <= 0)
4238
+ return line("ready", { age: "no write yet", where });
4239
+ return line("rec", { age: ago(t.lastWriteMs, now), where });
4240
+ }
4241
+ const direct = stores.find((s) => s.kind === "direct") ?? stores[0];
4242
+ const headline = direct ?? { label: whereLabel(t), lastWriteMs: 0, lastFailureMs: 0 };
4243
+ const lastActivity = Math.max(...stores.flatMap((s) => [s.lastWriteMs, s.lastFailureMs]));
4244
+ if (hookSilent(lastActivity, state.transcriptMtimeMs)) {
4245
+ const newest = stores.reduce((a, b) => b.lastWriteMs > a.lastWriteMs ? b : a, headline);
4246
+ return line("stalled", {
4247
+ note: "hook silent",
4248
+ age: lastWrite(newest.lastWriteMs, now),
4249
+ where: newest.label
4250
+ });
4251
+ }
4252
+ if (direct && storeState(direct, now) === "healthy") {
4253
+ return line("rec", { age: ago(direct.lastWriteMs, now), where: direct.label });
4254
+ }
4255
+ const healthyMirrors = stores.filter((s) => s !== direct && storeState(s, now) === "healthy");
4256
+ const first = healthyMirrors[0];
4257
+ if (first) {
4258
+ return line("rec", {
4259
+ qualifier: "(mirror)",
4260
+ age: ago(first.lastWriteMs, now),
4261
+ where: first.label,
4262
+ ...healthyMirrors.length > 1 ? { more: `+${healthyMirrors.length - 1}` } : {}
4263
+ });
4264
+ }
4265
+ if (stores.some((s) => storeState(s, now) === "failing")) {
4266
+ const best = Math.max(...stores.map((s) => s.lastWriteMs));
4267
+ return line("stalled", { age: lastWrite(best, now), where: headline.label });
4268
+ }
4269
+ return line("ready", { age: "no write yet", where: headline.label });
4270
+ }
4271
+ var SGR = {
4272
+ reset: "\x1B[0m",
4273
+ dim: "\x1B[2m",
4274
+ green: "\x1B[32m",
4275
+ yellow: "\x1B[33m",
4276
+ cyan: "\x1B[36m"
4277
+ };
4278
+ var STATE_COLOR = {
4279
+ rec: SGR.green,
4280
+ stalled: SGR.yellow,
4281
+ ready: SGR.cyan,
4282
+ off: SGR.dim
4283
+ };
4284
+ var SHRINK = [
4285
+ ({ tools: _, ...l }) => l,
4286
+ (l) => l.where ? { ...l, where: shortWhere(l.where) } : l,
4287
+ ({ events: _, ...l }) => l,
4288
+ ({ age: _, ...l }) => l,
4289
+ ({ where: _, more: __, ...l }) => l
4290
+ ];
4291
+ function shortWhere(where) {
4292
+ const at = where.indexOf("@");
4293
+ if (at > 0)
4294
+ return where.slice(0, at);
4295
+ const host = where.startsWith("[") ? where.slice(0, where.indexOf("]") + 1) : where.split(":")[0];
4296
+ if (!host || host.startsWith("[") || /^\d+(\.\d+){3}$/.test(host))
4297
+ return host || where;
4298
+ return host.split(".")[0] || host;
4299
+ }
4300
+ function formatStatusline(line, opts = {}) {
4301
+ let l = line;
4302
+ if (opts.columns && opts.columns > 0) {
4303
+ for (const shrink of SHRINK) {
4304
+ if ([...draw(l, {})].length <= opts.columns)
4305
+ break;
4306
+ l = shrink(l);
4307
+ }
4308
+ }
4309
+ return draw(l, opts);
4310
+ }
4311
+ function draw(l, opts) {
4312
+ const paint = (code, s) => opts.color ? `${code}${s}${SGR.reset}` : s;
4313
+ const dim = (s) => paint(SGR.dim, s);
4314
+ const color = STATE_COLOR[l.state];
4315
+ const head = [
4316
+ paint(color, `${GLYPH[l.state]}`),
4317
+ l.version,
4318
+ paint(color, l.state),
4319
+ ...l.qualifier ? [paint(SGR.yellow, l.qualifier)] : []
4320
+ ].join(" ");
4321
+ const counts = l.events === undefined ? undefined : l.tools === undefined ? `${l.events} ev` : `${l.events} ev \xB7 ${l.tools} tools`;
4322
+ const details = [
4323
+ l.note && (l.note === "hook silent" ? paint(SGR.yellow, l.note) : l.note),
4324
+ counts && dim(counts),
4325
+ l.age && dim(l.age)
4326
+ ].filter(Boolean);
4327
+ const sep = dim(" \xB7 ");
4328
+ let out = [head, ...details].join(sep);
4329
+ if (l.where) {
4330
+ const label = opts.link ? osc8(opts.link, l.where) : l.where;
4331
+ out += `${dim(" \u2192")} ${label}${l.more ? ` ${l.more}` : ""}`;
4332
+ }
4333
+ return out;
4334
+ }
4335
+ function osc8(url, text) {
4336
+ return `\x1B]8;;${url}\x1B\\${text}\x1B]8;;\x1B\\`;
4337
+ }
4338
+
3888
4339
  // packages/cli/src/hook/runtime.ts
3889
4340
  import {
3890
4341
  closeSync,
3891
4342
  openSync,
3892
4343
  readFileSync as readFileSync4,
3893
4344
  readSync,
3894
- statSync,
4345
+ statSync as statSync2,
3895
4346
  unlinkSync,
3896
4347
  writeFileSync as writeFileSync2
3897
4348
  } from "fs";
@@ -4278,7 +4729,7 @@ function makeChunkState(sessionId) {
4278
4729
  return true;
4279
4730
  } catch {
4280
4731
  try {
4281
- if (Date.now() - statSync(lockFile).mtimeMs > STALE_LOCK_MS) {
4732
+ if (Date.now() - statSync2(lockFile).mtimeMs > STALE_LOCK_MS) {
4282
4733
  unlinkSync(lockFile);
4283
4734
  closeSync(openSync(lockFile, "wx"));
4284
4735
  return true;
@@ -4293,7 +4744,7 @@ function makeChunkState(sessionId) {
4293
4744
  } catch {}
4294
4745
  },
4295
4746
  readTail(path, offset) {
4296
- const size = statSync(path).size;
4747
+ const size = statSync2(path).size;
4297
4748
  if (size <= offset)
4298
4749
  return "";
4299
4750
  const fd = openSync(path, "r");
@@ -4369,20 +4820,19 @@ function commonFields(ctx) {
4369
4820
  }
4370
4821
 
4371
4822
  // packages/cli/src/hook/announce.ts
4372
- function sessionLink(webapiUrl, sessionId) {
4373
- return webapiUrl ? `${webapiUrl.replace(/\/$/, "")}/app/sessions/${sessionId}` : null;
4374
- }
4375
4823
  function recordingBanner(targets, sessionId) {
4376
- const where = [`couchdb://${targets.couchUrl.replace(/^https?:\/\//, "")}/${targets.sessionsDb}`];
4824
+ const also = [];
4377
4825
  if (targets.bucket)
4378
- where.push(`s3://${targets.bucket}`);
4826
+ also.push(`s3://${targets.bucket}`);
4379
4827
  if (targets.mirrors.length) {
4380
- where.push(`mirrors: ${targets.mirrors.map(hostOf).join(", ")}`);
4828
+ also.push(`mirrors: ${targets.mirrors.map(hostOf).join(", ")}`);
4381
4829
  }
4830
+ const head = `\u25CF Claude Transcripts recording \u2192 ${whereLabel(targets)}${also.length ? ` (+ ${also.join(", ")})` : ""}`;
4382
4831
  const link = sessionLink(targets.webapiUrl, sessionId);
4383
- return `Claude Transcripts \u2014 recording to ${where.join(" + ")}${link ? ` \xB7 ${link}` : ""}`;
4832
+ return link ? `${head}
4833
+ ${link}` : head;
4384
4834
  }
4385
- var NOT_RECORDING_BANNER = "Claude Transcripts \u2014 not recording (no instance configured). Run `claude-transcripts install`.";
4835
+ var NOT_RECORDING_BANNER = "\u25CB Claude Transcripts not recording \u2014 no instance configured. Run `claude-transcripts install`.";
4386
4836
  function sessionStartEnvelope(out) {
4387
4837
  if (!out.systemMessage && !out.additionalContext)
4388
4838
  return null;
@@ -4406,7 +4856,7 @@ function emitSessionStart(out) {
4406
4856
  }
4407
4857
 
4408
4858
  // packages/cli/src/hook/handlers.ts
4409
- import { readFileSync as readFileSync5, statSync as statSync2 } from "fs";
4859
+ import { readFileSync as readFileSync5, statSync as statSync3 } from "fs";
4410
4860
 
4411
4861
  // packages/cli/src/hook/recall.ts
4412
4862
  var FACTS_TIMEOUT_MS = 2000;
@@ -4480,7 +4930,7 @@ var seedSessionStart = async (ctx) => {
4480
4930
  };
4481
4931
  function fileSize(path) {
4482
4932
  try {
4483
- return path ? statSync2(path).size : null;
4933
+ return path ? statSync3(path).size : null;
4484
4934
  } catch {
4485
4935
  return null;
4486
4936
  }
@@ -4671,7 +5121,7 @@ var HANDLERS = {
4671
5121
 
4672
5122
  // packages/cli/src/hook/index.ts
4673
5123
  function hookBindings() {
4674
- const model = buildAppModel(loadAppConfig(), process.env);
5124
+ const model = buildAppBlueprint(loadAppConfig(), process.env);
4675
5125
  const out = {};
4676
5126
  for (const b of model.bindings)
4677
5127
  out[b.event] = b.actions;
@@ -4989,7 +5439,7 @@ async function runHook(argv) {
4989
5439
  }
4990
5440
 
4991
5441
  // packages/cli/src/commands/import.ts
4992
- import { existsSync as existsSync4, statSync as statSync3 } from "fs";
5442
+ import { existsSync as existsSync4, statSync as statSync4 } from "fs";
4993
5443
  import { join as join7 } from "path";
4994
5444
  var BATCH = 500;
4995
5445
  function num(n) {
@@ -5208,7 +5658,7 @@ async function runImport(argv) {
5208
5658
  const path = join7(dir, rel);
5209
5659
  await putStream(`/api/ingest/${sessionId}/transcript`, path, "application/x-ndjson");
5210
5660
  blobs++;
5211
- blobBytes += statSync3(path).size;
5661
+ blobBytes += statSync4(path).size;
5212
5662
  }
5213
5663
  console.log(` blobs ${blobs} restored, ${formatBytes(blobBytes)}${skipped ? ` (${skipped} skipped)` : ""}`);
5214
5664
  } else {
@@ -5286,17 +5736,20 @@ function composeFiles(paths, upstream) {
5286
5736
 
5287
5737
  // packages/cli/src/lib/compose.ts
5288
5738
  var {$ } = globalThis.Bun;
5739
+ function storeEnv() {
5740
+ return toStoreEnv(buildAppBlueprint(loadAppConfig(), {}));
5741
+ }
5289
5742
  function fileArgs(paths) {
5290
5743
  return composeFiles(paths, true).flatMap((f) => ["-f", f]);
5291
5744
  }
5292
5745
  async function compose(opts, args) {
5293
5746
  const profile = opts.app ? ["--profile", "app"] : [];
5294
- await $`docker compose --env-file ${opts.paths.instanceEnv} ${fileArgs(opts.paths)} ${profile} ${args}`.env({ ...process.env, ...opts.env });
5747
+ await $`docker compose --env-file ${opts.paths.instanceEnv} ${fileArgs(opts.paths)} ${profile} ${args}`.env({ ...process.env, ...storeEnv(), ...opts.env });
5295
5748
  }
5296
5749
  async function composeQuiet(opts, args) {
5297
5750
  const profile = opts.app ? ["--profile", "app"] : [];
5298
5751
  try {
5299
- const text = await $`docker compose --env-file ${opts.paths.instanceEnv} ${fileArgs(opts.paths)} ${profile} ${args}`.env({ ...process.env, ...opts.env }).quiet().text();
5752
+ const text = await $`docker compose --env-file ${opts.paths.instanceEnv} ${fileArgs(opts.paths)} ${profile} ${args}`.env({ ...process.env, ...storeEnv(), ...opts.env }).quiet().text();
5300
5753
  return { ok: true, text };
5301
5754
  } catch (err) {
5302
5755
  return { ok: false, text: String(err.stderr ?? err) };
@@ -5484,7 +5937,8 @@ var PORT_KEYS = [
5484
5937
  "GARAGE_ADMIN_PORT",
5485
5938
  "GARAGE_WEBUI_PORT",
5486
5939
  "MEILI_PORT",
5487
- "MEILI_UI_PORT"
5940
+ "MEILI_UI_PORT",
5941
+ "FOSSIL_PORT"
5488
5942
  ];
5489
5943
  function secret(bytes = 32) {
5490
5944
  return randomBytes(bytes).toString("hex");
@@ -5532,6 +5986,7 @@ function buildInstanceEnv(opts = {}) {
5532
5986
  GARAGE_UI_TAG: "1.1.0",
5533
5987
  MEILI_TAG: "v1.10",
5534
5988
  MEILI_UI_TAG: "latest",
5989
+ FOSSIL_TAG: "2.28",
5535
5990
  COUCHDB_URL: "",
5536
5991
  COUCHDB_HOST: "127.0.0.1",
5537
5992
  COUCHDB_USER: "admin",
@@ -5544,6 +5999,7 @@ function buildInstanceEnv(opts = {}) {
5544
5999
  GARAGE_ADMIN_TOKEN: secret(32),
5545
6000
  GARAGE_METRICS_TOKEN: secret(32),
5546
6001
  MEILI_HOST: `http://127.0.0.1:${ports.MEILI_PORT}`,
6002
+ FOSSIL_URL: `http://127.0.0.1:${ports.FOSSIL_PORT}`,
5547
6003
  MEILI_MASTER_KEY: opts.meiliMasterKey ? secret(24) : "",
5548
6004
  MEILI_API_KEY: ""
5549
6005
  };
@@ -5554,10 +6010,24 @@ var HEADER = [
5554
6010
  "Rotating a secret here does NOT rotate it in an already-provisioned store."
5555
6011
  ];
5556
6012
  var INSTALLER_OWNED = ["APP_TAG"];
6013
+ function backfillPorts(existing) {
6014
+ const base = Number(existing.WEBAPI_PORT);
6015
+ if (!Number.isInteger(base) || base <= 0)
6016
+ return {};
6017
+ const out = {};
6018
+ PORT_KEYS.forEach((key, i) => {
6019
+ if (existing[key] === undefined)
6020
+ out[key] = String(base + i);
6021
+ });
6022
+ if (out.FOSSIL_PORT && existing.FOSSIL_URL === undefined) {
6023
+ out.FOSSIL_URL = `http://127.0.0.1:${out.FOSSIL_PORT}`;
6024
+ }
6025
+ return out;
6026
+ }
5557
6027
  function loadOrCreateInstanceEnv(path, opts = {}) {
5558
6028
  const generated = buildInstanceEnv(opts);
5559
6029
  const existing = existsSync6(path) ? parseEnv(readFileSync10(path, "utf8")) : {};
5560
- const merged = { ...generated, ...existing };
6030
+ const merged = { ...generated, ...backfillPorts(existing), ...existing };
5561
6031
  for (const key of INSTALLER_OWNED) {
5562
6032
  if (generated[key] !== undefined)
5563
6033
  merged[key] = generated[key];
@@ -5859,96 +6329,6 @@ async function removeStaleKeys(api, keep) {
5859
6329
  // packages/cli/src/commands/statusline.ts
5860
6330
  import { existsSync as existsSync7, mkdirSync as mkdirSync7, readFileSync as readFileSync11, writeFileSync as writeFileSync7 } from "fs";
5861
6331
  import { dirname as dirname6 } from "path";
5862
-
5863
- // packages/cli/src/lib/statusline.ts
5864
- import { statSync as statSync4 } from "fs";
5865
- var STALL_AFTER_MS = 60000;
5866
- var HOOK_SILENT_AFTER_MS = 5 * 60000;
5867
- function transcriptMtimeMs(path) {
5868
- if (typeof path !== "string" || !path)
5869
- return null;
5870
- try {
5871
- return statSync4(path).mtimeMs;
5872
- } catch {
5873
- return null;
5874
- }
5875
- }
5876
- function hookSilent(lastActivityMs, transcriptMs) {
5877
- if (!transcriptMs || lastActivityMs <= 0)
5878
- return false;
5879
- return transcriptMs - lastActivityMs > HOOK_SILENT_AFTER_MS;
5880
- }
5881
- function lastWrite(ms, now) {
5882
- return ms > 0 ? `last write ${ago(ms, now)}` : "no write landed";
5883
- }
5884
- function ago(ms, now) {
5885
- const s = Math.max(0, Math.round((now - ms) / 1000));
5886
- if (s < 60)
5887
- return `${s}s ago`;
5888
- const m = Math.round(s / 60);
5889
- if (m < 60)
5890
- return `${m}m ago`;
5891
- return `${Math.round(m / 60)}h ago`;
5892
- }
5893
- function versionLabel(version = VERSION) {
5894
- return `ct@${version === DEV_VERSION ? "dev" : version}`;
5895
- }
5896
- function whereLabel(t) {
5897
- const host = t.couchUrl.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
5898
- return `${t.sessionsDb}@${host}`;
5899
- }
5900
- function storeState(s, now) {
5901
- if (s.lastFailureMs > s.lastWriteMs && now - s.lastWriteMs > STALL_AFTER_MS)
5902
- return "failing";
5903
- return s.lastWriteMs > 0 ? "healthy" : "idle";
5904
- }
5905
- function renderStatusline(state, now = Date.now()) {
5906
- const ct = versionLabel(state.version);
5907
- if (!state.configured)
5908
- return `\u25CB ${ct} off \xB7 no instance configured`;
5909
- const t = state.targets;
5910
- if (!t)
5911
- return `\u25CB ${ct} off \xB7 not recording this session`;
5912
- const c = state.counts;
5913
- const toolCalls = c ? Object.values(c.tools).reduce((a, b) => a + b, 0) : 0;
5914
- const counts = c ? `${c.events} ev \xB7 ${toolCalls} tools` : "0 ev";
5915
- const stores = t.stores?.length ? t.stores : null;
5916
- if (!stores) {
5917
- const last = lastWrite(t.lastWriteMs, now);
5918
- if (hookSilent(Math.max(t.lastWriteMs, t.lastFailureMs), state.transcriptMtimeMs)) {
5919
- return `\u25D0 ${ct} stalled \xB7 hook silent \xB7 ${counts} \xB7 ${last} \u2192 ${whereLabel(t)}`;
5920
- }
5921
- if (t.lastFailureMs > t.lastWriteMs && now - t.lastWriteMs > STALL_AFTER_MS) {
5922
- return `\u25D0 ${ct} stalled \xB7 ${counts} \xB7 ${last} \u2192 ${whereLabel(t)}`;
5923
- }
5924
- if (t.lastWriteMs <= 0)
5925
- return `\u25CC ${ct} ready \xB7 ${counts} \xB7 no write yet \u2192 ${whereLabel(t)}`;
5926
- return `\u25CF ${ct} rec \xB7 ${counts} \xB7 ${ago(t.lastWriteMs, now)} \u2192 ${whereLabel(t)}`;
5927
- }
5928
- const direct = stores.find((s) => s.kind === "direct") ?? stores[0];
5929
- const headline = direct ?? { label: whereLabel(t), lastWriteMs: 0, lastFailureMs: 0 };
5930
- const lastActivity = Math.max(...stores.flatMap((s) => [s.lastWriteMs, s.lastFailureMs]));
5931
- if (hookSilent(lastActivity, state.transcriptMtimeMs)) {
5932
- const newest = stores.reduce((a, b) => b.lastWriteMs > a.lastWriteMs ? b : a, headline);
5933
- return `\u25D0 ${ct} stalled \xB7 hook silent \xB7 ${counts} \xB7 ${lastWrite(newest.lastWriteMs, now)} \u2192 ${newest.label}`;
5934
- }
5935
- if (direct && storeState(direct, now) === "healthy") {
5936
- return `\u25CF ${ct} rec \xB7 ${counts} \xB7 ${ago(direct.lastWriteMs, now)} \u2192 ${direct.label}`;
5937
- }
5938
- const healthyMirrors = stores.filter((s) => s !== direct && storeState(s, now) === "healthy");
5939
- const first = healthyMirrors[0];
5940
- if (first) {
5941
- const more = healthyMirrors.length > 1 ? ` +${healthyMirrors.length - 1}` : "";
5942
- return `\u25CF ${ct} rec (mirror) \xB7 ${counts} \xB7 ${ago(first.lastWriteMs, now)} \u2192 ${first.label}${more}`;
5943
- }
5944
- if (stores.some((s) => storeState(s, now) === "failing")) {
5945
- const best = Math.max(...stores.map((s) => s.lastWriteMs));
5946
- return `\u25D0 ${ct} stalled \xB7 ${counts} \xB7 ${lastWrite(best, now)} \u2192 ${headline.label}`;
5947
- }
5948
- return `\u25CC ${ct} ready \xB7 ${counts} \xB7 no write yet \u2192 ${headline.label}`;
5949
- }
5950
-
5951
- // packages/cli/src/commands/statusline.ts
5952
6332
  function readSettings2(path) {
5953
6333
  try {
5954
6334
  return JSON.parse(readFileSync11(path, "utf8"));
@@ -5977,6 +6357,10 @@ async function render() {
5977
6357
  targets,
5978
6358
  counts: id ? makeCounts(id).read() : null,
5979
6359
  transcriptMtimeMs: targets ? transcriptMtimeMs(input.transcript_path) : null
6360
+ }, Date.now(), {
6361
+ color: !process.env.NO_COLOR,
6362
+ link: targets && id ? sessionLink(targets.webapiUrl, id) : null,
6363
+ columns: Number(process.env.COLUMNS) || undefined
5980
6364
  });
5981
6365
  process.stdout.write(`${line}
5982
6366
  `);
@@ -6088,6 +6472,29 @@ async function resolvePortBase(base, span) {
6088
6472
  }
6089
6473
  return null;
6090
6474
  }
6475
+ async function relocateAddedPorts(path, env, priorKeys) {
6476
+ const added = PORT_KEYS.filter((k) => !priorKeys.has(k));
6477
+ if (added.length === 0)
6478
+ return env;
6479
+ const own = new Set(PORT_KEYS.map((k) => Number(env[k])));
6480
+ const patch = {};
6481
+ for (const key of added) {
6482
+ let port = Number(env[key]);
6483
+ const isOwnDuplicate = PORT_KEYS.some((k) => k !== key && Number(env[k]) === port);
6484
+ if (!isOwnDuplicate && await isPortFree(port))
6485
+ continue;
6486
+ do
6487
+ port += 1;
6488
+ while (own.has(port) || !await isPortFree(port));
6489
+ own.add(port);
6490
+ patch[key] = String(port);
6491
+ console.log(` \u2192 ${key} ${env[key]} is taken; using ${port}`);
6492
+ }
6493
+ if (patch.FOSSIL_PORT && !priorKeys.has("FOSSIL_URL")) {
6494
+ patch.FOSSIL_URL = `http://127.0.0.1:${patch.FOSSIL_PORT}`;
6495
+ }
6496
+ return Object.keys(patch).length ? updateInstanceEnv(path, patch) : env;
6497
+ }
6091
6498
  async function warnOnVersionSkew(env) {
6092
6499
  try {
6093
6500
  const res = await fetch(`http://127.0.0.1:${env.WEBAPI_PORT}/health`, {
@@ -6159,12 +6566,15 @@ async function runInstall(argv) {
6159
6566
  }
6160
6567
  step(2, TOTAL, "Configuration");
6161
6568
  const firstRun = !existsSync8(paths.instanceEnv);
6569
+ const priorKeys = new Set(firstRun ? [] : Object.keys(parseEnv(readFileSync12(paths.instanceEnv, "utf8"))));
6162
6570
  let env = loadOrCreateInstanceEnv(paths.instanceEnv, {
6163
6571
  portBase,
6164
6572
  appTag: VERSION === DEV_VERSION ? "main" : VERSION,
6165
6573
  meiliMasterKey: meiliKey
6166
6574
  });
6167
6575
  console.log(` ${firstRun ? "generated" : "reused"} ${paths.instanceEnv}`);
6576
+ if (!firstRun)
6577
+ env = await relocateAddedPorts(paths.instanceEnv, env, priorKeys);
6168
6578
  if (!firstRun && priorAppTag && priorAppTag !== env.APP_TAG) {
6169
6579
  console.log(` app image pin ${priorAppTag} \u2192 ${env.APP_TAG} (follows this CLI)`);
6170
6580
  }