@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.
- package/README.md +25 -2
- package/bin/ctlsurf-worker.js +243 -44
- package/out/headless/index.mjs +1033 -128
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +933 -17
- package/out/preload/index.js +15 -0
- package/out/renderer/assets/{cssMode-BtQxI2Eb.js → cssMode-BJfoToLX.js} +3 -3
- package/out/renderer/assets/{freemarker2-DRW5dmY5.js → freemarker2-T0eMtP1Z.js} +1 -1
- package/out/renderer/assets/{handlebars-CFLF3cP7.js → handlebars-Dbbib8fi.js} +1 -1
- package/out/renderer/assets/{html-DtnBOkwp.js → html-BRHT_oxc.js} +1 -1
- package/out/renderer/assets/{htmlMode-DMVysDCg.js → htmlMode-BRlZTxME.js} +3 -3
- package/out/renderer/assets/{index-CEjwKjtd.css → index-CBWAeUYW.css} +91 -0
- package/out/renderer/assets/{index-Dm70hk5s.js → index-RCAeuJTr.js} +476 -411
- package/out/renderer/assets/{javascript-ChlyA1sf.js → javascript-BbOZOxq9.js} +2 -2
- package/out/renderer/assets/{jsonMode-D1_oHzeN.js → jsonMode-CUvAeYcT.js} +3 -3
- package/out/renderer/assets/{liquid-D4ftdy1G.js → liquid-DRIACo_s.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-Ckqu5ubG.js → lspLanguageFeatures-Ds-3nH9p.js} +1 -1
- package/out/renderer/assets/{mdx-B9wsVYSC.js → mdx-D76jlpLO.js} +1 -1
- package/out/renderer/assets/{python-e23tD6IS.js → python-CMuRPKJi.js} +1 -1
- package/out/renderer/assets/{razor-CrDyXj3d.js → razor-DRoH5mal.js} +1 -1
- package/out/renderer/assets/{tsMode-BwGC5o4O.js → tsMode-dPJzgFD7.js} +1 -1
- package/out/renderer/assets/{typescript-Do-98d_O.js → typescript-D5USKnLj.js} +1 -1
- package/out/renderer/assets/{xml-Dpdu5R3m.js → xml-qYg1wt2P.js} +1 -1
- package/out/renderer/assets/{yaml-B0Zt7LCJ.js → yaml-Uc-CvnvL.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +4 -2
- package/scripts/check-work-map-gpu.cjs +62 -0
- package/scripts/check-work-map-gpu.d.cts +13 -0
- package/scripts/postinstall.cjs +44 -0
- package/scripts/prepare-work-map.py +49 -0
- package/scripts/setup-ui.cjs +161 -0
- package/scripts/test-work-map-capability.cjs +45 -0
- package/scripts/test-work-map-ui.cjs +130 -0
- package/scripts/test-work-map.cjs +352 -0
- package/scripts/work-map-clef.py +81 -0
- package/scripts/work-map-setup.cjs +127 -0
- package/src/main/index.ts +43 -1
- package/src/main/observer/observer.ts +2 -0
- package/src/main/orchestrator.ts +39 -4
- package/src/main/transcripts.ts +35 -16
- package/src/main/workMap/clef.ts +156 -0
- package/src/main/workMap/normalize.ts +172 -0
- package/src/main/workMap/source.ts +28 -0
- package/src/main/workMap/tree.ts +199 -0
- package/src/main/workMap/workMap.ts +244 -0
- package/src/preload/index.ts +17 -0
- package/src/renderer/App.tsx +15 -16
- package/src/renderer/components/ObserverPanel.tsx +3 -0
- package/src/renderer/components/SettingsDialog.tsx +0 -104
- package/src/renderer/components/WorkMapPanel.tsx +235 -0
- package/src/renderer/components/WorkMapToggle.tsx +50 -0
- package/src/renderer/lib/workMapHighlights.ts +41 -0
- package/src/renderer/styles.css +91 -0
- package/src/shared/workMap.d.ts +70 -0
package/src/main/orchestrator.ts
CHANGED
|
@@ -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 (
|
|
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: () =>
|
|
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()
|
package/src/main/transcripts.ts
CHANGED
|
@@ -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?:
|
|
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
|
|
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
|
-
|
|
191
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
222
|
-
//
|
|
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 { /*
|
|
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
|
|
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
|
+
}
|