@phenx-inc/ctlsurf 0.17.2 → 0.17.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +25 -2
  2. package/bin/ctlsurf-worker.js +243 -44
  3. package/out/headless/index.mjs +1033 -128
  4. package/out/headless/index.mjs.map +4 -4
  5. package/out/main/index.js +933 -17
  6. package/out/preload/index.js +15 -0
  7. package/out/renderer/assets/{cssMode-BtQxI2Eb.js → cssMode-BJfoToLX.js} +3 -3
  8. package/out/renderer/assets/{freemarker2-DRW5dmY5.js → freemarker2-T0eMtP1Z.js} +1 -1
  9. package/out/renderer/assets/{handlebars-CFLF3cP7.js → handlebars-Dbbib8fi.js} +1 -1
  10. package/out/renderer/assets/{html-DtnBOkwp.js → html-BRHT_oxc.js} +1 -1
  11. package/out/renderer/assets/{htmlMode-DMVysDCg.js → htmlMode-BRlZTxME.js} +3 -3
  12. package/out/renderer/assets/{index-CEjwKjtd.css → index-CBWAeUYW.css} +91 -0
  13. package/out/renderer/assets/{index-Dm70hk5s.js → index-RCAeuJTr.js} +476 -411
  14. package/out/renderer/assets/{javascript-ChlyA1sf.js → javascript-BbOZOxq9.js} +2 -2
  15. package/out/renderer/assets/{jsonMode-D1_oHzeN.js → jsonMode-CUvAeYcT.js} +3 -3
  16. package/out/renderer/assets/{liquid-D4ftdy1G.js → liquid-DRIACo_s.js} +1 -1
  17. package/out/renderer/assets/{lspLanguageFeatures-Ckqu5ubG.js → lspLanguageFeatures-Ds-3nH9p.js} +1 -1
  18. package/out/renderer/assets/{mdx-B9wsVYSC.js → mdx-D76jlpLO.js} +1 -1
  19. package/out/renderer/assets/{python-e23tD6IS.js → python-CMuRPKJi.js} +1 -1
  20. package/out/renderer/assets/{razor-CrDyXj3d.js → razor-DRoH5mal.js} +1 -1
  21. package/out/renderer/assets/{tsMode-BwGC5o4O.js → tsMode-dPJzgFD7.js} +1 -1
  22. package/out/renderer/assets/{typescript-Do-98d_O.js → typescript-D5USKnLj.js} +1 -1
  23. package/out/renderer/assets/{xml-Dpdu5R3m.js → xml-qYg1wt2P.js} +1 -1
  24. package/out/renderer/assets/{yaml-B0Zt7LCJ.js → yaml-Uc-CvnvL.js} +1 -1
  25. package/out/renderer/index.html +2 -2
  26. package/package.json +4 -2
  27. package/scripts/check-work-map-gpu.cjs +62 -0
  28. package/scripts/check-work-map-gpu.d.cts +13 -0
  29. package/scripts/postinstall.cjs +44 -0
  30. package/scripts/prepare-work-map.py +49 -0
  31. package/scripts/setup-ui.cjs +161 -0
  32. package/scripts/test-work-map-capability.cjs +45 -0
  33. package/scripts/test-work-map-ui.cjs +130 -0
  34. package/scripts/test-work-map.cjs +352 -0
  35. package/scripts/work-map-clef.py +81 -0
  36. package/scripts/work-map-setup.cjs +127 -0
  37. package/src/main/index.ts +43 -1
  38. package/src/main/observer/observer.ts +2 -0
  39. package/src/main/orchestrator.ts +39 -4
  40. package/src/main/transcripts.ts +35 -16
  41. package/src/main/workMap/clef.ts +156 -0
  42. package/src/main/workMap/normalize.ts +172 -0
  43. package/src/main/workMap/source.ts +28 -0
  44. package/src/main/workMap/tree.ts +199 -0
  45. package/src/main/workMap/workMap.ts +244 -0
  46. package/src/preload/index.ts +17 -0
  47. package/src/renderer/App.tsx +15 -16
  48. package/src/renderer/components/ObserverPanel.tsx +3 -0
  49. package/src/renderer/components/SettingsDialog.tsx +0 -104
  50. package/src/renderer/components/WorkMapPanel.tsx +235 -0
  51. package/src/renderer/components/WorkMapToggle.tsx +50 -0
  52. package/src/renderer/lib/workMapHighlights.ts +41 -0
  53. package/src/renderer/styles.css +91 -0
  54. package/src/shared/workMap.d.ts +70 -0
@@ -15,6 +15,9 @@ import { TicketStore, type TicketInput } from './ticketStore'
15
15
  import { Observer } from './observer/observer'
16
16
  import { OBSERVER_DEFAULTS, type ObserverSettings } from './observer/types'
17
17
  import { log } from './logger'
18
+ import { WorkMap } from './workMap/workMap'
19
+ import type { MapCapability, MapSettings, MapSnapshot } from '../shared/workMap'
20
+ import { initialCapability } from '../../scripts/check-work-map-gpu.cjs'
18
21
 
19
22
  // ─── Types ────────────────────────────────────────
20
23
 
@@ -29,6 +32,10 @@ export interface Profile {
29
32
 
30
33
  const DEFAULT_IDLE_TIMEOUT_MIN = 15
31
34
 
35
+ // ON HOLD: the supervision feature is removed from the UX. Keep its engine,
36
+ // IPC and saved settings for future work, but never run it invisibly.
37
+ const OBSERVER_ON_HOLD = true
38
+
32
39
  export interface SettingsData {
33
40
  activeProfile: string
34
41
  profiles: Record<string, Profile>
@@ -39,10 +46,11 @@ export interface SettingsData {
39
46
  // Speak agent replies aloud (Electron desktop only). Drives the transcript
40
47
  // tailer the same way logChat does, independent of chat logging.
41
48
  speakReplies?: boolean
42
- // Observer supervision layer (advisory; off by default). See
49
+ // Observer supervision layer (ON HOLD; runtime disabled). See
43
50
  // src/main/observer/. Requires an Anthropic API key for Tiers 1–2;
44
51
  // without one it still records the ledger, Tier-0 tags, and spans.
45
52
  observer?: ObserverSettings
53
+ workMap?: Partial<MapSettings>
46
54
  // Mirror of the renderer's appearance preference (localStorage owns the
47
55
  // authoritative copy — index.html has to read it before first paint). Kept
48
56
  // here so createWindow can pick the right backgroundColor on the next launch
@@ -54,6 +62,8 @@ export interface SettingsData {
54
62
  }
55
63
 
56
64
  export interface OrchestratorEvents {
65
+ onWorkMapUpdate?: (snapshot: MapSnapshot) => void
66
+ onWorkMapCapability?: (capability: MapCapability) => void
57
67
  onPtyData: (tabId: string, data: string) => void
58
68
  onPtyExit: (tabId: string, code: number) => void
59
69
  onWorkerStatus: (status: string) => void
@@ -118,6 +128,7 @@ export class Orchestrator {
118
128
  readonly timeTracker = new TimeTracker(this.ctlsurfApi)
119
129
  readonly ticketStore = new TicketStore(this.ctlsurfApi)
120
130
  readonly observer: Observer
131
+ readonly workMap: WorkMap
121
132
 
122
133
  // State
123
134
  private tabs = new Map<string, TabState>()
@@ -139,14 +150,18 @@ export class Orchestrator {
139
150
  private readonly pendingSshPasswords = new Map<string, string>()
140
151
  private readonly pendingSshPasswordTimers = new Map<string, ReturnType<typeof setTimeout>>()
141
152
 
142
- constructor(settingsDir: string, events: OrchestratorEvents) {
153
+ constructor(settingsDir: string, events: OrchestratorEvents, options?: { appRoot?: string; desktop?: boolean }) {
143
154
  this.settingsDir = settingsDir
144
155
  this.events = events
156
+ this.workMap = new WorkMap(options?.appRoot || process.cwd(), snapshot => this.events.onWorkMapUpdate?.(snapshot), {}, initialCapability({ desktop: options?.desktop === true }))
145
157
 
146
158
  this.observer = new Observer({
147
159
  settingsDir,
148
160
  api: this.ctlsurfApi,
149
- getSettings: () => this.settings.observer || {},
161
+ getSettings: () => ({
162
+ ...this.settings.observer,
163
+ enabled: !OBSERVER_ON_HOLD && !!this.settings.observer?.enabled,
164
+ }),
150
165
  onUpdate: (item) => this.events.onObserverUpdate?.(item),
151
166
  onActivity: (reviewing) => this.events.onObserverActivity?.(reviewing),
152
167
  })
@@ -154,6 +169,7 @@ export class Orchestrator {
154
169
  this.stateMonitor = new AgentStateMonitor(({ tabId, state, anyAwaiting }) => {
155
170
  this.events.onAgentStateChange?.(tabId, state, anyAwaiting)
156
171
  this.observer.noteAgentState(tabId, state)
172
+ this.workMap.noteState(tabId, state)
157
173
  })
158
174
  this.stateMonitor.start()
159
175
 
@@ -235,6 +251,19 @@ export class Orchestrator {
235
251
  this.restartActiveChatLogging()
236
252
  }
237
253
 
254
+ configureWorkMap(data: Partial<MapSettings>): void {
255
+ const capability = this.workMap.getCapability()
256
+ if (data.enabled && capability.state !== 'available') throw new Error(capability.reason || 'Work map is unavailable on this machine.')
257
+ this.settings.workMap = this.workMap.configure(data)
258
+ this.saveSettings()
259
+ }
260
+
261
+ setWorkMapCapability(capability: MapCapability): void {
262
+ this.workMap.setCapability(capability)
263
+ this.workMap.configure({ ...this.settings.workMap, enabled: capability.state === 'available' && !!this.settings.workMap?.enabled })
264
+ this.events.onWorkMapCapability?.(capability)
265
+ }
266
+
238
267
  // ─── Observer settings ──────────────────────────
239
268
 
240
269
  /** Summary for the settings UI — never echoes the API key back. */
@@ -242,7 +271,7 @@ export class Orchestrator {
242
271
  const o = this.settings.observer || {}
243
272
  const keySource = o.anthropicApiKey ? 'settings' : process.env.ANTHROPIC_API_KEY ? 'env' : null
244
273
  return {
245
- enabled: !!o.enabled,
274
+ enabled: !OBSERVER_ON_HOLD && !!o.enabled,
246
275
  hasApiKey: keySource !== null,
247
276
  keySource,
248
277
  dailyCapUsd: o.dailyCapUsd ?? OBSERVER_DEFAULTS.dailyCapUsd,
@@ -428,6 +457,7 @@ export class Orchestrator {
428
457
 
429
458
  this.applyProfile(this.getActiveProfile())
430
459
  this.bridge.setLoggingEnabled(!!this.settings.logChat)
460
+ this.workMap.configure({ ...this.settings.workMap, enabled: !!this.events.onWorkMapUpdate && !!this.settings.workMap?.enabled })
431
461
  }
432
462
 
433
463
  saveSettings(): void {
@@ -597,6 +627,7 @@ export class Orchestrator {
597
627
  // Observer removes its tab synchronously before generating the digest;
598
628
  // time tracking must finish before a new session reuses the same tab id.
599
629
  void this.observer.endTab(tabId, existing.projectRootPath)
630
+ this.workMap.remove(tabId)
600
631
  existing.ptyManager.kill()
601
632
  await this.timeTracker.endSession(tabId)
602
633
  }
@@ -615,6 +646,7 @@ export class Orchestrator {
615
646
  // Track all tabs (not just the active one): a background agent finishing is
616
647
  // exactly when the dock indicator is most useful.
617
648
  this.stateMonitor.trackTab(tabId, agent.id, cwd)
649
+ this.workMap.attach(tabId, agent.id, agent.name, cwd)
618
650
 
619
651
  // Remote (SSH) tab with an in-app password waiting: feed it to ssh's prompt
620
652
  // once, directly over the PTY. The password is consumed here (removed from
@@ -644,6 +676,7 @@ export class Orchestrator {
644
676
  if (this.tabs.get(tabId) !== tab) return
645
677
  this.events.onPtyExit(tabId, exitCode)
646
678
  const observerEnd = this.observer.endTab(tabId, tab.projectRootPath)
679
+ this.workMap.end(tabId)
647
680
  await this.timeTracker.endSession(tabId)
648
681
  void observerEnd
649
682
  // The user can start a replacement while the tracking write above is in
@@ -704,6 +737,7 @@ export class Orchestrator {
704
737
 
705
738
  async killTab(tabId: string): Promise<void> {
706
739
  const tab = this.tabs.get(tabId)
740
+ this.workMap.remove(tabId)
707
741
  if (!tab) return
708
742
  if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer)
709
743
  await this.timeTracker.endSession(tabId)
@@ -933,6 +967,7 @@ export class Orchestrator {
933
967
  // ─── Shutdown ───────────────────────────────────
934
968
 
935
969
  async shutdown(): Promise<void> {
970
+ this.workMap.shutdown()
936
971
  this.stopNoProjectPolling()
937
972
  this.stopChatLogging()
938
973
  this.stateMonitor.stop()
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs'
2
2
  import path from 'path'
3
3
  import os from 'os'
4
+ import { StringDecoder } from 'string_decoder'
4
5
  import { log } from './logger'
5
6
 
6
7
  /**
@@ -46,7 +47,7 @@ export interface TranscriptTailerOptions {
46
47
  * by cwd. Used by the Observer to see tool_use/tool_result records the
47
48
  * chat sink discards. Must never throw.
48
49
  */
49
- rawSink?: (obj: unknown) => void
50
+ rawSink?: (obj: unknown, source: { filePath: string; sinceMs: number }) => void
50
51
  /** Override transcript roots (for tests). */
51
52
  claudeProjectsDir?: string
52
53
  codexSessionsDir?: string
@@ -56,19 +57,24 @@ const POLL_INTERVAL_MS = 1_000
56
57
  const DISCOVERY_SLACK_MS = 10_000
57
58
  const READ_CHUNK_BYTES = 64 * 1024
58
59
  const MAX_ENTRY_CHARS = 20_000
60
+ const MAX_READ_PER_POLL = 512 * 1024
61
+ const MAX_LINE_CHARS = 1024 * 1024
59
62
 
60
63
  interface FileTail {
61
64
  offset: number
62
65
  remainder: string
63
66
  /** Codex file whose session_meta cwd belongs to a different project. */
64
67
  excluded: boolean
68
+ decoder: StringDecoder
69
+ discardingLine: boolean
70
+ scoped: boolean
65
71
  }
66
72
 
67
73
  export class TranscriptTailer {
68
74
  private readonly agentId: TranscriptAgentId
69
75
  private readonly cwd: string
70
76
  private readonly sink: ChatLogSink
71
- private readonly rawSink?: (obj: unknown) => void
77
+ private readonly rawSink?: TranscriptTailerOptions['rawSink']
72
78
  private readonly claudeProjectsDir: string
73
79
  private readonly codexSessionsDir: string
74
80
 
@@ -140,7 +146,7 @@ export class TranscriptTailer {
140
146
  try {
141
147
  const stat = fs.statSync(filePath)
142
148
  if (stat.mtimeMs >= this.sinceMs - DISCOVERY_SLACK_MS) {
143
- this.files.set(filePath, { offset: 0, remainder: '', excluded: false })
149
+ this.files.set(filePath, { offset: 0, remainder: '', excluded: false, decoder: new StringDecoder('utf8'), discardingLine: false, scoped: this.agentId === 'claude' })
144
150
  }
145
151
  } catch { /* ignore — file may have vanished */ }
146
152
  }
@@ -177,7 +183,14 @@ export class TranscriptTailer {
177
183
  } catch {
178
184
  return
179
185
  }
180
- if (size <= tail.offset) return
186
+ if (size < tail.offset) {
187
+ tail.offset = 0
188
+ tail.remainder = ''
189
+ tail.decoder = new StringDecoder('utf8')
190
+ tail.discardingLine = false
191
+ tail.scoped = this.agentId === 'claude'
192
+ }
193
+ if (size === tail.offset) return
181
194
 
182
195
  let fd: number
183
196
  try {
@@ -187,18 +200,21 @@ export class TranscriptTailer {
187
200
  }
188
201
  try {
189
202
  const buf = Buffer.alloc(READ_CHUNK_BYTES)
190
- while (tail.offset < size && !tail.excluded) {
191
- const bytesRead = fs.readSync(fd, buf, 0, READ_CHUNK_BYTES, tail.offset)
203
+ const end = Math.min(size, tail.offset + MAX_READ_PER_POLL)
204
+ while (tail.offset < end && !tail.excluded) {
205
+ const bytesRead = fs.readSync(fd, buf, 0, Math.min(READ_CHUNK_BYTES, end - tail.offset), tail.offset)
192
206
  if (bytesRead <= 0) break
193
207
  tail.offset += bytesRead
194
- tail.remainder += buf.toString('utf-8', 0, bytesRead)
208
+ tail.remainder += tail.decoder.write(buf.subarray(0, bytesRead))
195
209
 
196
210
  const lines = tail.remainder.split('\n')
197
211
  tail.remainder = lines.pop() || ''
198
212
  for (const line of lines) {
199
- this.handleLine(line, tail)
213
+ if (tail.discardingLine) { tail.discardingLine = false; continue }
214
+ if (line.length <= MAX_LINE_CHARS) this.handleLine(line, tail, filePath)
200
215
  if (tail.excluded) break
201
216
  }
217
+ if (tail.remainder.length > MAX_LINE_CHARS) { tail.remainder = ''; tail.discardingLine = true }
202
218
  }
203
219
  } catch (err) {
204
220
  console.error(`[transcripts] Read error for ${filePath}:`, err)
@@ -207,7 +223,7 @@ export class TranscriptTailer {
207
223
  }
208
224
  }
209
225
 
210
- private handleLine(line: string, tail: FileTail): void {
226
+ private handleLine(line: string, tail: FileTail, filePath: string): void {
211
227
  const trimmed = line.trim()
212
228
  if (!trimmed) return
213
229
 
@@ -218,16 +234,18 @@ export class TranscriptTailer {
218
234
  return // tolerate partial/corrupt lines
219
235
  }
220
236
 
221
- // Raw hook (Observer): every parsed object except replayed history.
222
- // Codex lines from excluded files never reach here (drainFile stops).
237
+ // Scope the file before invoking raw consumers (including session_meta).
238
+ // Otherwise a different project's tool records could leak into its map.
239
+ const entry = this.agentId === 'claude' ? this.parseClaudeLine(obj) : this.parseCodexLine(obj, tail)
240
+ if (tail.excluded || !tail.scoped || obj?.isSidechain || obj?.isMeta) return
241
+ if (this.agentId === 'claude' && typeof obj?.cwd === 'string' && stripTrailingSep(obj.cwd) !== this.cwd) return
242
+
223
243
  if (this.rawSink) {
224
244
  const rawMs = typeof obj?.timestamp === 'string' ? Date.parse(obj.timestamp) : NaN
225
- if (!Number.isFinite(rawMs) || rawMs >= this.sinceMs - DISCOVERY_SLACK_MS) {
226
- try { this.rawSink(obj) } catch { /* observer errors must not break tailing */ }
245
+ if (obj?.type === 'session_meta' || !Number.isFinite(rawMs) || rawMs >= this.sinceMs - DISCOVERY_SLACK_MS) {
246
+ try { this.rawSink(obj, { filePath, sinceMs: this.sinceMs }) } catch { /* consumers must not break tailing */ }
227
247
  }
228
248
  }
229
-
230
- const entry = this.agentId === 'claude' ? this.parseClaudeLine(obj) : this.parseCodexLine(obj, tail)
231
249
  if (!entry) return
232
250
 
233
251
  // Skip history replayed into resumed/forked session files.
@@ -269,9 +287,10 @@ export class TranscriptTailer {
269
287
 
270
288
  if (obj.type === 'session_meta') {
271
289
  const metaCwd = payload?.cwd
272
- if (typeof metaCwd === 'string' && stripTrailingSep(metaCwd) !== this.cwd) {
290
+ if (typeof metaCwd !== 'string' || stripTrailingSep(metaCwd) !== this.cwd) {
273
291
  tail.excluded = true // another project's session sharing the date dir
274
292
  }
293
+ tail.scoped = !tail.excluded
275
294
  return null
276
295
  }
277
296
 
@@ -0,0 +1,156 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import type { MapSettings, MapSnapshot } from '../../shared/workMap'
5
+
6
+ export interface DecisionRequest {
7
+ model: string
8
+ state: string
9
+ questions: Record<string, { type: 'choice'; instructions: string; criteria: Record<string, string> }>
10
+ }
11
+
12
+ export interface Decision { choice: string; probabilities: Record<string, number> }
13
+
14
+ export function validateDecisions(value: any, req: DecisionRequest): Record<string, Decision> {
15
+ const answers = value?.answers
16
+ if (!answers || Object.keys(answers).sort().join() !== Object.keys(req.questions).sort().join()) throw new Error('Clef returned an invalid decision schema')
17
+ for (const [key, q] of Object.entries(req.questions)) {
18
+ const a = answers[key]
19
+ const allowed = Object.keys(q.criteria).sort()
20
+ if (!a || !allowed.includes(a.choice) || !a.probabilities || Object.keys(a.probabilities).sort().join() !== allowed.join()) throw new Error('Clef returned an unknown branch or activity')
21
+ const values = Object.values(a.probabilities) as number[]
22
+ if (values.some(p => typeof p !== 'number' || !Number.isFinite(p) || p < 0 || p > 1)
23
+ || Math.abs(values.reduce((s, p) => s + p, 0) - 1) > 0.002
24
+ || a.probabilities[a.choice] < Math.max(...values) - 0.000001) throw new Error('Clef returned invalid probabilities')
25
+ }
26
+ return answers
27
+ }
28
+
29
+ /** One isolated model process shared by all tabs; the caller owns its bounded queue. */
30
+ export class ClefWorker {
31
+ state: MapSnapshot['runtime'] = 'idle'
32
+ error: string | null = null
33
+ private child: ChildProcessWithoutNullStreams | null = null
34
+ private ready: Promise<void> | null = null
35
+ private rejectReady: ((err: Error) => void) | null = null
36
+ private pending: { id: number; resolve: (value: any) => void; reject: (err: Error) => void } | null = null
37
+ private seq = 0
38
+ private generation = 0
39
+ private idle: ReturnType<typeof setTimeout> | null = null
40
+ private loadTimeout: ReturnType<typeof setTimeout> | null = null
41
+ private requestTimeout: ReturnType<typeof setTimeout> | null = null
42
+ private stopping: Promise<void> = Promise.resolve()
43
+
44
+ constructor(private scriptPath: string, private onChange: () => void) {}
45
+
46
+ private change(state: MapSnapshot['runtime'], error: string | null = null): void {
47
+ this.state = state
48
+ this.error = error
49
+ this.onChange()
50
+ }
51
+
52
+ async predict(settings: MapSettings, request: DecisionRequest): Promise<Record<string, Decision>> {
53
+ const generation = this.generation
54
+ try {
55
+ await this.start(settings, generation)
56
+ if (!this.child || generation !== this.generation) throw new Error('Clef process stopped')
57
+ if (this.pending) throw new Error('Clef is already processing a batch')
58
+ if (this.idle) clearTimeout(this.idle)
59
+ this.change('thinking')
60
+ const response = await new Promise<any>((resolve, reject) => {
61
+ this.pending = { id: ++this.seq, resolve, reject }
62
+ this.requestTimeout = setTimeout(() => this.fail('Clef timed out; turn the map off and on to retry.'), 20_000)
63
+ this.child!.stdin.write(JSON.stringify({ id: this.seq, request }) + '\n', err => {
64
+ if (err) this.fail(`Clef input failed: ${err.message}`)
65
+ })
66
+ })
67
+ const answers = validateDecisions(response, request)
68
+ this.change('ready')
69
+ this.armIdle()
70
+ return answers
71
+ } catch (err) {
72
+ if (generation === this.generation) this.fail(err instanceof Error ? err.message : String(err))
73
+ throw err
74
+ }
75
+ }
76
+
77
+ private async start(settings: MapSettings, generation: number): Promise<void> {
78
+ await this.stopping
79
+ if (generation !== this.generation) throw new Error('Clef process stopped')
80
+ if (this.ready) return this.ready
81
+ if (process.platform !== 'darwin' || process.arch !== 'arm64') throw new Error('Local Clef-Flash requires an Apple Silicon Mac. Choose Rules on this machine.')
82
+ if (!fs.existsSync(settings.pythonPath) || !fs.existsSync(path.join(settings.modelPath, 'clef_mlx.py'))) {
83
+ throw new Error('Clef runtime not found. Open Local model setup and select the Python executable and downloaded Clef-Flash folder.')
84
+ }
85
+ this.change('loading')
86
+ const child = spawn(settings.pythonPath, ['-u', this.scriptPath, settings.modelPath], {
87
+ stdio: ['pipe', 'pipe', 'pipe'],
88
+ env: { ...process.env, HF_HUB_OFFLINE: '1', TRANSFORMERS_OFFLINE: '1', PYTHONDONTWRITEBYTECODE: '1' },
89
+ })
90
+ this.child = child
91
+ let buffer = ''
92
+ let stderr = ''
93
+ this.ready = new Promise<void>((resolve, reject) => {
94
+ this.rejectReady = reject
95
+ this.loadTimeout = setTimeout(() => this.fail('Clef did not load within 60 seconds.'), 60_000)
96
+ child.stdout.on('data', chunk => {
97
+ if (this.child !== child) return
98
+ buffer += String(chunk)
99
+ if (buffer.length > 128_000) { this.fail('Clef response exceeded its output budget'); return }
100
+ let newline: number
101
+ while ((newline = buffer.indexOf('\n')) >= 0) {
102
+ const line = buffer.slice(0, newline)
103
+ buffer = buffer.slice(newline + 1)
104
+ let msg: any
105
+ try { msg = JSON.parse(line) } catch { continue }
106
+ if (msg.ready) {
107
+ if (this.loadTimeout) clearTimeout(this.loadTimeout)
108
+ this.rejectReady = null
109
+ this.change('ready')
110
+ resolve()
111
+ } else if (msg.error) {
112
+ this.fail(String(msg.error))
113
+ } else if (msg.id === this.pending?.id) {
114
+ if (this.requestTimeout) clearTimeout(this.requestTimeout)
115
+ const pending = this.pending
116
+ this.pending = null
117
+ pending?.resolve(msg)
118
+ }
119
+ }
120
+ })
121
+ child.stderr.on('data', chunk => { stderr = (stderr + String(chunk)).slice(-1000) })
122
+ child.on('error', err => { if (this.child === child) this.fail(`Cannot start Clef: ${err.message}`) })
123
+ child.on('exit', () => { if (this.child === child) this.fail(`Clef stopped unexpectedly. ${stderr.slice(-300)}`) })
124
+ })
125
+ return this.ready
126
+ }
127
+
128
+ private armIdle(): void {
129
+ if (this.idle) clearTimeout(this.idle)
130
+ this.idle = setTimeout(() => this.stop(), 60_000)
131
+ this.idle.unref()
132
+ }
133
+
134
+ private fail(message: string): void {
135
+ this.stop(message)
136
+ this.change('error', message.slice(0, 600))
137
+ }
138
+
139
+ stop(reason = 'Clef stopped'): void {
140
+ ++this.generation
141
+ for (const timer of [this.idle, this.loadTimeout, this.requestTimeout]) if (timer) clearTimeout(timer)
142
+ this.idle = this.loadTimeout = this.requestTimeout = null
143
+ this.rejectReady?.(new Error(reason))
144
+ this.rejectReady = null
145
+ this.pending?.reject(new Error(reason))
146
+ this.pending = null
147
+ this.ready = null
148
+ const child = this.child
149
+ this.child = null
150
+ if (child) {
151
+ this.stopping = new Promise(resolve => child.once('close', () => resolve()))
152
+ child.kill('SIGKILL')
153
+ }
154
+ this.change('idle')
155
+ }
156
+ }
@@ -0,0 +1,172 @@
1
+ import { createHash } from 'crypto'
2
+ import path from 'path'
3
+ import type { MapActivity, MapEvidence, MapIntent, MapStatus } from '../../shared/workMap'
4
+
5
+ const textOf = (value: unknown): string => {
6
+ if (typeof value === 'string') return value
7
+ if (Array.isArray(value)) return value.map(b => b?.text || '').filter(Boolean).join('\n')
8
+ return ''
9
+ }
10
+ const compact = (value: unknown): string => {
11
+ if (typeof value === 'string') return value.slice(0, 1800)
12
+ try { return JSON.stringify(value ?? {}).slice(0, 1800) } catch { return '' }
13
+ }
14
+ const noise = (text: string): boolean => /^\s*(?:<environment_context>|<user_instructions>|<permissions instructions>|<system-reminder>|<local-command|<command-|<task-notification>|<turn_aborted>|# AGENTS\.md)/i.test(text)
15
+
16
+ const summaryText = (value: unknown): string => {
17
+ if (typeof value === 'string') return value
18
+ if (!Array.isArray(value)) return ''
19
+ return value.map(part => typeof part === 'string' ? part : part?.type === 'summary_text' ? part.text || '' : '').filter(Boolean).join('\n\n')
20
+ }
21
+
22
+ /** Read only the explicitly readable summary fields. Never inspect encrypted/raw content. */
23
+ function codexThinking(raw: any): { text: string; key: string } | null {
24
+ const p = raw?.payload
25
+ if (raw?.type === 'response_item' && p?.type === 'reasoning') {
26
+ const text = summaryText(p.summary)
27
+ return { text, key: p.id || text }
28
+ }
29
+ if (raw?.type === 'event_msg' && p?.type === 'agent_reasoning') {
30
+ const text = typeof p.text === 'string' ? p.text : ''
31
+ return { text, key: p.id || text }
32
+ }
33
+ const item = raw?.type === 'event_msg' && p?.type === 'item_completed' ? p.item
34
+ : raw?.method === 'item/completed' ? raw.params?.item : null
35
+ if (item?.type === 'Reasoning' || item?.type === 'reasoning') {
36
+ const text = summaryText(item.summary_text ?? item.summaryText ?? item.summary)
37
+ return { text, key: item.id || text }
38
+ }
39
+ return null
40
+ }
41
+
42
+ export function mapThinkingStatus(raw: any, agent: string): 'available' | 'unavailable' | null {
43
+ if (agent === 'claude') {
44
+ if (raw?.type !== 'assistant' || !Array.isArray(raw.message?.content)) return null
45
+ const blocks = raw.message.content.filter((b: any) => b?.type === 'thinking' || b?.type === 'redacted_thinking')
46
+ if (!blocks.length) return null
47
+ return blocks.some((b: any) => b.type === 'thinking' && typeof b.thinking === 'string' && b.thinking.trim()) ? 'available' : 'unavailable'
48
+ }
49
+ const thinking = codexThinking(raw)
50
+ return thinking ? thinking.text.trim() ? 'available' : 'unavailable' : null
51
+ }
52
+
53
+ /** Public messages, readable thinking summaries, and tool records. */
54
+ export function normalizeMapEvents(raw: any, file: string, agent: string, includeFullSource = false): MapEvidence[] {
55
+ if (!raw || typeof raw !== 'object' || raw.isSidechain || raw.isMeta) return []
56
+ const ts = typeof raw.timestamp === 'string' ? raw.timestamp : new Date().toISOString()
57
+ const source = path.basename(file)
58
+ const events: MapEvidence[] = []
59
+ const add = (kind: MapEvidence['kind'], text: string, key: string, extra: Partial<MapEvidence> = {}) => {
60
+ if (!text.trim() || ((kind === 'prompt' || kind === 'message') && noise(text))) return
61
+ const hash = createHash('sha256').update(`${file}:${key}:${kind}`).digest('hex').slice(0, 20)
62
+ const bounded = kind === 'thinking' && text.length > 1800 ? `${text.slice(0, 300)}\n…\n${text.slice(-1490)}` : text.trim().slice(0, 1800)
63
+ const segments = kind === 'thinking' || kind === 'message' ? decisionCandidates(text) : undefined
64
+ events.push({ id: hash, ts, kind, text: bounded, source, ...extra, ...(segments?.length ? { segments } : {}),
65
+ ...(includeFullSource && (kind === 'thinking' || kind === 'message') ? { fullText: text.trim() } : {}),
66
+ })
67
+ }
68
+ if (agent === 'claude') {
69
+ if (raw.type !== 'user' && raw.type !== 'assistant') return []
70
+ const blocks = raw.message?.content
71
+ const key = raw.uuid || raw.message?.id || `${ts}:${compact(blocks)}`
72
+ if (typeof blocks === 'string') add(raw.type === 'user' ? 'prompt' : 'message', blocks, key)
73
+ else if (Array.isArray(blocks)) blocks.forEach((b: any, i: number) => {
74
+ if (b.type === 'text') add(raw.type === 'user' ? 'prompt' : 'message', b.text || '', `${key}:${i}`)
75
+ if (raw.type === 'assistant' && b.type === 'thinking' && typeof b.thinking === 'string') add('thinking', b.thinking, `${key}:${i}`)
76
+ if (b.type === 'tool_use') add('tool', compact(b.input?.command ?? b.input?.file_path ?? b.input), b.id, { tool: b.name, toolId: b.id, status: 'running' })
77
+ if (b.type === 'tool_result') add('result', textOf(b.content) || 'Tool completed', b.tool_use_id, {
78
+ toolId: b.tool_use_id, status: b.is_error === true ? 'failed' : 'succeeded',
79
+ })
80
+ })
81
+ return events
82
+ }
83
+ const thinking = codexThinking(raw)
84
+ if (thinking) {
85
+ add('thinking', thinking.text, `reasoning:${thinking.key}`)
86
+ return events
87
+ }
88
+ const p = raw.payload
89
+ if (!p) return []
90
+ if (raw.type === 'event_msg') {
91
+ if (p.type === 'user_message' || p.type === 'agent_message') {
92
+ add(p.type === 'user_message' ? 'prompt' : 'message', textOf(p.message), `${ts}:${p.type}:${textOf(p.message)}`)
93
+ }
94
+ // New Codex app transcripts carry authoritative command/file results here.
95
+ const item = p.type === 'item_completed' ? p.item : null
96
+ if (item?.type === 'CommandExecution') {
97
+ const status: MapStatus = item.status === 'failed' || (typeof item.exit_code === 'number' && item.exit_code !== 0)
98
+ ? 'failed' : item.status === 'completed' && item.exit_code === 0 ? 'succeeded' : 'unknown'
99
+ const command = Array.isArray(item.command)
100
+ ? item.command.length >= 3 && /^-(?:l?c|cl)$/.test(item.command[1]) ? item.command.slice(2).join(' ') : item.command.join(' ')
101
+ : compact(item.command)
102
+ add('tool', command, item.id, { tool: 'shell', toolId: item.id, status: 'running' })
103
+ add('result', item.aggregated_output || `Command ended (${item.exit_code ?? item.status})`, item.id, { toolId: item.id, status })
104
+ }
105
+ if (item?.type === 'FileChange') {
106
+ add('tool', Object.keys(item.changes || {}).join('\n'), item.id, { tool: 'apply_patch', toolId: item.id, status: 'unknown' })
107
+ }
108
+ }
109
+ if (raw.type !== 'response_item') return events
110
+ if (p.type === 'message' && (p.role === 'user' || p.role === 'assistant')) {
111
+ const text = Array.isArray(p.content) ? p.content.filter((b: any) => ['input_text', 'output_text'].includes(b.type)).map((b: any) => b.text).join('\n') : ''
112
+ add(p.role === 'user' ? 'prompt' : 'message', text, p.id || `${ts}:${p.role}:${text}`)
113
+ }
114
+ if (p.type === 'function_call' || p.type === 'custom_tool_call') {
115
+ // The exec wrapper is orchestration code; CommandExecution records describe its actual work.
116
+ if (/^(?:functions\.)?exec$/.test(p.name)) return events
117
+ let input = p.arguments ?? p.input
118
+ try { const obj = JSON.parse(input); input = obj.cmd ?? obj.command ?? obj.file_path ?? obj } catch { /* plain custom-tool input */ }
119
+ add('tool', compact(input), p.call_id, { tool: p.name, toolId: p.call_id, status: 'running' })
120
+ }
121
+ if (p.type === 'function_call_output' || p.type === 'custom_tool_call_output') {
122
+ const output = textOf(p.output) || compact(p.output)
123
+ // Never infer success from prose; only explicit runtime metadata / exit codes.
124
+ const exit = output.match(/(?:Process exited with code|exit_code["']?\s*:)\s*(-?\d+)/i)
125
+ const status: MapStatus = p.is_error === true ? 'failed' : exit ? (Number(exit[1]) === 0 ? 'succeeded' : 'failed') : 'unknown'
126
+ add('result', output, p.call_id, { toolId: p.call_id, status })
127
+ }
128
+ return events
129
+ }
130
+
131
+ export function activityFor(e: MapEvidence): MapActivity {
132
+ if (e.kind === 'thinking') return 'thinking'
133
+ if (e.kind === 'prompt') return 'planning'
134
+ if (e.kind === 'message') return /\b(will|next|plan|investigat|checking)\b/i.test(e.text) ? 'planning' : 'reporting'
135
+ if (/edit|write|apply_patch|filechange/i.test(e.tool || '')) return 'editing'
136
+ if (/\b(?:pytest|vitest|jest|tsc|unittest|npm (?:run )?(?:test|build)|cargo test|go test|node\s+\S*test[-_.])\b/i.test(e.text)) return 'testing'
137
+ return 'investigating'
138
+ }
139
+
140
+ export function shortLabel(text: string, limit = 72): string {
141
+ const line = text.replace(/```[\s\S]*?```/g, '').replace(/^[\s#>*-]+/gm, '').replace(/\s+/g, ' ').trim()
142
+ return line.length > limit ? `${line.slice(0, limit - 1).trimEnd()}…` : line || 'Current work'
143
+ }
144
+
145
+ export function topicFor(e: MapEvidence): string {
146
+ if (e.kind === 'thinking') return 'Reasoning & decisions'
147
+ const paths = e.text.match(/(?:[\w.@-]+\/)+[\w.@-]+\.(?:tsx?|jsx?|py|json|css|md|rs|go|sh|cjs|mjs)\b/g)
148
+ if (paths?.length) return shortLabel(paths[0].split('/').slice(-2).join('/'), 48)
149
+ if (activityFor(e) === 'testing') return 'Build & tests'
150
+ if (e.kind === 'message') return 'Approach & findings'
151
+ return shortLabel(e.tool || 'Investigation', 48)
152
+ }
153
+
154
+ /** Candidates are exact excerpts, not invented descriptions of a thought. */
155
+ export function decisionCandidates(text: string): string[] {
156
+ const parts = text.split(/\n+|(?<=[.!?])\s+|,\s+(?=(?:so|because|since|rather|instead)\b)/i)
157
+ .map(s => s.replace(/^[\s#>*-]+/, '').replace(/\*\*/g, '').trim()).filter(s => s.length >= 15)
158
+ const score = (s: string) => (intentFor(s) !== 'context' ? 10 : 0)
159
+ + (/\b(?:choose|decid|instead|suspect|likely|next|prefer)\w*\b/i.test(s) ? 5 : 0)
160
+ + (/\b(?:because|since|so|avoid|tradeoff|due to|constraint|means)\b/i.test(s) ? 8 : 0)
161
+ return [...new Set(parts)].map((text, index) => ({ text: text.slice(0, 320), index, score: score(text) }))
162
+ .sort((a, b) => b.score - a.score || a.index - b.index).slice(0, 6).map(p => p.text)
163
+ }
164
+
165
+ export function intentFor(text: string): MapIntent {
166
+ if (/\b(?:suspect|hypothesis|likely|probably|perhaps|maybe|might|could be|seems)\b/i.test(text)) return 'hypothesis'
167
+ if (/\b(?:alternative|option|versus|weigh|either|could (?:use|try))\b/i.test(text)) return 'alternative'
168
+ if (/\b(?:choose|decided?|prefer|instead|use .+ rather than|let['’]s use)\b/i.test(text)) return 'decision'
169
+ if (/\b(?:building|implementing|keeping) (?:it |this )?(?:as|with|in)\b/i.test(text)) return 'decision'
170
+ if (/\b(?:I['’]ll|I will|we should|need to|next|going to|plan to|first (?:check|test|inspect|read))\b/i.test(text)) return 'next_step'
171
+ return 'context'
172
+ }
@@ -0,0 +1,28 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { StringDecoder } from 'string_decoder'
4
+ import type { MapSource } from '../../shared/workMap'
5
+ import { normalizeMapEvents } from './normalize'
6
+
7
+ /** Read only a recorded readable source event, without retaining entire transcripts. */
8
+ export async function readMapSource(file: string, agent: string, eventId: string): Promise<MapSource | null> {
9
+ const decoder = new StringDecoder('utf8')
10
+ let remainder = ''
11
+ let discard = false
12
+ const stream = fs.createReadStream(file, { highWaterMark: 64 * 1024 })
13
+ for await (const chunk of stream) {
14
+ remainder += decoder.write(chunk as Buffer)
15
+ const lines = remainder.split('\n')
16
+ remainder = lines.pop() || ''
17
+ for (const line of lines) {
18
+ if (discard) { discard = false; continue }
19
+ if (line.length > 1024 * 1024) continue
20
+ let raw
21
+ try { raw = JSON.parse(line) } catch { continue }
22
+ const event = normalizeMapEvents(raw, file, agent, true).find(e => e.id === eventId)
23
+ if (event?.fullText !== undefined) return { text: event.fullText, source: path.basename(file) }
24
+ }
25
+ if (remainder.length > 1024 * 1024) { remainder = ''; discard = true }
26
+ }
27
+ return null
28
+ }