@phenx-inc/ctlsurf 0.13.6 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/out/headless/index.mjs +13663 -385
  2. package/out/headless/index.mjs.map +4 -4
  3. package/out/main/index.js +11665 -286
  4. package/out/main/node-CUnQxFha.js +868 -0
  5. package/out/preload/index.js +17 -0
  6. package/out/renderer/assets/{cssMode-CsSLnKZ-.js → cssMode-bQYoCKra.js} +3 -3
  7. package/out/renderer/assets/{freemarker2-C1XYRbOq.js → freemarker2-BS1ERQl_.js} +1 -1
  8. package/out/renderer/assets/{handlebars-BSdbbnMW.js → handlebars-B-ZEHRLr.js} +1 -1
  9. package/out/renderer/assets/{html-96igtyOz.js → html-lk9e1Lkl.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-BRnCcIzp.js → htmlMode-Ds1eMYgM.js} +3 -3
  11. package/out/renderer/assets/{index-Cc-jDHm3.js → index-BJNR4hRK.js} +380 -23
  12. package/out/renderer/assets/{index-BEekQe3-.css → index-BpCORklm.css} +217 -0
  13. package/out/renderer/assets/{javascript-7MdRNHk9.js → javascript-D6OeziKX.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-oGam2go_.js → jsonMode-t5SESE75.js} +3 -3
  15. package/out/renderer/assets/{liquid-DoKC7ba2.js → liquid-B5Wjh0p_.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-C2h5qGCv.js → lspLanguageFeatures-BEgiDCIW.js} +1 -1
  17. package/out/renderer/assets/{mdx-0w8n1HGo.js → mdx-ByNAXh8t.js} +1 -1
  18. package/out/renderer/assets/{python-BfY4HssB.js → python-CIFO0XQW.js} +1 -1
  19. package/out/renderer/assets/{razor-BoDk9Ula.js → razor-LXHhGcNO.js} +1 -1
  20. package/out/renderer/assets/{tsMode-CX0oVKRt.js → tsMode-WiO8CDaI.js} +1 -1
  21. package/out/renderer/assets/{typescript-BqIU7abP.js → typescript-ZBQlq256.js} +1 -1
  22. package/out/renderer/assets/{xml-BlSDnxFw.js → xml-DBaLJlQU.js} +1 -1
  23. package/out/renderer/assets/{yaml-bVMmGUtU.js → yaml-CId9skah.js} +1 -1
  24. package/out/renderer/index.html +2 -2
  25. package/package.json +2 -1
  26. package/src/main/index.ts +36 -2
  27. package/src/main/observer/anthropic.ts +48 -0
  28. package/src/main/observer/budget.ts +79 -0
  29. package/src/main/observer/ledger.ts +61 -0
  30. package/src/main/observer/observer.ts +566 -0
  31. package/src/main/observer/spans.ts +170 -0
  32. package/src/main/observer/store.ts +200 -0
  33. package/src/main/observer/tier0.ts +234 -0
  34. package/src/main/observer/tier1.ts +306 -0
  35. package/src/main/observer/tier2.ts +125 -0
  36. package/src/main/observer/types.ts +224 -0
  37. package/src/main/orchestrator.ts +74 -0
  38. package/src/main/transcripts.ts +18 -0
  39. package/src/preload/index.ts +36 -0
  40. package/src/renderer/App.tsx +22 -0
  41. package/src/renderer/components/ObserverPanel.tsx +289 -0
  42. package/src/renderer/components/SettingsDialog.tsx +104 -0
  43. package/src/renderer/components/TerminalPanel.tsx +29 -0
  44. package/src/renderer/styles.css +217 -0
@@ -9768,3 +9768,220 @@ html, body, #root {
9768
9768
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
9769
9769
  pointer-events: none;
9770
9770
  }
9771
+
9772
+ /* ─── Observer pane ─────────────────────────────── */
9773
+
9774
+ /* Header on/off switch */
9775
+ .observer-switch { display: inline-flex; align-items: center; cursor: pointer; padding-top: 2px; }
9776
+ .observer-switch input { position: absolute; opacity: 0; width: 0; height: 0; }
9777
+ .observer-switch-track {
9778
+ width: 34px; height: 19px; border-radius: 10px;
9779
+ background: #3b4261; position: relative; transition: background 0.15s ease;
9780
+ display: inline-block; flex-shrink: 0;
9781
+ }
9782
+ .observer-switch-thumb {
9783
+ position: absolute; top: 2px; left: 2px; width: 15px; height: 15px;
9784
+ border-radius: 50%; background: #c0caf5; transition: transform 0.15s ease;
9785
+ }
9786
+ .observer-switch input:checked + .observer-switch-track { background: #9ece6a; }
9787
+ .observer-switch input:checked + .observer-switch-track .observer-switch-thumb {
9788
+ transform: translateX(15px); background: #1a1b26;
9789
+ }
9790
+ .observer-switch input:disabled + .observer-switch-track { opacity: 0.5; }
9791
+
9792
+ .obs-feed { gap: 6px; }
9793
+
9794
+ /* Finding / digest cards */
9795
+ .obs-card {
9796
+ position: relative;
9797
+ display: flex;
9798
+ flex-direction: column;
9799
+ gap: 5px;
9800
+ width: 100%;
9801
+ text-align: left;
9802
+ appearance: none;
9803
+ font: inherit;
9804
+ color: inherit;
9805
+ cursor: pointer;
9806
+ background: #15161f;
9807
+ border: 1px solid #262a3f;
9808
+ border-left: 3px solid #565f89;
9809
+ border-radius: 9px;
9810
+ padding: 9px 26px 9px 12px;
9811
+ transition: border-color 0.12s ease, background 0.12s ease;
9812
+ }
9813
+ .obs-card:hover { background: #181a26; border-color: #323752; border-left-width: 3px; }
9814
+ .obs-card:active { transform: scale(0.995); }
9815
+
9816
+ .obs-card-top {
9817
+ display: flex;
9818
+ align-items: center;
9819
+ gap: 8px;
9820
+ min-width: 0;
9821
+ }
9822
+ .obs-chip {
9823
+ display: inline-flex;
9824
+ align-items: center;
9825
+ gap: 5px;
9826
+ flex-shrink: 0;
9827
+ font-size: 9.5px;
9828
+ font-weight: 600;
9829
+ letter-spacing: 0.06em;
9830
+ text-transform: uppercase;
9831
+ padding: 2px 7px 2px 6px;
9832
+ border: 1px solid;
9833
+ border-radius: 999px;
9834
+ line-height: 1.4;
9835
+ }
9836
+ .obs-chip-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
9837
+ .obs-card-cat {
9838
+ font-size: 10px;
9839
+ color: #565f89;
9840
+ letter-spacing: 0.05em;
9841
+ text-transform: uppercase;
9842
+ white-space: nowrap;
9843
+ overflow: hidden;
9844
+ text-overflow: ellipsis;
9845
+ }
9846
+ .obs-card-time {
9847
+ margin-left: auto;
9848
+ flex-shrink: 0;
9849
+ font-size: 10px;
9850
+ color: #414868;
9851
+ font-variant-numeric: tabular-nums;
9852
+ }
9853
+ .obs-card-title {
9854
+ font-size: 12.5px;
9855
+ font-weight: 600;
9856
+ color: #c0caf5;
9857
+ line-height: 1.4;
9858
+ word-break: break-word;
9859
+ }
9860
+ .obs-card-body {
9861
+ font-size: 11.5px;
9862
+ color: #9aa5ce;
9863
+ line-height: 1.5;
9864
+ word-break: break-word;
9865
+ }
9866
+ .obs-card-body p { margin: 0 0 6px; white-space: pre-wrap; }
9867
+ .obs-card-facts { display: flex; flex-wrap: wrap; gap: 6px; }
9868
+ .obs-fact {
9869
+ font-size: 10px;
9870
+ color: #565f89;
9871
+ background: #1c1e2b;
9872
+ border: 1px solid #262a3f;
9873
+ border-radius: 5px;
9874
+ padding: 1px 6px;
9875
+ font-variant-numeric: tabular-nums;
9876
+ }
9877
+ .obs-card-caret {
9878
+ position: absolute;
9879
+ right: 10px;
9880
+ top: 10px;
9881
+ font-size: 10px;
9882
+ color: #414868;
9883
+ transition: transform 0.12s ease;
9884
+ }
9885
+ .obs-card-caret.open { transform: rotate(90deg); }
9886
+
9887
+ /* One-line heartbeats (reviews ran, attach/detach) and health warnings */
9888
+ .obs-line {
9889
+ display: flex;
9890
+ align-items: baseline;
9891
+ gap: 8px;
9892
+ padding: 3px 12px 3px 14px;
9893
+ font-size: 11px;
9894
+ color: #565f89;
9895
+ line-height: 1.45;
9896
+ }
9897
+ .obs-line-dot {
9898
+ flex-shrink: 0;
9899
+ width: 4px; height: 4px;
9900
+ border-radius: 50%;
9901
+ background: #3b4261;
9902
+ align-self: center;
9903
+ }
9904
+ .obs-line.health { color: #e0af68; }
9905
+ .obs-line.health .obs-line-dot { background: #e0af68; }
9906
+ .obs-line-text { min-width: 0; word-break: break-word; }
9907
+ .obs-line-time {
9908
+ margin-left: auto;
9909
+ flex-shrink: 0;
9910
+ font-size: 10px;
9911
+ color: #414868;
9912
+ font-variant-numeric: tabular-nums;
9913
+ }
9914
+
9915
+ /* Sensitivity lever + processing indicator row */
9916
+ .obs-toolbar {
9917
+ display: flex;
9918
+ align-items: center;
9919
+ justify-content: space-between;
9920
+ gap: 10px;
9921
+ padding: 8px 14px;
9922
+ border-bottom: 1px solid #23263a;
9923
+ }
9924
+ .obs-sensitivity {
9925
+ display: inline-flex;
9926
+ background: #15161f;
9927
+ border: 1px solid #262a3f;
9928
+ border-radius: 7px;
9929
+ padding: 2px;
9930
+ gap: 2px;
9931
+ }
9932
+ .obs-sens-btn {
9933
+ appearance: none;
9934
+ border: none;
9935
+ background: transparent;
9936
+ color: #565f89;
9937
+ font: inherit;
9938
+ font-size: 10.5px;
9939
+ font-weight: 600;
9940
+ padding: 3px 9px;
9941
+ border-radius: 5px;
9942
+ cursor: pointer;
9943
+ transition: background 0.12s ease, color 0.12s ease;
9944
+ white-space: nowrap;
9945
+ }
9946
+ .obs-sens-btn:hover { color: #9aa5ce; }
9947
+ .obs-sens-btn.active { background: #2a2f4a; color: #c0caf5; }
9948
+
9949
+ /* "reviewing" bouncing dots */
9950
+ .obs-reviewing {
9951
+ display: inline-flex;
9952
+ align-items: center;
9953
+ gap: 3px;
9954
+ font-size: 10.5px;
9955
+ color: #7aa2f7;
9956
+ letter-spacing: 0.03em;
9957
+ }
9958
+ .obs-reviewing-dot {
9959
+ width: 4px; height: 4px;
9960
+ border-radius: 50%;
9961
+ background: #7aa2f7;
9962
+ animation: obs-dot-bounce 1.1s ease-in-out infinite;
9963
+ }
9964
+ .obs-reviewing-dot:nth-child(2) { animation-delay: 0.15s; }
9965
+ .obs-reviewing-dot:nth-child(3) { animation-delay: 0.3s; margin-right: 4px; }
9966
+ @keyframes obs-dot-bounce {
9967
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
9968
+ 30% { transform: translateY(-3px); opacity: 1; }
9969
+ }
9970
+
9971
+ /* Pulsing eye badge while a model review is in flight */
9972
+ .obs-badge { position: relative; }
9973
+ .obs-badge.reviewing { color: #7aa2f7; }
9974
+ .obs-badge.reviewing::after {
9975
+ content: '';
9976
+ position: absolute;
9977
+ inset: -1px;
9978
+ border-radius: inherit;
9979
+ border: 1px solid #7aa2f7;
9980
+ animation: obs-badge-pulse 1.6s ease-out infinite;
9981
+ pointer-events: none;
9982
+ }
9983
+ @keyframes obs-badge-pulse {
9984
+ 0% { opacity: 0.9; transform: scale(1); }
9985
+ 70% { opacity: 0; transform: scale(1.35); }
9986
+ 100% { opacity: 0; transform: scale(1.35); }
9987
+ }
@@ -1,5 +1,5 @@
1
- import { conf as conf$1, language as language$1 } from "./typescript-BqIU7abP.js";
2
- import "./index-Cc-jDHm3.js";
1
+ import { conf as conf$1, language as language$1 } from "./typescript-ZBQlq256.js";
2
+ import "./index-BJNR4hRK.js";
3
3
  const conf = conf$1;
4
4
  const language = {
5
5
  // Set defaultToken to invalid to see what you do not tokenize yet
@@ -1,6 +1,6 @@
1
- import { c as createWebWorker, l as languages, e as editor } from "./index-Cc-jDHm3.js";
2
- import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-C2h5qGCv.js";
3
- import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-C2h5qGCv.js";
1
+ import { c as createWebWorker, l as languages, e as editor } from "./index-BJNR4hRK.js";
2
+ import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-BEgiDCIW.js";
3
+ import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-BEgiDCIW.js";
4
4
  const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
5
5
  class WorkerManager {
6
6
  constructor(defaults) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Cc-jDHm3.js";
1
+ import { l as languages } from "./index-BJNR4hRK.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-Cc-jDHm3.js";
1
+ import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-BJNR4hRK.js";
2
2
  var DocumentUri;
3
3
  (function(DocumentUri2) {
4
4
  function is(value) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Cc-jDHm3.js";
1
+ import { l as languages } from "./index-BJNR4hRK.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["{/*", "*/}"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Cc-jDHm3.js";
1
+ import { l as languages } from "./index-BJNR4hRK.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Cc-jDHm3.js";
1
+ import { l as languages } from "./index-BJNR4hRK.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-Cc-jDHm3.js";
1
+ import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-BJNR4hRK.js";
2
2
  class WorkerManager {
3
3
  constructor(_modeId, _defaults) {
4
4
  this._modeId = _modeId;
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Cc-jDHm3.js";
1
+ import { l as languages } from "./index-BJNR4hRK.js";
2
2
  const conf = {
3
3
  wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,
4
4
  comments: {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Cc-jDHm3.js";
1
+ import { l as languages } from "./index-BJNR4hRK.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Cc-jDHm3.js";
1
+ import { l as languages } from "./index-BJNR4hRK.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#"
@@ -49,8 +49,8 @@
49
49
  color: #565f89;
50
50
  }
51
51
  </style>
52
- <script type="module" crossorigin src="./assets/index-Cc-jDHm3.js"></script>
53
- <link rel="stylesheet" crossorigin href="./assets/index-BEekQe3-.css">
52
+ <script type="module" crossorigin src="./assets/index-BJNR4hRK.js"></script>
53
+ <link rel="stylesheet" crossorigin href="./assets/index-BpCORklm.css">
54
54
  </head>
55
55
  <body>
56
56
  <div id="boot-splash">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.13.6",
3
+ "version": "0.14.1",
4
4
  "description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
5
5
  "main": "out/main/index.js",
6
6
  "bin": {
@@ -43,6 +43,7 @@
43
43
  "node": ">=18"
44
44
  },
45
45
  "dependencies": {
46
+ "@anthropic-ai/sdk": "^0.110.0",
46
47
  "@huggingface/transformers": "^4.2.0",
47
48
  "@monaco-editor/react": "^4.7.0",
48
49
  "@xterm/addon-fit": "^0.10.0",
package/src/main/index.ts CHANGED
@@ -213,6 +213,8 @@ const orchestrator = new Orchestrator(
213
213
  onWorkerRegistered: (data) => mainWindow?.webContents.send('worker:registered', data),
214
214
  onProjectChanged: (name) => mainWindow?.webContents.send('app:projectChanged', name),
215
215
  onAgentMessage: (text) => mainWindow?.webContents.send('agent:message', text),
216
+ onObserverUpdate: (item) => mainWindow?.webContents.send('observer:update', item),
217
+ onObserverActivity: (reviewing) => mainWindow?.webContents.send('observer:activity', reviewing),
216
218
  onAgentStateChange: (tabId, state, anyAwaiting) => {
217
219
  log(`[agent-state] tab=${tabId} state=${state} anyAwaiting=${anyAwaiting}`)
218
220
  mainWindow?.webContents.send('agent:state', tabId, state, anyAwaiting)
@@ -619,6 +621,19 @@ ipcMain.handle('speak:set', (_event, enabled: boolean) => {
619
621
  orchestrator.setSpeakReplies(!!enabled)
620
622
  return { enabled: orchestrator.speakRepliesEnabled }
621
623
  })
624
+
625
+ // ─── Observer IPC ─────────────────────────────────
626
+
627
+ ipcMain.handle('observer:get', () => orchestrator.getObserverSettingsSummary())
628
+ ipcMain.handle('observer:set', (_event, data: { enabled: boolean; anthropicApiKey?: string; dailyCapUsd?: number; sensitivity?: 'severe' | 'balanced' | 'all' }) => {
629
+ orchestrator.saveObserverSettings(data)
630
+ return orchestrator.getObserverSettingsSummary()
631
+ })
632
+ ipcMain.handle('observer:feed', () => orchestrator.observer.getFeed())
633
+ ipcMain.handle('observer:toggle', (_event, enabled: boolean) => {
634
+ orchestrator.setObserverEnabled(!!enabled)
635
+ return orchestrator.observer.getFeed()
636
+ })
622
637
  // Latest assistant reply from the active tab's transcript, for on-demand TTS.
623
638
  ipcMain.handle('agent:getLastReply', () => orchestrator.getLastReply())
624
639
 
@@ -709,9 +724,28 @@ app.whenReady().then(() => {
709
724
  setInterval(() => { void checkForUpdate() }, UPDATE_CHECK_INTERVAL_MS)
710
725
  })
711
726
 
727
+ // Shutdown is async (final Tier-1 flush, session digest, time-tracker
728
+ // writes). Hold the quit until it settles — capped so a hung network call
729
+ // can never trap the process — otherwise that work is lost. before-quit
730
+ // covers Cmd+Q; window-all-closed covers closing the last window.
731
+ let shutdownStarted = false
732
+ function shutdownThenQuit(): void {
733
+ if (shutdownStarted) return
734
+ shutdownStarted = true
735
+ void Promise.race([
736
+ orchestrator.shutdown(),
737
+ new Promise((resolve) => setTimeout(resolve, 10_000)),
738
+ ]).finally(() => app.quit())
739
+ }
740
+
741
+ app.on('before-quit', (event) => {
742
+ if (shutdownStarted) return // second pass: let the quit proceed
743
+ event.preventDefault()
744
+ shutdownThenQuit()
745
+ })
746
+
712
747
  app.on('window-all-closed', () => {
713
- orchestrator.shutdown()
714
- app.quit()
748
+ shutdownThenQuit()
715
749
  })
716
750
 
717
751
  app.on('activate', () => {
@@ -0,0 +1,48 @@
1
+ import Anthropic from '@anthropic-ai/sdk'
2
+ import type { ModelCaller, ModelCallRequest, ModelCallResult } from './types'
3
+
4
+ /**
5
+ * Production ModelCaller backed by @anthropic-ai/sdk.
6
+ *
7
+ * The system block carries a cache_control marker so the 5-min prompt cache
8
+ * is refreshed by the Tier-1 call cadence (spec §3.2). Structured output
9
+ * (output_config.format json_schema) enforces the strict-JSON contract when
10
+ * a schema is provided.
11
+ */
12
+ export function createModelCaller(apiKey: string): ModelCaller {
13
+ const client = new Anthropic({ apiKey })
14
+
15
+ return async (req: ModelCallRequest): Promise<ModelCallResult> => {
16
+ const params: Record<string, unknown> = {
17
+ model: req.model,
18
+ max_tokens: req.maxTokens,
19
+ system: [
20
+ { type: 'text', text: req.system, cache_control: { type: 'ephemeral' } },
21
+ ],
22
+ messages: [{ role: 'user', content: req.user }],
23
+ }
24
+ if (req.schema) {
25
+ params.output_config = { format: { type: 'json_schema', schema: req.schema } }
26
+ }
27
+
28
+ // The SDK's create() is typed narrowly around output_config across
29
+ // versions; pass through as any to keep the wire shape authoritative.
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ const response: any = await (client.messages.create as any)(params)
32
+
33
+ const text = (response.content || [])
34
+ .filter((b: any) => b?.type === 'text' && typeof b.text === 'string')
35
+ .map((b: any) => b.text)
36
+ .join('')
37
+
38
+ return {
39
+ text,
40
+ usage: {
41
+ inputTokens: response.usage?.input_tokens ?? 0,
42
+ outputTokens: response.usage?.output_tokens ?? 0,
43
+ cacheReadTokens: response.usage?.cache_read_input_tokens ?? 0,
44
+ cacheWriteTokens: response.usage?.cache_creation_input_tokens ?? 0,
45
+ },
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,79 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+
4
+ /**
5
+ * Daily spend guard (spec §9). Tracks approximate model cost in a small
6
+ * JSON file; on cap breach Tier 1 degrades to Tier-0-tagged events only
7
+ * and an observer.health event is emitted by the caller.
8
+ */
9
+
10
+ interface Usage {
11
+ inputTokens: number
12
+ outputTokens: number
13
+ cacheReadTokens: number
14
+ cacheWriteTokens: number
15
+ }
16
+
17
+ /** $/MTok — input, output. Cache reads 0.1x input, writes 1.25x input. */
18
+ const PRICES: Record<string, { in: number; out: number }> = {
19
+ 'claude-haiku-4-5': { in: 1, out: 5 },
20
+ 'claude-sonnet-4-6': { in: 3, out: 15 },
21
+ 'claude-sonnet-5': { in: 3, out: 15 },
22
+ }
23
+ const DEFAULT_PRICE = { in: 3, out: 15 }
24
+
25
+ export function estimateCostUsd(model: string, usage: Usage): number {
26
+ const p = PRICES[model] || DEFAULT_PRICE
27
+ return (
28
+ (usage.inputTokens * p.in +
29
+ usage.outputTokens * p.out +
30
+ usage.cacheReadTokens * p.in * 0.1 +
31
+ usage.cacheWriteTokens * p.in * 1.25) /
32
+ 1_000_000
33
+ )
34
+ }
35
+
36
+ export class ObserverBudget {
37
+ private readonly filePath: string
38
+ private readonly capUsd: number
39
+ private date: string
40
+ private spentUsd = 0
41
+
42
+ constructor(dir: string, capUsd: number, now: () => Date = () => new Date()) {
43
+ this.filePath = path.join(dir, 'budget.json')
44
+ this.capUsd = capUsd
45
+ this.date = localDay(now())
46
+ try {
47
+ const raw = JSON.parse(fs.readFileSync(this.filePath, 'utf-8'))
48
+ if (raw?.date === this.date && typeof raw.spentUsd === 'number') {
49
+ this.spentUsd = raw.spentUsd
50
+ }
51
+ } catch { /* fresh day / first run */ }
52
+ }
53
+
54
+ addUsage(model: string, usage: Usage, now: () => Date = () => new Date()): void {
55
+ const today = localDay(now())
56
+ if (today !== this.date) {
57
+ this.date = today
58
+ this.spentUsd = 0
59
+ }
60
+ this.spentUsd += estimateCostUsd(model, usage)
61
+ try {
62
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
63
+ fs.writeFileSync(this.filePath, JSON.stringify({ date: this.date, spentUsd: this.spentUsd }))
64
+ } catch { /* best-effort */ }
65
+ }
66
+
67
+ overCap(now: () => Date = () => new Date()): boolean {
68
+ if (localDay(now()) !== this.date) return false // new day resets on next addUsage
69
+ return this.spentUsd >= this.capUsd
70
+ }
71
+
72
+ get spent(): number {
73
+ return this.spentUsd
74
+ }
75
+ }
76
+
77
+ function localDay(d: Date): string {
78
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
79
+ }
@@ -0,0 +1,61 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import type { LedgerEvent, LedgerEventType } from './types'
4
+
5
+ /**
6
+ * Session Ledger
7
+ *
8
+ * Append-only JSONL file per observed session — the substrate everything
9
+ * else consumes (spec §1, §6). Events get monotonic `evt_N` ids; Observer
10
+ * outputs (findings, spans, digests, health) are appended to the same file
11
+ * with their own id prefixes so receipts always resolve within the file.
12
+ *
13
+ * A bounded in-memory tail is kept for ground-truth lookback (e.g. "was a
14
+ * test command actually run since the last user message?").
15
+ */
16
+
17
+ const MEMORY_TAIL_EVENTS = 500
18
+
19
+ export class SessionLedger {
20
+ readonly sessionId: string
21
+ readonly filePath: string
22
+
23
+ private seq = 0
24
+ private tail: LedgerEvent[] = []
25
+
26
+ constructor(dir: string, sessionId: string) {
27
+ this.sessionId = sessionId
28
+ this.filePath = path.join(dir, `${sessionId}.jsonl`)
29
+ try { fs.mkdirSync(dir, { recursive: true }) } catch { /* ignore */ }
30
+ }
31
+
32
+ /** Append a normalized event; assigns id + session and persists it. */
33
+ append(partial: Omit<LedgerEvent, 'id' | 'session'> & { id?: string }): LedgerEvent {
34
+ const event: LedgerEvent = {
35
+ ...partial,
36
+ id: partial.id || this.nextId(partial.type),
37
+ session: this.sessionId,
38
+ }
39
+ this.tail.push(event)
40
+ if (this.tail.length > MEMORY_TAIL_EVENTS) {
41
+ this.tail.splice(0, this.tail.length - MEMORY_TAIL_EVENTS)
42
+ }
43
+ try {
44
+ fs.appendFileSync(this.filePath, JSON.stringify(event) + '\n')
45
+ } catch { /* best-effort: observation must never break the session */ }
46
+ return event
47
+ }
48
+
49
+ /** Recent events, oldest first (bounded to the in-memory tail). */
50
+ recent(limit = MEMORY_TAIL_EVENTS): LedgerEvent[] {
51
+ return limit >= this.tail.length ? [...this.tail] : this.tail.slice(-limit)
52
+ }
53
+
54
+ private nextId(type: LedgerEventType): string {
55
+ if (type === 'observer.finding') return `fnd_${this.sessionId.slice(4, 10)}_${++this.seq}`
56
+ if (type === 'observer.span') return `spn_${this.sessionId.slice(4, 10)}_${++this.seq}`
57
+ if (type === 'observer.digest') return `dig_${this.sessionId.slice(4, 10)}_${++this.seq}`
58
+ if (type === 'observer.health') return `hlt_${this.sessionId.slice(4, 10)}_${++this.seq}`
59
+ return `evt_${++this.seq}`
60
+ }
61
+ }