@claude-transcripts/cli 0.5.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",
@@ -1974,6 +1641,14 @@ var TOPOLOGY_EDGES = [
1974
1641
  level: "compact",
1975
1642
  requiresFeature: "s3Blobs"
1976
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
+ },
1977
1652
  {
1978
1653
  from: "webapi",
1979
1654
  to: "meilisearch",
@@ -1982,133 +1657,562 @@ var TOPOLOGY_EDGES = [
1982
1657
  level: "compact",
1983
1658
  requiresFeature: "meilisearch"
1984
1659
  },
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."
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
+ }
2080
+ },
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);
2095
+ },
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);
2108
+ },
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,
@@ -4846,7 +5121,7 @@ var HANDLERS = {
4846
5121
 
4847
5122
  // packages/cli/src/hook/index.ts
4848
5123
  function hookBindings() {
4849
- const model = buildAppModel(loadAppConfig(), process.env);
5124
+ const model = buildAppBlueprint(loadAppConfig(), process.env);
4850
5125
  const out = {};
4851
5126
  for (const b of model.bindings)
4852
5127
  out[b.event] = b.actions;
@@ -5461,17 +5736,20 @@ function composeFiles(paths, upstream) {
5461
5736
 
5462
5737
  // packages/cli/src/lib/compose.ts
5463
5738
  var {$ } = globalThis.Bun;
5739
+ function storeEnv() {
5740
+ return toStoreEnv(buildAppBlueprint(loadAppConfig(), {}));
5741
+ }
5464
5742
  function fileArgs(paths) {
5465
5743
  return composeFiles(paths, true).flatMap((f) => ["-f", f]);
5466
5744
  }
5467
5745
  async function compose(opts, args) {
5468
5746
  const profile = opts.app ? ["--profile", "app"] : [];
5469
- 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 });
5470
5748
  }
5471
5749
  async function composeQuiet(opts, args) {
5472
5750
  const profile = opts.app ? ["--profile", "app"] : [];
5473
5751
  try {
5474
- 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();
5475
5753
  return { ok: true, text };
5476
5754
  } catch (err) {
5477
5755
  return { ok: false, text: String(err.stderr ?? err) };
@@ -5659,7 +5937,8 @@ var PORT_KEYS = [
5659
5937
  "GARAGE_ADMIN_PORT",
5660
5938
  "GARAGE_WEBUI_PORT",
5661
5939
  "MEILI_PORT",
5662
- "MEILI_UI_PORT"
5940
+ "MEILI_UI_PORT",
5941
+ "FOSSIL_PORT"
5663
5942
  ];
5664
5943
  function secret(bytes = 32) {
5665
5944
  return randomBytes(bytes).toString("hex");
@@ -5707,6 +5986,7 @@ function buildInstanceEnv(opts = {}) {
5707
5986
  GARAGE_UI_TAG: "1.1.0",
5708
5987
  MEILI_TAG: "v1.10",
5709
5988
  MEILI_UI_TAG: "latest",
5989
+ FOSSIL_TAG: "2.28",
5710
5990
  COUCHDB_URL: "",
5711
5991
  COUCHDB_HOST: "127.0.0.1",
5712
5992
  COUCHDB_USER: "admin",
@@ -5719,6 +5999,7 @@ function buildInstanceEnv(opts = {}) {
5719
5999
  GARAGE_ADMIN_TOKEN: secret(32),
5720
6000
  GARAGE_METRICS_TOKEN: secret(32),
5721
6001
  MEILI_HOST: `http://127.0.0.1:${ports.MEILI_PORT}`,
6002
+ FOSSIL_URL: `http://127.0.0.1:${ports.FOSSIL_PORT}`,
5722
6003
  MEILI_MASTER_KEY: opts.meiliMasterKey ? secret(24) : "",
5723
6004
  MEILI_API_KEY: ""
5724
6005
  };
@@ -5729,10 +6010,24 @@ var HEADER = [
5729
6010
  "Rotating a secret here does NOT rotate it in an already-provisioned store."
5730
6011
  ];
5731
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
+ }
5732
6027
  function loadOrCreateInstanceEnv(path, opts = {}) {
5733
6028
  const generated = buildInstanceEnv(opts);
5734
6029
  const existing = existsSync6(path) ? parseEnv(readFileSync10(path, "utf8")) : {};
5735
- const merged = { ...generated, ...existing };
6030
+ const merged = { ...generated, ...backfillPorts(existing), ...existing };
5736
6031
  for (const key of INSTALLER_OWNED) {
5737
6032
  if (generated[key] !== undefined)
5738
6033
  merged[key] = generated[key];
@@ -6177,6 +6472,29 @@ async function resolvePortBase(base, span) {
6177
6472
  }
6178
6473
  return null;
6179
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
+ }
6180
6498
  async function warnOnVersionSkew(env) {
6181
6499
  try {
6182
6500
  const res = await fetch(`http://127.0.0.1:${env.WEBAPI_PORT}/health`, {
@@ -6248,12 +6566,15 @@ async function runInstall(argv) {
6248
6566
  }
6249
6567
  step(2, TOTAL, "Configuration");
6250
6568
  const firstRun = !existsSync8(paths.instanceEnv);
6569
+ const priorKeys = new Set(firstRun ? [] : Object.keys(parseEnv(readFileSync12(paths.instanceEnv, "utf8"))));
6251
6570
  let env = loadOrCreateInstanceEnv(paths.instanceEnv, {
6252
6571
  portBase,
6253
6572
  appTag: VERSION === DEV_VERSION ? "main" : VERSION,
6254
6573
  meiliMasterKey: meiliKey
6255
6574
  });
6256
6575
  console.log(` ${firstRun ? "generated" : "reused"} ${paths.instanceEnv}`);
6576
+ if (!firstRun)
6577
+ env = await relocateAddedPorts(paths.instanceEnv, env, priorKeys);
6257
6578
  if (!firstRun && priorAppTag && priorAppTag !== env.APP_TAG) {
6258
6579
  console.log(` app image pin ${priorAppTag} \u2192 ${env.APP_TAG} (follows this CLI)`);
6259
6580
  }