@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.
- package/out/headless/index.mjs +13663 -385
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +11665 -286
- package/out/main/node-CUnQxFha.js +868 -0
- package/out/preload/index.js +17 -0
- package/out/renderer/assets/{cssMode-CsSLnKZ-.js → cssMode-bQYoCKra.js} +3 -3
- package/out/renderer/assets/{freemarker2-C1XYRbOq.js → freemarker2-BS1ERQl_.js} +1 -1
- package/out/renderer/assets/{handlebars-BSdbbnMW.js → handlebars-B-ZEHRLr.js} +1 -1
- package/out/renderer/assets/{html-96igtyOz.js → html-lk9e1Lkl.js} +1 -1
- package/out/renderer/assets/{htmlMode-BRnCcIzp.js → htmlMode-Ds1eMYgM.js} +3 -3
- package/out/renderer/assets/{index-Cc-jDHm3.js → index-BJNR4hRK.js} +380 -23
- package/out/renderer/assets/{index-BEekQe3-.css → index-BpCORklm.css} +217 -0
- package/out/renderer/assets/{javascript-7MdRNHk9.js → javascript-D6OeziKX.js} +2 -2
- package/out/renderer/assets/{jsonMode-oGam2go_.js → jsonMode-t5SESE75.js} +3 -3
- package/out/renderer/assets/{liquid-DoKC7ba2.js → liquid-B5Wjh0p_.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-C2h5qGCv.js → lspLanguageFeatures-BEgiDCIW.js} +1 -1
- package/out/renderer/assets/{mdx-0w8n1HGo.js → mdx-ByNAXh8t.js} +1 -1
- package/out/renderer/assets/{python-BfY4HssB.js → python-CIFO0XQW.js} +1 -1
- package/out/renderer/assets/{razor-BoDk9Ula.js → razor-LXHhGcNO.js} +1 -1
- package/out/renderer/assets/{tsMode-CX0oVKRt.js → tsMode-WiO8CDaI.js} +1 -1
- package/out/renderer/assets/{typescript-BqIU7abP.js → typescript-ZBQlq256.js} +1 -1
- package/out/renderer/assets/{xml-BlSDnxFw.js → xml-DBaLJlQU.js} +1 -1
- package/out/renderer/assets/{yaml-bVMmGUtU.js → yaml-CId9skah.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +2 -1
- package/src/main/index.ts +36 -2
- package/src/main/observer/anthropic.ts +48 -0
- package/src/main/observer/budget.ts +79 -0
- package/src/main/observer/ledger.ts +61 -0
- package/src/main/observer/observer.ts +566 -0
- package/src/main/observer/spans.ts +170 -0
- package/src/main/observer/store.ts +200 -0
- package/src/main/observer/tier0.ts +234 -0
- package/src/main/observer/tier1.ts +306 -0
- package/src/main/observer/tier2.ts +125 -0
- package/src/main/observer/types.ts +224 -0
- package/src/main/orchestrator.ts +74 -0
- package/src/main/transcripts.ts +18 -0
- package/src/preload/index.ts +36 -0
- package/src/renderer/App.tsx +22 -0
- package/src/renderer/components/ObserverPanel.tsx +289 -0
- package/src/renderer/components/SettingsDialog.tsx +104 -0
- package/src/renderer/components/TerminalPanel.tsx +29 -0
- package/src/renderer/styles.css +217 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
|
|
4
|
+
import type { CtlsurfApi } from '../ctlsurfApi'
|
|
5
|
+
import { TranscriptTailer, supportsTranscriptLogging } from '../transcripts'
|
|
6
|
+
import { log as baseLog } from '../logger'
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
Finding,
|
|
10
|
+
LedgerEvent,
|
|
11
|
+
ModelCaller,
|
|
12
|
+
ObserverFeedItem,
|
|
13
|
+
ObserverSettings,
|
|
14
|
+
Sensitivity,
|
|
15
|
+
Span,
|
|
16
|
+
} from './types'
|
|
17
|
+
import { OBSERVER_DEFAULTS, SEVERITY_ORDER, severityFloor } from './types'
|
|
18
|
+
import { SessionLedger } from './ledger'
|
|
19
|
+
import { tagEvent, checkTestClaim, truncateForTier1 } from './tier0'
|
|
20
|
+
import { SpanTracker } from './spans'
|
|
21
|
+
import { Tier1Reviewer } from './tier1'
|
|
22
|
+
import { generateDigest } from './tier2'
|
|
23
|
+
import { ObserverBudget } from './budget'
|
|
24
|
+
import { ObserverStore } from './store'
|
|
25
|
+
import { createModelCaller } from './anthropic'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Observer — supervision layer over the session ledger (spec v0.1, Phase 1).
|
|
29
|
+
*
|
|
30
|
+
* Consumes the agent's native transcript (via its own TranscriptTailer) and
|
|
31
|
+
* pty-derived activity signals, normalizes them into a local append-only
|
|
32
|
+
* JSONL ledger, and produces two artifact streams:
|
|
33
|
+
* - findings (Tier 0 ground-truth + Tier 1 Haiku windowed review)
|
|
34
|
+
* - spans (deterministic time attribution + Tier 1 boundary detection)
|
|
35
|
+
* with a Tier 2 session-end digest delivered to the project's ctlsurf
|
|
36
|
+
* Agent Datastore page.
|
|
37
|
+
*
|
|
38
|
+
* Advisory only: nothing here can block or slow the acting agent, and every
|
|
39
|
+
* code path is wrapped so observation failures never break the session.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
function log(...args: unknown[]): void {
|
|
43
|
+
baseLog('[observer]', ...args)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface TabSession {
|
|
47
|
+
tabId: string
|
|
48
|
+
agentId: string
|
|
49
|
+
agentName: string
|
|
50
|
+
cwd: string
|
|
51
|
+
sessionId: string
|
|
52
|
+
startedAt: string
|
|
53
|
+
ledger: SessionLedger
|
|
54
|
+
tailer: TranscriptTailer | null
|
|
55
|
+
spans: SpanTracker
|
|
56
|
+
tier1: Tier1Reviewer | null
|
|
57
|
+
findings: Finding[]
|
|
58
|
+
closedSpans: Span[]
|
|
59
|
+
escalations: string[]
|
|
60
|
+
userMessages: string[]
|
|
61
|
+
/** Last AgentStateMonitor state, to detect busy → awaiting transitions. */
|
|
62
|
+
lastAgentState: string
|
|
63
|
+
ended: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ObserverOptions {
|
|
67
|
+
settingsDir: string
|
|
68
|
+
api: CtlsurfApi
|
|
69
|
+
getSettings: () => ObserverSettings
|
|
70
|
+
/** Live feed hook for the in-app Observer panel. */
|
|
71
|
+
onUpdate?: (item: ObserverFeedItem) => void
|
|
72
|
+
/** Model review in-flight signal — drives the panel's processing
|
|
73
|
+
* animation. Emitted on 0→1 and →0 transitions of concurrent reviews. */
|
|
74
|
+
onActivity?: (reviewing: boolean) => void
|
|
75
|
+
/** Override transcript roots (for tests). */
|
|
76
|
+
claudeProjectsDir?: string
|
|
77
|
+
codexSessionsDir?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const FEED_LIMIT = 200
|
|
81
|
+
|
|
82
|
+
export class Observer {
|
|
83
|
+
private readonly opts: ObserverOptions
|
|
84
|
+
private readonly store: ObserverStore
|
|
85
|
+
private tabs = new Map<string, TabSession>()
|
|
86
|
+
private budget: ObserverBudget | null = null
|
|
87
|
+
private budgetCapUsd = -1
|
|
88
|
+
private modelCaller: ModelCaller | null = null
|
|
89
|
+
private modelCallerKey = ''
|
|
90
|
+
/** Rolling feed for the in-app panel (survives tab close, not restart). */
|
|
91
|
+
private feed: ObserverFeedItem[] = []
|
|
92
|
+
private feedSeq = 0
|
|
93
|
+
/** Concurrent model reviews in flight (Tier 1 windows + Tier 2 digests). */
|
|
94
|
+
private activeReviews = 0
|
|
95
|
+
|
|
96
|
+
constructor(opts: ObserverOptions) {
|
|
97
|
+
this.opts = opts
|
|
98
|
+
this.store = new ObserverStore(opts.api)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
isEnabled(): boolean {
|
|
102
|
+
return !!this.opts.getSettings().enabled
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Feed + status snapshot for the in-app panel. */
|
|
106
|
+
getFeed(): {
|
|
107
|
+
items: ObserverFeedItem[]
|
|
108
|
+
enabled: boolean
|
|
109
|
+
hasApiKey: boolean
|
|
110
|
+
observing: Array<{ tabId: string; agentName: string; session: string }>
|
|
111
|
+
spentTodayUsd: number
|
|
112
|
+
reviewing: boolean
|
|
113
|
+
sensitivity: Sensitivity
|
|
114
|
+
} {
|
|
115
|
+
const settings = this.opts.getSettings()
|
|
116
|
+
return {
|
|
117
|
+
items: [...this.feed],
|
|
118
|
+
enabled: !!settings.enabled,
|
|
119
|
+
hasApiKey: !!(settings.anthropicApiKey || process.env.ANTHROPIC_API_KEY),
|
|
120
|
+
observing: [...this.tabs.values()].map((t) => ({ tabId: t.tabId, agentName: t.agentName, session: t.sessionId })),
|
|
121
|
+
spentTodayUsd: Math.round(this.ensureBudget().spent * 1000) / 1000,
|
|
122
|
+
reviewing: this.activeReviews > 0,
|
|
123
|
+
sensitivity: settings.sensitivity || OBSERVER_DEFAULTS.sensitivity,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Detach from all tabs without digests (used when the user flips the
|
|
128
|
+
* panel toggle off — quiet stop, not a session end). */
|
|
129
|
+
detachAll(): void {
|
|
130
|
+
for (const tabId of [...this.tabs.keys()]) this.detachSync(tabId)
|
|
131
|
+
this.pushFeed({ kind: 'status', title: 'Observer turned off' })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Begin observing a tab. No-op unless enabled and the agent has a native
|
|
135
|
+
* transcript (Claude Code / Codex) — Phase 1 scope. */
|
|
136
|
+
attachTab(tabId: string, agentId: string, agentName: string, cwd: string): void {
|
|
137
|
+
try {
|
|
138
|
+
if (!this.isEnabled()) return
|
|
139
|
+
if (this.tabs.has(tabId)) this.detachSync(tabId)
|
|
140
|
+
if (!supportsTranscriptLogging(agentId)) {
|
|
141
|
+
log(`Tab ${tabId}: agent "${agentId}" has no native transcript; observer idle for this tab`)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const settings = this.opts.getSettings()
|
|
146
|
+
const sessionId = `ses_${randomUUID().replace(/-/g, '').slice(0, 12)}`
|
|
147
|
+
const ledgerDir = path.join(this.opts.settingsDir, 'observer', 'ledger')
|
|
148
|
+
const ledger = new SessionLedger(ledgerDir, sessionId)
|
|
149
|
+
|
|
150
|
+
const tab: TabSession = {
|
|
151
|
+
tabId,
|
|
152
|
+
agentId,
|
|
153
|
+
agentName,
|
|
154
|
+
cwd,
|
|
155
|
+
sessionId,
|
|
156
|
+
startedAt: new Date().toISOString(),
|
|
157
|
+
ledger,
|
|
158
|
+
tailer: null,
|
|
159
|
+
spans: new SpanTracker(sessionId, (span) => this.onSpanClosed(tab, span)),
|
|
160
|
+
tier1: null,
|
|
161
|
+
findings: [],
|
|
162
|
+
closedSpans: [],
|
|
163
|
+
escalations: [],
|
|
164
|
+
userMessages: [],
|
|
165
|
+
lastAgentState: 'idle',
|
|
166
|
+
ended: false,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const caller = this.resolveModelCaller()
|
|
170
|
+
if (caller) {
|
|
171
|
+
tab.tier1 = new Tier1Reviewer({
|
|
172
|
+
session: sessionId,
|
|
173
|
+
model: settings.tier1Model || OBSERVER_DEFAULTS.tier1Model,
|
|
174
|
+
windowEvents: settings.windowEvents ?? OBSERVER_DEFAULTS.windowEvents,
|
|
175
|
+
debounceMs: (settings.debounceSec ?? OBSERVER_DEFAULTS.debounceSec) * 1000,
|
|
176
|
+
callModel: caller,
|
|
177
|
+
// Resolved lazily so a daily-cap change from the settings UI
|
|
178
|
+
// applies to sessions already running.
|
|
179
|
+
isOverBudget: () => this.ensureBudget().overCap(),
|
|
180
|
+
onUsage: (model, usage) => this.ensureBudget().addUsage(model, usage),
|
|
181
|
+
onFindings: (findings) => this.onFindings(tab, findings),
|
|
182
|
+
onSpanBoundary: (workRef, kind) => tab.spans.applyBoundary(workRef, kind),
|
|
183
|
+
onEscalate: (reason) => {
|
|
184
|
+
tab.escalations.push(reason)
|
|
185
|
+
log(`Tab ${tabId}: Tier 1 escalation — ${reason}`)
|
|
186
|
+
},
|
|
187
|
+
onHealth: (message) => this.emitHealth(tab, message),
|
|
188
|
+
onReviewState: (running) => this.noteReviewState(running),
|
|
189
|
+
getSensitivity: () => this.opts.getSettings().sensitivity || OBSERVER_DEFAULTS.sensitivity,
|
|
190
|
+
onReviewed: (eventCount, findingCount) =>
|
|
191
|
+
this.pushFeed({
|
|
192
|
+
kind: 'status',
|
|
193
|
+
tabId,
|
|
194
|
+
session: sessionId,
|
|
195
|
+
title: `Reviewed ${eventCount} event${eventCount === 1 ? '' : 's'} — ${findingCount === 0 ? 'nothing to flag' : `${findingCount} finding${findingCount === 1 ? '' : 's'}`}`,
|
|
196
|
+
}),
|
|
197
|
+
getSpanHypothesis: () => tab.spans.currentWorkRef(),
|
|
198
|
+
})
|
|
199
|
+
} else {
|
|
200
|
+
this.emitHealth(tab, 'No Anthropic API key configured (settings.observer.anthropicApiKey or ANTHROPIC_API_KEY); running Tier 0 + spans only.')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
tab.spans.begin()
|
|
204
|
+
tab.tailer = new TranscriptTailer({
|
|
205
|
+
agentId: agentId as 'claude' | 'codex',
|
|
206
|
+
cwd,
|
|
207
|
+
sink: (entry) => this.onChatEntry(tab, entry.type === 'user_input' ? 'user_message' : 'agent_message', entry.content, entry.ts),
|
|
208
|
+
rawSink: (obj) => this.onRawTranscriptLine(tab, obj),
|
|
209
|
+
claudeProjectsDir: this.opts.claudeProjectsDir,
|
|
210
|
+
codexSessionsDir: this.opts.codexSessionsDir,
|
|
211
|
+
})
|
|
212
|
+
tab.tailer.start()
|
|
213
|
+
this.tabs.set(tabId, tab)
|
|
214
|
+
log(`Observing tab ${tabId} (${agentId}) → ledger ${ledger.filePath}`)
|
|
215
|
+
this.pushFeed({
|
|
216
|
+
kind: 'status',
|
|
217
|
+
tabId,
|
|
218
|
+
session: sessionId,
|
|
219
|
+
title: `Observing ${agentName} (${tab.tier1 ? 'reviews when the agent hands control back' : 'Tier 0 + spans only — no API key'})`,
|
|
220
|
+
})
|
|
221
|
+
} catch (err: any) {
|
|
222
|
+
log(`attachTab failed: ${err?.message || err}`)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Human keystrokes reached the tab's pty (activity attribution only —
|
|
227
|
+
* content is never recorded here; typed prompts arrive via the transcript). */
|
|
228
|
+
noteHumanInput(tabId: string): void {
|
|
229
|
+
this.tabs.get(tabId)?.spans.noteHumanInput()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Agent busy/awaiting/idle state changed (from AgentStateMonitor). */
|
|
233
|
+
noteAgentState(tabId: string, state: string): void {
|
|
234
|
+
const tab = this.tabs.get(tabId)
|
|
235
|
+
if (!tab || tab.ended) return
|
|
236
|
+
tab.spans.noteAgentState(state)
|
|
237
|
+
const event = tab.ledger.append({ type: 'agent_state', ts: new Date().toISOString(), state })
|
|
238
|
+
tab.spans.noteEvent(event)
|
|
239
|
+
|
|
240
|
+
// Turn boundary: the agent just handed control back to the human — the
|
|
241
|
+
// same signal that drives the dock notification. Review what's buffered
|
|
242
|
+
// now instead of waiting for the window/debounce.
|
|
243
|
+
const wasBusy = tab.lastAgentState === 'busy'
|
|
244
|
+
const awaiting = state === 'awaiting_input' || state === 'awaiting_decision'
|
|
245
|
+
tab.lastAgentState = state
|
|
246
|
+
if (wasBusy && awaiting) tab.tier1?.reviewNow()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Session over: flush Tier 1, close spans, generate + deliver the digest.
|
|
250
|
+
* `projectPath` overrides cwd for ctlsurf delivery (remote-bound tabs). */
|
|
251
|
+
async endTab(tabId: string, projectPath?: string): Promise<void> {
|
|
252
|
+
const tab = this.tabs.get(tabId)
|
|
253
|
+
if (!tab || tab.ended) return
|
|
254
|
+
tab.ended = true
|
|
255
|
+
this.tabs.delete(tabId)
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
tab.tailer?.stop()
|
|
259
|
+
tab.tailer = null
|
|
260
|
+
await tab.tier1?.flush()
|
|
261
|
+
tab.tier1?.dispose()
|
|
262
|
+
tab.spans.end()
|
|
263
|
+
|
|
264
|
+
const settings = this.opts.getSettings()
|
|
265
|
+
const caller = this.resolveModelCaller()
|
|
266
|
+
const budget = this.ensureBudget()
|
|
267
|
+
this.noteReviewState(true)
|
|
268
|
+
let digest
|
|
269
|
+
try {
|
|
270
|
+
digest = await generateDigest(
|
|
271
|
+
{
|
|
272
|
+
session: tab.sessionId,
|
|
273
|
+
agentName: tab.agentName,
|
|
274
|
+
cwd: projectPath || tab.cwd,
|
|
275
|
+
startedAt: tab.startedAt,
|
|
276
|
+
endedAt: new Date().toISOString(),
|
|
277
|
+
findings: tab.findings,
|
|
278
|
+
spans: tab.closedSpans,
|
|
279
|
+
humanInstructionsSummary: tab.tier1?.instructionsSummary || '',
|
|
280
|
+
escalations: tab.escalations,
|
|
281
|
+
userMessages: tab.userMessages,
|
|
282
|
+
},
|
|
283
|
+
caller && !budget.overCap() ? caller : null,
|
|
284
|
+
settings.tier2Model || OBSERVER_DEFAULTS.tier2Model,
|
|
285
|
+
(model, usage) => budget.addUsage(model, usage),
|
|
286
|
+
)
|
|
287
|
+
} finally {
|
|
288
|
+
this.noteReviewState(false)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
tab.ledger.append({
|
|
292
|
+
type: 'observer.digest',
|
|
293
|
+
ts: new Date().toISOString(),
|
|
294
|
+
text: digest.digest,
|
|
295
|
+
payload: { from_model: digest.fromModel, findings: tab.findings.length, spans: tab.closedSpans.length },
|
|
296
|
+
})
|
|
297
|
+
this.pushFeed({
|
|
298
|
+
kind: 'digest',
|
|
299
|
+
tabId: tab.tabId,
|
|
300
|
+
session: tab.sessionId,
|
|
301
|
+
title: `Session digest (${tab.findings.length} finding${tab.findings.length === 1 ? '' : 's'}, ${tab.closedSpans.length} span${tab.closedSpans.length === 1 ? '' : 's'})`,
|
|
302
|
+
detail: digest.digest,
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
const deliveryPath = projectPath || tab.cwd
|
|
306
|
+
await this.store.appendDigest(deliveryPath, digest.digest, {
|
|
307
|
+
session: tab.sessionId,
|
|
308
|
+
agent: tab.agentName,
|
|
309
|
+
findings: tab.findings.length,
|
|
310
|
+
spans: tab.closedSpans.length,
|
|
311
|
+
ledger: tab.ledger.filePath,
|
|
312
|
+
})
|
|
313
|
+
log(`Tab ${tabId}: session ${tab.sessionId} closed — ${tab.findings.length} finding(s), ${tab.closedSpans.length} span(s)`)
|
|
314
|
+
} catch (err: any) {
|
|
315
|
+
log(`endTab failed for ${tabId}: ${err?.message || err}`)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async shutdown(): Promise<void> {
|
|
320
|
+
const ids = [...this.tabs.keys()]
|
|
321
|
+
await Promise.all(ids.map((id) => this.endTab(id)))
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ─── Event intake ───────────────────────────────
|
|
325
|
+
|
|
326
|
+
/** Clean chat text from the tailer's filtered sink. */
|
|
327
|
+
private onChatEntry(tab: TabSession, type: 'user_message' | 'agent_message', text: string, ts: string): void {
|
|
328
|
+
if (tab.ended) return
|
|
329
|
+
try {
|
|
330
|
+
if (type === 'user_message') tab.userMessages.push(text)
|
|
331
|
+
this.ingest(tab, { type, ts, text })
|
|
332
|
+
} catch (err: any) {
|
|
333
|
+
log(`onChatEntry failed: ${err?.message || err}`)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Raw transcript objects — used only for tool_use / tool_result, which the
|
|
338
|
+
* chat sink filters out. Message text arrives via onChatEntry. */
|
|
339
|
+
private onRawTranscriptLine(tab: TabSession, obj: any): void {
|
|
340
|
+
if (tab.ended || !obj || typeof obj !== 'object') return
|
|
341
|
+
try {
|
|
342
|
+
if (tab.agentId !== 'claude') return // Codex tool events: later phase
|
|
343
|
+
if (obj.isMeta || obj.isSidechain) return
|
|
344
|
+
if (typeof obj.cwd === 'string' && stripTrailingSep(obj.cwd) !== stripTrailingSep(tab.cwd)) return
|
|
345
|
+
const content = obj.message?.content
|
|
346
|
+
if (!Array.isArray(content)) return
|
|
347
|
+
const ts = typeof obj.timestamp === 'string' ? obj.timestamp : new Date().toISOString()
|
|
348
|
+
|
|
349
|
+
if (obj.type === 'assistant') {
|
|
350
|
+
for (const block of content) {
|
|
351
|
+
if (block?.type !== 'tool_use') continue
|
|
352
|
+
this.ingest(tab, {
|
|
353
|
+
type: 'tool_use',
|
|
354
|
+
ts,
|
|
355
|
+
tool: typeof block.name === 'string' ? block.name : 'unknown',
|
|
356
|
+
input: summarizeToolInput(block.input),
|
|
357
|
+
toolUseId: typeof block.id === 'string' ? block.id : undefined,
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
} else if (obj.type === 'user') {
|
|
361
|
+
for (const block of content) {
|
|
362
|
+
if (block?.type !== 'tool_result') continue
|
|
363
|
+
this.ingest(tab, {
|
|
364
|
+
type: 'tool_result',
|
|
365
|
+
ts,
|
|
366
|
+
toolUseId: typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined,
|
|
367
|
+
isError: !!block.is_error,
|
|
368
|
+
text: truncateForTier1(extractToolResultText(block.content)),
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
} catch (err: any) {
|
|
373
|
+
log(`onRawTranscriptLine failed: ${err?.message || err}`)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Tier 0 → ledger → spans → ground truth → Tier 1. */
|
|
378
|
+
private ingest(tab: TabSession, partial: Omit<LedgerEvent, 'id' | 'session' | 'tags'>): void {
|
|
379
|
+
const tags = tagEvent({ ...partial, id: '', session: tab.sessionId }, tab.cwd)
|
|
380
|
+
const event = tab.ledger.append({ ...partial, tags: tags.length ? tags : undefined })
|
|
381
|
+
tab.spans.noteEvent(event)
|
|
382
|
+
|
|
383
|
+
// Log-only "would have gated" (Phase 1): deterministic Tier-0 finding.
|
|
384
|
+
for (const tag of tags) {
|
|
385
|
+
if (tag.rule.startsWith('would_gate:') || tag.rule === 'secret_material') {
|
|
386
|
+
this.onFindings(tab, [{
|
|
387
|
+
type: 'observer.finding',
|
|
388
|
+
ts: event.ts,
|
|
389
|
+
category: tag.rule === 'secret_material' ? 'security' : 'destructive',
|
|
390
|
+
severity: tag.severity,
|
|
391
|
+
confidence: 0.95,
|
|
392
|
+
title: tag.rule === 'secret_material'
|
|
393
|
+
? `Secret-shaped string in session (${tag.note})`
|
|
394
|
+
: `Would have gated: ${tag.rule.slice('would_gate:'.length)}`,
|
|
395
|
+
detail: `${tag.note || ''} (gates are log-only in Phase 1)`.trim(),
|
|
396
|
+
receipts: [event.id],
|
|
397
|
+
tier: 0,
|
|
398
|
+
status: 'open',
|
|
399
|
+
delivery: ['digest'],
|
|
400
|
+
resolution: null,
|
|
401
|
+
}])
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Ground truth: "tests pass" claims are decidable from the ledger.
|
|
406
|
+
if (event.type === 'agent_message' && tags.some((t) => t.rule === 'claim_tests_pass')) {
|
|
407
|
+
const check = checkTestClaim(tab.ledger.recent(), event.id)
|
|
408
|
+
if (!check.verified) {
|
|
409
|
+
this.onFindings(tab, [{
|
|
410
|
+
type: 'observer.finding',
|
|
411
|
+
ts: event.ts,
|
|
412
|
+
category: 'unverified_claim',
|
|
413
|
+
severity: 'high',
|
|
414
|
+
confidence: 0.9,
|
|
415
|
+
title: 'Claimed tests pass; ledger disagrees',
|
|
416
|
+
detail: check.detail,
|
|
417
|
+
receipts: check.receipts,
|
|
418
|
+
tier: 0,
|
|
419
|
+
status: 'open',
|
|
420
|
+
delivery: ['digest'],
|
|
421
|
+
resolution: null,
|
|
422
|
+
}])
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
tab.tier1?.push(event)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ─── Outputs ────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
private onFindings(tab: TabSession, findings: Array<Omit<Finding, 'id' | 'session'>>): void {
|
|
432
|
+
const complete: Finding[] = findings.map((f) => {
|
|
433
|
+
const event = tab.ledger.append({
|
|
434
|
+
type: 'observer.finding',
|
|
435
|
+
ts: f.ts,
|
|
436
|
+
payload: f,
|
|
437
|
+
})
|
|
438
|
+
return { ...f, id: event.id, session: tab.sessionId }
|
|
439
|
+
})
|
|
440
|
+
tab.findings.push(...complete)
|
|
441
|
+
|
|
442
|
+
// Sensitivity lever: everything is in the ledger, but only findings at
|
|
443
|
+
// or above the floor surface in the panel and ctlsurf.
|
|
444
|
+
const floor = severityFloor(this.opts.getSettings().sensitivity || OBSERVER_DEFAULTS.sensitivity)
|
|
445
|
+
const surfaced = complete.filter((f) => SEVERITY_ORDER[f.severity] >= SEVERITY_ORDER[floor])
|
|
446
|
+
|
|
447
|
+
for (const f of surfaced) {
|
|
448
|
+
this.pushFeed({
|
|
449
|
+
kind: 'finding',
|
|
450
|
+
tabId: tab.tabId,
|
|
451
|
+
session: tab.sessionId,
|
|
452
|
+
title: f.title,
|
|
453
|
+
detail: f.detail,
|
|
454
|
+
category: f.category,
|
|
455
|
+
severity: f.severity,
|
|
456
|
+
confidence: f.confidence,
|
|
457
|
+
receipts: f.receipts,
|
|
458
|
+
tier: f.tier,
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
// Deliver as they happen so the supervisor surface stays near-real-time.
|
|
462
|
+
void this.store.addFindings(tab.cwd, surfaced)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private onSpanClosed(tab: TabSession, span: Span): void {
|
|
466
|
+
tab.closedSpans.push(span)
|
|
467
|
+
tab.ledger.append({ type: 'observer.span', ts: span.end, id: span.id, payload: span })
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private emitHealth(tab: TabSession, message: string): void {
|
|
471
|
+
log(`[health] ${message}`)
|
|
472
|
+
tab.ledger.append({ type: 'observer.health', ts: new Date().toISOString(), text: message })
|
|
473
|
+
this.pushFeed({ kind: 'health', tabId: tab.tabId, session: tab.sessionId, title: message })
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Track concurrent model calls; notify the panel on busy↔idle edges. */
|
|
477
|
+
private noteReviewState(running: boolean): void {
|
|
478
|
+
const wasActive = this.activeReviews > 0
|
|
479
|
+
this.activeReviews = Math.max(0, this.activeReviews + (running ? 1 : -1))
|
|
480
|
+
const isActive = this.activeReviews > 0
|
|
481
|
+
if (wasActive !== isActive) {
|
|
482
|
+
try { this.opts.onActivity?.(isActive) } catch { /* renderer gone */ }
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private pushFeed(item: Omit<ObserverFeedItem, 'id' | 'ts'>): void {
|
|
487
|
+
const complete: ObserverFeedItem = {
|
|
488
|
+
...item,
|
|
489
|
+
id: `feed_${++this.feedSeq}`,
|
|
490
|
+
ts: new Date().toISOString(),
|
|
491
|
+
}
|
|
492
|
+
this.feed.push(complete)
|
|
493
|
+
if (this.feed.length > FEED_LIMIT) this.feed.splice(0, this.feed.length - FEED_LIMIT)
|
|
494
|
+
try { this.opts.onUpdate?.(complete) } catch { /* renderer gone — ignore */ }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ─── Config plumbing ────────────────────────────
|
|
498
|
+
|
|
499
|
+
private resolveModelCaller(): ModelCaller | null {
|
|
500
|
+
const key = this.opts.getSettings().anthropicApiKey || process.env.ANTHROPIC_API_KEY || ''
|
|
501
|
+
if (!key) return null
|
|
502
|
+
if (!this.modelCaller || this.modelCallerKey !== key) {
|
|
503
|
+
this.modelCaller = createModelCaller(key)
|
|
504
|
+
this.modelCallerKey = key
|
|
505
|
+
}
|
|
506
|
+
return this.modelCaller
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private ensureBudget(): ObserverBudget {
|
|
510
|
+
const cap = this.opts.getSettings().dailyCapUsd ?? OBSERVER_DEFAULTS.dailyCapUsd
|
|
511
|
+
if (!this.budget || this.budgetCapUsd !== cap) {
|
|
512
|
+
// Recreating re-reads today's spend from disk, so a cap change (from
|
|
513
|
+
// the settings UI) applies without losing the day's accounting.
|
|
514
|
+
this.budget = new ObserverBudget(path.join(this.opts.settingsDir, 'observer'), cap)
|
|
515
|
+
this.budgetCapUsd = cap
|
|
516
|
+
}
|
|
517
|
+
return this.budget
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private detachSync(tabId: string): void {
|
|
521
|
+
const tab = this.tabs.get(tabId)
|
|
522
|
+
if (!tab) return
|
|
523
|
+
tab.ended = true
|
|
524
|
+
tab.tailer?.stop()
|
|
525
|
+
tab.tier1?.dispose()
|
|
526
|
+
this.tabs.delete(tabId)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ─── Helpers ──────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
function stripTrailingSep(p: string): string {
|
|
533
|
+
return p.length > 1 ? p.replace(/[/\\]+$/, '') : p
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Compact one-line summary of a tool_use input for the ledger. */
|
|
537
|
+
export function summarizeToolInput(input: unknown): string {
|
|
538
|
+
if (!input || typeof input !== 'object') return ''
|
|
539
|
+
const obj = input as Record<string, unknown>
|
|
540
|
+
if (typeof obj.command === 'string') return obj.command.slice(0, 600)
|
|
541
|
+
const parts: string[] = []
|
|
542
|
+
if (typeof obj.file_path === 'string') parts.push(obj.file_path)
|
|
543
|
+
if (typeof obj.pattern === 'string') parts.push(`pattern=${obj.pattern}`)
|
|
544
|
+
if (typeof obj.url === 'string') parts.push(obj.url)
|
|
545
|
+
if (typeof obj.description === 'string') parts.push(obj.description)
|
|
546
|
+
if (typeof obj.new_string === 'string' || typeof obj.old_string === 'string' || typeof obj.content === 'string') {
|
|
547
|
+
const body = `${obj.old_string || ''} -> ${obj.new_string || ''}${obj.content || ''}`
|
|
548
|
+
parts.push(body.slice(0, 400))
|
|
549
|
+
}
|
|
550
|
+
if (parts.length > 0) return parts.join(' ').slice(0, 600)
|
|
551
|
+
try {
|
|
552
|
+
return JSON.stringify(obj).slice(0, 400)
|
|
553
|
+
} catch {
|
|
554
|
+
return ''
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Extract text from a tool_result content value (string or block array). */
|
|
559
|
+
export function extractToolResultText(content: unknown): string {
|
|
560
|
+
if (typeof content === 'string') return content
|
|
561
|
+
if (!Array.isArray(content)) return ''
|
|
562
|
+
return content
|
|
563
|
+
.filter((b: any) => b?.type === 'text' && typeof b.text === 'string')
|
|
564
|
+
.map((b: any) => b.text)
|
|
565
|
+
.join('\n')
|
|
566
|
+
}
|