@phenx-inc/ctlsurf 0.17.2 → 0.17.3
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/out/headless/index.mjs +1033 -128
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +929 -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 +15 -0
- package/scripts/prepare-work-map.py +37 -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/src/main/index.ts +35 -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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { MapActivity, MapEvidence, MapIntent, MapNode } from '../../shared/workMap'
|
|
2
|
+
import type { DecisionRequest, Decision } from './clef'
|
|
3
|
+
import { activityFor, decisionCandidates, intentFor, shortLabel, topicFor } from './normalize'
|
|
4
|
+
|
|
5
|
+
const ACTIVITIES: Record<Exclude<MapActivity, 'thinking'>, string> = {
|
|
6
|
+
investigating: 'Reading files, searching, inspecting or diagnosing',
|
|
7
|
+
editing: 'Changing files, code, dependencies or configuration',
|
|
8
|
+
testing: 'Running tests, builds or validation',
|
|
9
|
+
planning: 'Discussing the approach or deciding what to do next',
|
|
10
|
+
reporting: 'Explaining findings or summarizing completed work',
|
|
11
|
+
}
|
|
12
|
+
const INTENTS: Record<MapIntent, string> = {
|
|
13
|
+
decision: 'A stated choice of approach or rejection of another approach',
|
|
14
|
+
hypothesis: 'An unverified explanation or suspicion to investigate',
|
|
15
|
+
alternative: 'Weighing different possible approaches without choosing yet',
|
|
16
|
+
next_step: 'A stated next action or check to perform',
|
|
17
|
+
context: 'General narration with no clear choice, hypothesis or next step',
|
|
18
|
+
}
|
|
19
|
+
export interface MapDecisionJob {
|
|
20
|
+
request: DecisionRequest
|
|
21
|
+
ids: string[]
|
|
22
|
+
goalId: string
|
|
23
|
+
statements: Record<string, string[]>
|
|
24
|
+
reasons: Record<string, string[]>
|
|
25
|
+
}
|
|
26
|
+
export const MAP_NODE_LIMIT = 240
|
|
27
|
+
|
|
28
|
+
/** Bounded evidence-backed tree. Model suggestions never change execution status. */
|
|
29
|
+
export class WorkMapTree {
|
|
30
|
+
nodes: MapNode[] = []
|
|
31
|
+
dropped = 0
|
|
32
|
+
private goal: string | null = null
|
|
33
|
+
private seq = 0
|
|
34
|
+
private seen = new Set<string>()
|
|
35
|
+
private tools = new Map<string, string>()
|
|
36
|
+
private lastText: { key: string; time: number } | null = null
|
|
37
|
+
private contextId: string | undefined
|
|
38
|
+
|
|
39
|
+
ingest(e: MapEvidence): string | null {
|
|
40
|
+
if (this.seen.has(e.id)) return null
|
|
41
|
+
this.seen.add(e.id)
|
|
42
|
+
if (this.seen.size > 1200) this.seen.delete(this.seen.values().next().value!)
|
|
43
|
+
if (e.kind === 'result') {
|
|
44
|
+
const id = this.tools.get(`${e.source}:${e.toolId}`)
|
|
45
|
+
const node = this.nodes.find(n => n.id === id)
|
|
46
|
+
if (node) { node.status = e.status || 'unknown'; node.evidence = [node.evidence[0], e] }
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
if (e.kind === 'prompt' || e.kind === 'message' || e.kind === 'thinking') {
|
|
50
|
+
const key = `${e.source}:${e.kind}:${e.text}`
|
|
51
|
+
const time = Date.parse(e.ts)
|
|
52
|
+
if (this.lastText?.key === key && Math.abs(time - this.lastText.time) < 3000) return null
|
|
53
|
+
this.lastText = { key, time }
|
|
54
|
+
}
|
|
55
|
+
if (e.kind === 'prompt') {
|
|
56
|
+
this.contextId = undefined
|
|
57
|
+
this.goal = this.add({ parentId: null, kind: 'goal', title: shortLabel(e.text, 110), activity: 'planning', status: 'unknown', ts: e.ts, evidence: [e] }).id
|
|
58
|
+
this.trim()
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
if (!this.goal) this.goal = this.add({ parentId: null, kind: 'goal', title: 'Session work', activity: 'planning', status: 'unknown', ts: e.ts, evidence: [] }).id
|
|
62
|
+
const topic = topicFor(e)
|
|
63
|
+
const branch = this.branch(this.goal, topic, e.ts)
|
|
64
|
+
const activity = activityFor(e)
|
|
65
|
+
const statement = e.kind === 'thinking' || e.kind === 'message' ? e.segments?.[0] || decisionCandidates(e.text)[0] || e.text : e.text
|
|
66
|
+
const title = e.kind === 'tool' ? `${e.tool || 'Tool'} · ${shortLabel(e.text, 65)}` : shortLabel(statement, 110)
|
|
67
|
+
const intent = e.kind === 'thinking' || e.kind === 'message' ? intentFor(statement) : undefined
|
|
68
|
+
const node = this.add({ parentId: branch.id, kind: 'step', title, activity, status: e.status || 'unknown', ts: e.ts, evidence: [e], placement: 'rules',
|
|
69
|
+
intent, ...(intent && intent !== 'context' ? { focus: { text: statement } } : {}),
|
|
70
|
+
...(e.kind === 'tool' && this.contextId ? { contextId: this.contextId } : {}),
|
|
71
|
+
})
|
|
72
|
+
if (e.kind === 'thinking' || e.kind === 'message') this.contextId = node.id
|
|
73
|
+
if (e.toolId) this.tools.set(`${e.source}:${e.toolId}`, node.id)
|
|
74
|
+
this.trim()
|
|
75
|
+
return node.id
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
end(): void {
|
|
79
|
+
for (const n of this.nodes) if (n.status === 'running') n.status = 'interrupted'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
request(ids: string[]): MapDecisionJob | null {
|
|
83
|
+
const nodes = ids.map(id => this.nodes.find(n => n.id === id && n.kind === 'step')).filter((n): n is MapNode => !!n)
|
|
84
|
+
if (!nodes.length) return null
|
|
85
|
+
const parent = this.nodes.find(n => n.id === nodes[0].parentId)
|
|
86
|
+
if (!parent?.parentId) return null
|
|
87
|
+
const goalId = parent.parentId
|
|
88
|
+
const sameGoal = nodes.filter(n => this.nodes.find(b => b.id === n.parentId)?.parentId === goalId)
|
|
89
|
+
// Richer thought questions get a whole bounded request; tool batches remain at most two.
|
|
90
|
+
const isIntent = (n: MapNode | undefined) => n?.evidence[0]?.kind === 'thinking' || n?.evidence[0]?.kind === 'message'
|
|
91
|
+
const selected = sameGoal.slice(0, isIntent(sameGoal[0]) || isIntent(sameGoal[1]) ? 1 : 2)
|
|
92
|
+
const goal = this.nodes.find(n => n.id === goalId)!
|
|
93
|
+
const branches = this.nodes.filter(n => n.kind === 'branch' && n.parentId === goalId).slice(-4)
|
|
94
|
+
const criteria: Record<string, string> = Object.fromEntries(branches.map(n => [n.id, n.title.slice(0, 48)]))
|
|
95
|
+
criteria.new_topic = 'A different work topic; create a new branch'
|
|
96
|
+
const questions: DecisionRequest['questions'] = {}
|
|
97
|
+
const statements: Record<string, string[]> = {}
|
|
98
|
+
const reasons: Record<string, string[]> = {}
|
|
99
|
+
selected.forEach((node, i) => {
|
|
100
|
+
questions[`branch_${i}`] = { type: 'choice', instructions: `Which work topic does event_${i} belong to? Use the goal and stated reasoning, not incidental file paths.`, criteria }
|
|
101
|
+
if (isIntent(node)) {
|
|
102
|
+
const candidates = node.evidence[0].segments || decisionCandidates(node.evidence[0].text)
|
|
103
|
+
statements[node.id] = candidates.length ? candidates : [node.evidence[0].text.slice(0, 220)]
|
|
104
|
+
questions[`statement_${i}`] = { type: 'choice', instructions: `Which excerpt best captures the agent's choice, hypothesis or next check in event_${i}?`,
|
|
105
|
+
criteria: Object.fromEntries(statements[node.id].map((s, j) => [`s${j}`, s])),
|
|
106
|
+
}
|
|
107
|
+
questions[`intent_${i}`] = { type: 'choice', instructions: `What does the selected excerpt express? A tentative explanation is not an established decision.`, criteria: INTENTS }
|
|
108
|
+
reasons[node.id] = candidates.filter(s => /\b(?:because|since|so|avoid|tradeoff|due to|constraint|rather|instead)\b/i.test(s)).slice(0, 3)
|
|
109
|
+
if (reasons[node.id].length) questions[`reason_${i}`] = { type: 'choice', instructions: 'Which excerpt states why the agent chose its approach? Choose none if no supporting reason is stated.',
|
|
110
|
+
criteria: { ...Object.fromEntries(reasons[node.id].map((s, j) => [`r${j}`, s])), none: 'No supporting reason stated' },
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
questions[`activity_${i}`] = { type: 'choice', instructions: `What activity does event_${i} show?`, criteria: ACTIVITIES }
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
return {
|
|
117
|
+
ids: selected.map(n => n.id), goalId, statements, reasons,
|
|
118
|
+
request: {
|
|
119
|
+
model: 'clef-flash',
|
|
120
|
+
state: JSON.stringify({ goal: goal.title, events: selected.map((n, i) => ({ id: `event_${i}`, kind: n.evidence[0]?.kind, tool: n.evidence[0]?.tool,
|
|
121
|
+
text: n.evidence[0]?.text.slice(0, 450),
|
|
122
|
+
preceding_intent: this.nodes.find(c => c.id === n.contextId)?.evidence[0]?.text.slice(-400),
|
|
123
|
+
current_topic: this.nodes.find(b => b.id === n.parentId)?.title,
|
|
124
|
+
})) }),
|
|
125
|
+
questions,
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
apply(job: Pick<MapDecisionJob, 'ids' | 'goalId' | 'statements' | 'reasons'>, answers: Record<string, Decision>): void {
|
|
131
|
+
job.ids.forEach((id, i) => {
|
|
132
|
+
const node = this.nodes.find(n => n.id === id)
|
|
133
|
+
if (!node || !this.nodes.some(n => n.id === job.goalId)) return
|
|
134
|
+
const decision = answers[`branch_${i}`]
|
|
135
|
+
const textSource = node.evidence[0]?.kind === 'thinking' || node.evidence[0]?.kind === 'message'
|
|
136
|
+
if (textSource) {
|
|
137
|
+
const choice = answers[`statement_${i}`]?.choice
|
|
138
|
+
const statement = job.statements[node.id]?.[Number(choice?.slice(1))]
|
|
139
|
+
if (statement) node.title = shortLabel(statement, 110)
|
|
140
|
+
// Joint decision heads can choose one excerpt but label another part
|
|
141
|
+
// of the window. Explicit wording in the chosen excerpt takes priority.
|
|
142
|
+
const statedIntent = intentFor(statement || '')
|
|
143
|
+
node.intent = statedIntent !== 'context' ? statedIntent : answers[`intent_${i}`]?.choice as MapIntent
|
|
144
|
+
const reasonChoice = answers[`reason_${i}`]?.choice
|
|
145
|
+
const reason = reasonChoice && reasonChoice !== 'none' ? job.reasons[node.id]?.[Number(reasonChoice.slice(1))] : undefined
|
|
146
|
+
node.focus = statement ? { text: statement, ...(reason && reason !== statement ? { reason } : {}) } : undefined
|
|
147
|
+
}
|
|
148
|
+
const topic = topicFor(node.evidence[0])
|
|
149
|
+
const newTitle = shortLabel(node.evidence[0].kind === 'thinking' ? node.title : node.evidence[0].text, 48)
|
|
150
|
+
const target = decision.choice === 'new_topic'
|
|
151
|
+
? this.branch(job.goalId, newTitle === topic ? `${node.activity} · ${topic}` : newTitle, node.ts)
|
|
152
|
+
: this.nodes.find(n => n.id === decision.choice && n.kind === 'branch' && n.parentId === job.goalId)
|
|
153
|
+
if (!target) return
|
|
154
|
+
node.parentId = target.id
|
|
155
|
+
node.activity = node.evidence[0]?.kind === 'thinking' ? 'thinking' : textSource ? node.intent === 'context' ? 'reporting' : 'planning' : answers[`activity_${i}`].choice as MapActivity
|
|
156
|
+
node.placement = 'clef-flash'
|
|
157
|
+
node.confidence = decision.probabilities[decision.choice]
|
|
158
|
+
})
|
|
159
|
+
this.removeEmptyBranches()
|
|
160
|
+
this.trim()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private add(node: Omit<MapNode, 'id'>): MapNode {
|
|
164
|
+
const result = { ...node, id: `map_${++this.seq}` }
|
|
165
|
+
this.nodes.push(result)
|
|
166
|
+
return result
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private branch(goal: string, title: string, ts: string): MapNode {
|
|
170
|
+
return this.nodes.find(n => n.kind === 'branch' && n.parentId === goal && n.title === title)
|
|
171
|
+
|| this.add({ parentId: goal, kind: 'branch', title, activity: 'planning', status: 'unknown', ts, evidence: [] })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private removeEmptyBranches(): void {
|
|
175
|
+
const parents = new Set(this.nodes.map(n => n.parentId))
|
|
176
|
+
this.nodes = this.nodes.filter(n => n.kind !== 'branch' || parents.has(n.id))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private trim(): void {
|
|
180
|
+
while (this.nodes.length > MAP_NODE_LIMIT) {
|
|
181
|
+
const oldGoal = this.nodes.find(n => n.kind === 'goal' && n.id !== this.goal)
|
|
182
|
+
if (oldGoal) {
|
|
183
|
+
const removed = new Set([oldGoal.id, ...this.nodes.filter(n => n.parentId === oldGoal.id).map(n => n.id)])
|
|
184
|
+
const before = this.nodes.length
|
|
185
|
+
this.nodes = this.nodes.filter(n => !removed.has(n.id) && !removed.has(n.parentId || ''))
|
|
186
|
+
this.dropped += before - this.nodes.length
|
|
187
|
+
} else {
|
|
188
|
+
const oldest = this.nodes.findIndex(n => n.kind === 'step')
|
|
189
|
+
if (oldest < 0) break
|
|
190
|
+
this.nodes.splice(oldest, 1)
|
|
191
|
+
this.dropped++
|
|
192
|
+
this.removeEmptyBranches()
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const retained = new Set(this.nodes.map(n => n.id))
|
|
196
|
+
if (this.contextId && !retained.has(this.contextId)) this.contextId = undefined
|
|
197
|
+
for (const [key, id] of this.tools) if (!retained.has(id)) this.tools.delete(key)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import type { MapCapability, MapSettings, MapSnapshot, MapSource } from '../../shared/workMap'
|
|
5
|
+
import { initialCapability } from '../../../scripts/check-work-map-gpu.cjs'
|
|
6
|
+
import { TranscriptTailer, supportsTranscriptLogging, type TranscriptTailerOptions } from '../transcripts'
|
|
7
|
+
import { ClefWorker } from './clef'
|
|
8
|
+
import { mapThinkingStatus, normalizeMapEvents } from './normalize'
|
|
9
|
+
import { WorkMapTree } from './tree'
|
|
10
|
+
import { readMapSource } from './source'
|
|
11
|
+
|
|
12
|
+
interface MapTab {
|
|
13
|
+
agentId: string
|
|
14
|
+
agentName: string
|
|
15
|
+
cwd: string
|
|
16
|
+
tree: WorkMapTree
|
|
17
|
+
tailer: TranscriptTailer | null
|
|
18
|
+
ended: boolean
|
|
19
|
+
state: string
|
|
20
|
+
source: string | null
|
|
21
|
+
ignored: Set<string>
|
|
22
|
+
pending: Set<string>
|
|
23
|
+
thinkingStatus: MapSnapshot['thinkingStatus']
|
|
24
|
+
sources: Map<string, string>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class WorkMap {
|
|
28
|
+
private tabs = new Map<string, MapTab>()
|
|
29
|
+
private owners = new Map<string, string>()
|
|
30
|
+
private settings: MapSettings
|
|
31
|
+
private clef: ClefWorker
|
|
32
|
+
private revision = 0
|
|
33
|
+
private epoch = 0
|
|
34
|
+
private timer: ReturnType<typeof setTimeout> | null = null
|
|
35
|
+
private publishing: ReturnType<typeof setTimeout> | null = null
|
|
36
|
+
private working = false
|
|
37
|
+
private round = 0
|
|
38
|
+
|
|
39
|
+
constructor(appRoot: string, private onUpdate: (snapshot: MapSnapshot) => void,
|
|
40
|
+
private roots: Pick<TranscriptTailerOptions, 'claudeProjectsDir' | 'codexSessionsDir'> = {},
|
|
41
|
+
private capability: MapCapability = initialCapability()) {
|
|
42
|
+
const cache = path.join(appRoot, 'experiments/live-map/.cache')
|
|
43
|
+
const prepared = path.join(os.homedir(), '.ctlsurf/clef-flash')
|
|
44
|
+
const pilotPython = path.join(cache, 'envs/clef-flash/bin/python')
|
|
45
|
+
this.settings = {
|
|
46
|
+
enabled: false, engine: 'clef-flash',
|
|
47
|
+
pythonPath: process.env.CTLSURF_CLEF_PYTHON || (fs.existsSync(pilotPython) ? pilotPython : path.join(prepared, 'env/bin/python')),
|
|
48
|
+
modelPath: process.env.CTLSURF_CLEF_MODEL || (fs.existsSync(pilotPython) ? path.join(cache, 'models/clef-flash') : path.join(prepared, 'model')),
|
|
49
|
+
}
|
|
50
|
+
this.clef = new ClefWorker(path.join(appRoot, 'scripts/work-map-clef.py'), () => this.publish())
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
configure(value: Partial<MapSettings>): MapSettings {
|
|
54
|
+
++this.epoch
|
|
55
|
+
const previous = this.settings
|
|
56
|
+
this.settings = {
|
|
57
|
+
enabled: this.capability.state === 'available' && (typeof value.enabled === 'boolean' ? value.enabled : previous.enabled),
|
|
58
|
+
engine: value.engine === 'rules' || value.engine === 'clef-flash' ? value.engine : previous.engine,
|
|
59
|
+
pythonPath: typeof value.pythonPath === 'string' && value.pythonPath.trim() ? value.pythonPath.trim() : previous.pythonPath,
|
|
60
|
+
modelPath: typeof value.modelPath === 'string' && value.modelPath.trim() ? value.modelPath.trim() : previous.modelPath,
|
|
61
|
+
}
|
|
62
|
+
this.clef.stop()
|
|
63
|
+
if (this.timer) clearTimeout(this.timer)
|
|
64
|
+
this.timer = null
|
|
65
|
+
for (const tab of this.tabs.values()) {
|
|
66
|
+
tab.pending.clear()
|
|
67
|
+
if (this.settings.enabled && !tab.ended) this.start(tab)
|
|
68
|
+
else { tab.tailer?.stop(); tab.tailer = null; tab.tree.end() }
|
|
69
|
+
if (this.settings.enabled && this.settings.engine === 'clef-flash') {
|
|
70
|
+
tab.tree.nodes.filter(n => n.kind === 'step' && n.placement !== 'clef-flash').slice(-24).forEach(n => tab.pending.add(n.id))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
this.publish()
|
|
74
|
+
this.schedule()
|
|
75
|
+
return { ...this.settings }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
attach(tabId: string, agentId: string, agentName: string, cwd: string): void {
|
|
79
|
+
this.remove(tabId)
|
|
80
|
+
const tab: MapTab = { agentId, agentName, cwd, tree: new WorkMapTree(), tailer: null, ended: false, state: 'idle', source: null, ignored: new Set(), pending: new Set(), thinkingStatus: 'waiting', sources: new Map() }
|
|
81
|
+
this.tabs.set(tabId, tab)
|
|
82
|
+
if (this.settings.enabled) this.start(tab)
|
|
83
|
+
this.publish()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getCapability(): MapCapability {
|
|
87
|
+
return { ...this.capability }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
setCapability(capability: MapCapability): void {
|
|
91
|
+
this.capability = { ...capability }
|
|
92
|
+
if (capability.state !== 'available') this.configure({ enabled: false })
|
|
93
|
+
this.publish()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
end(tabId: string): void {
|
|
97
|
+
const tab = this.tabs.get(tabId)
|
|
98
|
+
if (!tab) return
|
|
99
|
+
tab.ended = true
|
|
100
|
+
tab.tailer?.stop()
|
|
101
|
+
tab.tailer = null
|
|
102
|
+
tab.pending.clear()
|
|
103
|
+
tab.tree.end()
|
|
104
|
+
for (const [source, owner] of this.owners) if (owner === tabId) this.owners.delete(source)
|
|
105
|
+
if (![...this.tabs.values()].some(t => !t.ended)) this.clef.stop()
|
|
106
|
+
this.publish()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
remove(tabId: string): void {
|
|
110
|
+
this.end(tabId)
|
|
111
|
+
this.tabs.delete(tabId)
|
|
112
|
+
for (const [source, owner] of this.owners) if (owner === tabId) this.owners.delete(source)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
clear(tabId: string): void {
|
|
116
|
+
const tab = this.tabs.get(tabId)
|
|
117
|
+
if (tab) { tab.tree = new WorkMapTree(); tab.pending.clear() }
|
|
118
|
+
this.publish()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
noteState(tabId: string, state: string): void {
|
|
122
|
+
const tab = this.tabs.get(tabId)
|
|
123
|
+
if (tab && tab.state !== state) { tab.state = state; this.publish() }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
snapshot(tabId: string): MapSnapshot {
|
|
127
|
+
const tab = this.tabs.get(tabId)
|
|
128
|
+
return {
|
|
129
|
+
revision: ++this.revision, tabId, agentName: tab?.agentName || null,
|
|
130
|
+
supported: !!tab && supportsTranscriptLogging(tab.agentId), ended: !!tab?.ended,
|
|
131
|
+
state: tab?.state || 'idle', settings: { ...this.settings },
|
|
132
|
+
runtime: !this.settings.enabled ? 'off' : this.settings.engine === 'rules' ? 'ready' : this.clef.state,
|
|
133
|
+
error: this.settings.engine === 'clef-flash' ? this.clef.error : null,
|
|
134
|
+
nodes: tab?.tree.nodes || [], dropped: tab?.tree.dropped || 0,
|
|
135
|
+
thinkingStatus: tab?.thinkingStatus || 'waiting',
|
|
136
|
+
capability: this.getCapability(),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async getSource(tabId: string, eventId: string): Promise<MapSource | null> {
|
|
141
|
+
const tab = this.tabs.get(tabId)
|
|
142
|
+
const event = tab?.tree.nodes.flatMap(n => n.evidence).find(e => e.id === eventId && (e.kind === 'thinking' || e.kind === 'message'))
|
|
143
|
+
const file = event && tab?.sources.get(event.source)
|
|
144
|
+
if (!event || !file || !tab) return null
|
|
145
|
+
return readMapSource(file, tab.agentId, event.id)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private start(tab: MapTab): void {
|
|
149
|
+
if (tab.tailer || !supportsTranscriptLogging(tab.agentId)) return
|
|
150
|
+
tab.tailer = new TranscriptTailer({
|
|
151
|
+
...this.roots,
|
|
152
|
+
agentId: tab.agentId, cwd: tab.cwd, sink: () => {},
|
|
153
|
+
rawSink: (raw: any, source) => {
|
|
154
|
+
if (!source || !this.settings.enabled || tab.ended) return
|
|
155
|
+
const file = source.filePath
|
|
156
|
+
if (tab.ignored.has(file)) return
|
|
157
|
+
if (raw.type === 'session_meta') {
|
|
158
|
+
const meta = raw.payload || {}
|
|
159
|
+
if ((meta.thread_source && meta.thread_source !== 'user') || (meta.source && typeof meta.source === 'object') || (meta.agent_path && meta.agent_path !== '/root')) {
|
|
160
|
+
tab.ignored.add(file)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const tabId = [...this.tabs].find(([, candidate]) => candidate === tab)?.[0]
|
|
165
|
+
if (!tabId) return
|
|
166
|
+
if (this.owners.has(file) && this.owners.get(file) !== tabId) return
|
|
167
|
+
// Pin a source; a newly created session (/new or /clear) may replace it.
|
|
168
|
+
if (tab.source && tab.source !== file) {
|
|
169
|
+
if (raw.type !== 'session_meta' || Date.parse(raw.payload?.timestamp || raw.timestamp) < source.sinceMs) return
|
|
170
|
+
this.owners.delete(tab.source)
|
|
171
|
+
}
|
|
172
|
+
tab.source = file
|
|
173
|
+
this.owners.set(file, tabId)
|
|
174
|
+
const thinking = mapThinkingStatus(raw, tab.agentId)
|
|
175
|
+
const statusChanged = thinking !== null && thinking !== tab.thinkingStatus && (thinking === 'available' || tab.thinkingStatus === 'waiting')
|
|
176
|
+
if (statusChanged) tab.thinkingStatus = thinking
|
|
177
|
+
const events = normalizeMapEvents(raw, file, tab.agentId)
|
|
178
|
+
for (const e of events) {
|
|
179
|
+
tab.sources.set(e.source, file)
|
|
180
|
+
const id = tab.tree.ingest(e)
|
|
181
|
+
if (id && this.settings.engine === 'clef-flash') {
|
|
182
|
+
tab.pending.add(id)
|
|
183
|
+
// A burst cannot create an unbounded inference backlog.
|
|
184
|
+
if (tab.pending.size > 24) tab.pending.delete(tab.pending.values().next().value!)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const retainedSources = new Set(tab.tree.nodes.flatMap(n => n.evidence.map(e => e.source)))
|
|
188
|
+
for (const name of tab.sources.keys()) if (!retainedSources.has(name)) tab.sources.delete(name)
|
|
189
|
+
if (events.length || statusChanged) { this.publish(); this.schedule() }
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
tab.tailer.start()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private schedule(): void {
|
|
196
|
+
if (this.timer || this.working || !this.settings.enabled || this.settings.engine !== 'clef-flash' || this.clef.state === 'error') return
|
|
197
|
+
if (![...this.tabs.values()].some(t => t.pending.size)) return
|
|
198
|
+
this.timer = setTimeout(() => { this.timer = null; void this.infer() }, 1000)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async infer(): Promise<void> {
|
|
202
|
+
const candidates = [...this.tabs].filter(([, t]) => t.pending.size && !t.ended)
|
|
203
|
+
const candidate = candidates[this.round++ % candidates.length]
|
|
204
|
+
if (!candidate || !this.settings.enabled || this.settings.engine !== 'clef-flash') return
|
|
205
|
+
const [tabId, tab] = candidate
|
|
206
|
+
const tree = tab.tree
|
|
207
|
+
const ids = [...tab.pending].slice(0, 2)
|
|
208
|
+
const job = tree.request(ids)
|
|
209
|
+
const consumed = job?.ids || ids
|
|
210
|
+
consumed.forEach(id => tab.pending.delete(id))
|
|
211
|
+
if (!job) { this.schedule(); return }
|
|
212
|
+
const epoch = this.epoch
|
|
213
|
+
this.working = true
|
|
214
|
+
try {
|
|
215
|
+
const answers = await this.clef.predict(this.settings, job.request)
|
|
216
|
+
if (epoch === this.epoch && this.tabs.get(tabId) === tab && !tab.ended && tab.tree === tree) tree.apply(job, answers)
|
|
217
|
+
} catch { /* The worker exposes errors in snapshots; never affect the working agent. */ }
|
|
218
|
+
finally {
|
|
219
|
+
this.working = false
|
|
220
|
+
this.publish()
|
|
221
|
+
this.schedule()
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private publish(): void {
|
|
226
|
+
if (this.publishing) return
|
|
227
|
+
this.publishing = setTimeout(() => {
|
|
228
|
+
this.publishing = null
|
|
229
|
+
for (const tabId of this.tabs.keys()) this.onUpdate(this.snapshot(tabId))
|
|
230
|
+
}, 120)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
shutdown(): void {
|
|
234
|
+
this.settings.enabled = false
|
|
235
|
+
++this.epoch
|
|
236
|
+
for (const tab of this.tabs.values()) tab.tailer?.stop()
|
|
237
|
+
this.clef.stop()
|
|
238
|
+
if (this.timer) clearTimeout(this.timer)
|
|
239
|
+
if (this.publishing) clearTimeout(this.publishing)
|
|
240
|
+
this.timer = this.publishing = null
|
|
241
|
+
this.tabs.clear()
|
|
242
|
+
this.owners.clear()
|
|
243
|
+
}
|
|
244
|
+
}
|
package/src/preload/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { contextBridge, ipcRenderer } from 'electron'
|
|
2
|
+
import type { MapCapability, MapSettings, MapSnapshot, MapSource } from '../shared/workMap'
|
|
2
3
|
|
|
3
4
|
export interface AgentConfig {
|
|
4
5
|
id: string
|
|
@@ -161,6 +162,22 @@ const api = {
|
|
|
161
162
|
setSpeakReplies: (enabled: boolean): Promise<{ enabled: boolean }> =>
|
|
162
163
|
ipcRenderer.invoke('speak:set', enabled),
|
|
163
164
|
|
|
165
|
+
getWorkMap: (tabId: string): Promise<MapSnapshot> => ipcRenderer.invoke('workmap:get', tabId),
|
|
166
|
+
getWorkMapCapability: (): Promise<MapCapability> => ipcRenderer.invoke('workmap:capability'),
|
|
167
|
+
onWorkMapCapability: (callback: (capability: MapCapability) => void) => {
|
|
168
|
+
const listener = (_event: Electron.IpcRendererEvent, capability: MapCapability) => callback(capability)
|
|
169
|
+
ipcRenderer.on('workmap:capability', listener)
|
|
170
|
+
return () => ipcRenderer.removeListener('workmap:capability', listener)
|
|
171
|
+
},
|
|
172
|
+
getWorkMapSource: (tabId: string, eventId: string): Promise<MapSource | null> => ipcRenderer.invoke('workmap:source', tabId, eventId),
|
|
173
|
+
setWorkMap: (tabId: string, settings: Partial<MapSettings>): Promise<MapSnapshot> => ipcRenderer.invoke('workmap:set', tabId, settings),
|
|
174
|
+
clearWorkMap: (tabId: string): Promise<MapSnapshot> => ipcRenderer.invoke('workmap:clear', tabId),
|
|
175
|
+
onWorkMapUpdate: (callback: (snapshot: MapSnapshot) => void) => {
|
|
176
|
+
const listener = (_event: Electron.IpcRendererEvent, snapshot: MapSnapshot) => callback(snapshot)
|
|
177
|
+
ipcRenderer.on('workmap:update', listener)
|
|
178
|
+
return () => ipcRenderer.removeListener('workmap:update', listener)
|
|
179
|
+
},
|
|
180
|
+
|
|
164
181
|
// Observer supervision layer (global). The API key is write-only over IPC:
|
|
165
182
|
// the getter reports only whether one is configured and from where.
|
|
166
183
|
getObserverSettings: (): Promise<{ enabled: boolean; hasApiKey: boolean; keySource: 'settings' | 'env' | null; dailyCapUsd: number }> =>
|
package/src/renderer/App.tsx
CHANGED
|
@@ -17,7 +17,9 @@ import { StatusBar } from './components/StatusBar'
|
|
|
17
17
|
import type { ResourceSnapshot } from './components/Resources'
|
|
18
18
|
import { SettingsDialog } from './components/SettingsDialog'
|
|
19
19
|
import { TicketPane } from './components/TicketPanel'
|
|
20
|
-
import {
|
|
20
|
+
import { WorkMapPanel } from './components/WorkMapPanel'
|
|
21
|
+
import { WorkMapToggle, canOpenWorkMap } from './components/WorkMapToggle'
|
|
22
|
+
import type { MapCapability, MapSettings, MapSnapshot, MapSource } from '../shared/workMap'
|
|
21
23
|
|
|
22
24
|
interface AgentConfig {
|
|
23
25
|
id: string
|
|
@@ -71,6 +73,13 @@ declare global {
|
|
|
71
73
|
getLogChat: () => Promise<{ enabled: boolean }>
|
|
72
74
|
setLogChat: (enabled: boolean) => Promise<{ enabled: boolean }>
|
|
73
75
|
getSpeakReplies: () => Promise<{ enabled: boolean }>
|
|
76
|
+
getWorkMap: (tabId: string) => Promise<MapSnapshot>
|
|
77
|
+
getWorkMapCapability: () => Promise<MapCapability>
|
|
78
|
+
onWorkMapCapability: (callback: (capability: MapCapability) => void) => () => void
|
|
79
|
+
getWorkMapSource: (tabId: string, eventId: string) => Promise<MapSource | null>
|
|
80
|
+
setWorkMap: (tabId: string, settings: Partial<MapSettings>) => Promise<MapSnapshot>
|
|
81
|
+
clearWorkMap: (tabId: string) => Promise<MapSnapshot>
|
|
82
|
+
onWorkMapUpdate: (callback: (snapshot: MapSnapshot) => void) => () => void
|
|
74
83
|
setSpeakReplies: (enabled: boolean) => Promise<{ enabled: boolean }>
|
|
75
84
|
getObserverSettings: () => Promise<{ enabled: boolean; hasApiKey: boolean; keySource: 'settings' | 'env' | null; dailyCapUsd: number }>
|
|
76
85
|
setObserverSettings: (data: { enabled: boolean; anthropicApiKey?: string; dailyCapUsd?: number; sensitivity?: 'severe' | 'balanced' | 'all' }) => Promise<{ enabled: boolean; hasApiKey: boolean; keySource: 'settings' | 'env' | null; dailyCapUsd: number }>
|
|
@@ -520,6 +529,7 @@ export default function App() {
|
|
|
520
529
|
|
|
521
530
|
// Toggle pane: hide removes from layout tree, show re-inserts
|
|
522
531
|
const togglePane = useCallback((id: string) => {
|
|
532
|
+
if (id === 'workmap' && !visiblePaneIds.includes(id) && !canOpenWorkMap()) return
|
|
523
533
|
if (visiblePaneIds.includes(id)) {
|
|
524
534
|
// Hide: remove from tree
|
|
525
535
|
const newLayout = removePane(layout, id)
|
|
@@ -575,6 +585,7 @@ export default function App() {
|
|
|
575
585
|
case 'T': e.preventDefault(); togglePane('terminal'); break
|
|
576
586
|
case 'P': e.preventDefault(); togglePane('ctlsurf'); break
|
|
577
587
|
case 'K': e.preventDefault(); togglePane('tickets'); break
|
|
588
|
+
case 'M': e.preventDefault(); togglePane('workmap'); break
|
|
578
589
|
}
|
|
579
590
|
}
|
|
580
591
|
}
|
|
@@ -670,7 +681,7 @@ export default function App() {
|
|
|
670
681
|
},
|
|
671
682
|
{ id: 'ctlsurf', label: 'ctlsurf', content: <CtlsurfPanel navigate={ctlsurfNav} /> },
|
|
672
683
|
{ id: 'tickets', label: 'Tickets', content: <TicketPane onSendToTerminal={handleSendToTerminal} /> },
|
|
673
|
-
{ id: '
|
|
684
|
+
{ id: 'workmap', label: 'Work map', content: <WorkMapPanel key={activeTabId} tabId={activeTabId} /> },
|
|
674
685
|
]
|
|
675
686
|
|
|
676
687
|
return (
|
|
@@ -707,20 +718,6 @@ export default function App() {
|
|
|
707
718
|
</svg>
|
|
708
719
|
<span>Tickets</span>
|
|
709
720
|
</button>
|
|
710
|
-
<button
|
|
711
|
-
className={`titlebar-btn titlebar-icon-btn ${visiblePaneIds.includes('observer') ? 'active' : ''}`}
|
|
712
|
-
onClick={() => togglePane('observer')}
|
|
713
|
-
title="Toggle Observer pane — live session review"
|
|
714
|
-
aria-label="Observer"
|
|
715
|
-
>
|
|
716
|
-
<svg viewBox="0 0 24 24" width="13" height="13"
|
|
717
|
-
fill="none" stroke="currentColor" strokeWidth="2"
|
|
718
|
-
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
719
|
-
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
720
|
-
<circle cx="12" cy="12" r="3" />
|
|
721
|
-
</svg>
|
|
722
|
-
<span>Observer</span>
|
|
723
|
-
</button>
|
|
724
721
|
<button
|
|
725
722
|
className={`titlebar-btn titlebar-icon-btn ${showFloatingMic ? 'active' : ''}`}
|
|
726
723
|
onClick={() => setFloatingMicVisible(!showFloatingMic)}
|
|
@@ -735,6 +732,8 @@ export default function App() {
|
|
|
735
732
|
<line x1="8" y1="23" x2="16" y2="23" />
|
|
736
733
|
</svg>
|
|
737
734
|
</button>
|
|
735
|
+
<WorkMapToggle visible={visiblePaneIds.includes('workmap')} onToggle={() => togglePane('workmap')}
|
|
736
|
+
onUnavailable={() => setLayout(previous => removePane(previous, 'workmap') || { type: 'leaf', paneId: 'terminal' })} />
|
|
738
737
|
<SpeakControls remote={activeAgentIsRemote} />
|
|
739
738
|
<span className="titlebar-separator" />
|
|
740
739
|
<button
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from 'react'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
+
* ON HOLD: retained for future work; not mounted in the app or exposed in Settings.
|
|
5
|
+
* Runtime is disabled by OBSERVER_ON_HOLD in the orchestrator.
|
|
6
|
+
*
|
|
4
7
|
* Observer side pane — live supervision feed for the current session.
|
|
5
8
|
*
|
|
6
9
|
* Shares the drawer chrome with the Tickets pane but renders its own card
|