picocode-core 0.9.119
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 +15 -0
- package/package.json +33 -0
- package/src/agent-transcript.js +80 -0
- package/src/agent.js +170 -0
- package/src/agents.js +213 -0
- package/src/attachments.js +141 -0
- package/src/boot.js +28 -0
- package/src/catalog-snapshot.json +1 -0
- package/src/catalog.js +86 -0
- package/src/codex-models.js +56 -0
- package/src/commands.js +61 -0
- package/src/compaction.js +82 -0
- package/src/completion.js +21 -0
- package/src/config.js +32 -0
- package/src/context.js +82 -0
- package/src/controller.js +1263 -0
- package/src/conversation-search.js +101 -0
- package/src/deliberation-history.js +65 -0
- package/src/deliberation.js +61 -0
- package/src/derive.js +307 -0
- package/src/events.js +54 -0
- package/src/export.js +16 -0
- package/src/files.js +39 -0
- package/src/format.js +6 -0
- package/src/fuzzy.js +41 -0
- package/src/git.js +156 -0
- package/src/history.js +51 -0
- package/src/init.js +25 -0
- package/src/keys.js +26 -0
- package/src/mcp.js +280 -0
- package/src/memory.js +120 -0
- package/src/models.js +14 -0
- package/src/openai-auth.js +204 -0
- package/src/paths.js +67 -0
- package/src/reversible-edit.js +79 -0
- package/src/rewind.js +84 -0
- package/src/session-index.js +264 -0
- package/src/session-lock.js +27 -0
- package/src/session.js +160 -0
- package/src/shells.js +166 -0
- package/src/skills.js +164 -0
- package/src/steer.js +129 -0
- package/src/system-prompt.js +49 -0
- package/src/terminal-theme.js +49 -0
- package/src/tools/bash.js +184 -0
- package/src/tools/diff.js +18 -0
- package/src/tools/edit.js +95 -0
- package/src/tools/glob.js +43 -0
- package/src/tools/grep.js +59 -0
- package/src/tools/index.js +296 -0
- package/src/tools/read.js +49 -0
- package/src/tools/recorder.js +74 -0
- package/src/tools/web.js +84 -0
- package/src/tools/write.js +48 -0
- package/src/update.js +82 -0
- package/src/user-tools.js +59 -0
- package/src/wakeups.js +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# picocode-core
|
|
2
|
+
|
|
3
|
+
The runtime behind [pico](https://github.com/nvms/pico). It contains everything except rendering: session event logs and their derived state, the agent turn loop, the tool registry, background shells, subagents and deliberation, MCP client support, memories, skills and commands, compaction, rewind, steering, scheduled wake-ups, model catalog and Codex OAuth.
|
|
4
|
+
|
|
5
|
+
The session driver lives in `controller.js`. `createController({ boot })` owns the conversation state (session log, derived transcript, model, effort, queue, live turn overlay) and exposes methods such as `send`, `interrupt`, `compact`, `resume`, `fork`, `rewind`, `switchModel`, and `sendParallel`, emitting `change`, `flash`, `derived`, `question`, `session`, and `project` events for a frontend to render. The terminal client in `packages/pico` is one consumer.
|
|
6
|
+
|
|
7
|
+
Modules are imported by path:
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { runTurn } from 'picocode-core/agent.js'
|
|
11
|
+
import { deriveState } from 'picocode-core/derive.js'
|
|
12
|
+
import { createToolset } from 'picocode-core/tools/index.js'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Model access goes through [@prsm/ai](https://github.com/prsmjs/ai). The package is plain ESM and requires Node 24 or newer.
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "picocode-core",
|
|
3
|
+
"version": "0.9.119",
|
|
4
|
+
"description": "The agent runtime behind pico: sessions, tools, subagents, MCP, memory, and model access",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/nvms/pico.git",
|
|
10
|
+
"directory": "packages/core"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
"./*": "./src/*"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=24"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test 'test/**/*.test.js'",
|
|
23
|
+
"snapshot": "node scripts/snapshot-catalog.js",
|
|
24
|
+
"prepublishOnly": "node scripts/snapshot-catalog.js"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
28
|
+
"@prsm/ai": "^1.6.1",
|
|
29
|
+
"diff": "^7.0.0",
|
|
30
|
+
"fast-glob": "^3.3.0",
|
|
31
|
+
"proper-lockfile": "^4.1.2"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { defaultTitle } from './tools/recorder.js'
|
|
2
|
+
|
|
3
|
+
function parseCallArgs(call) {
|
|
4
|
+
try {
|
|
5
|
+
return typeof call.function?.arguments === 'string'
|
|
6
|
+
? JSON.parse(call.function.arguments)
|
|
7
|
+
: call.function?.arguments || {}
|
|
8
|
+
} catch {
|
|
9
|
+
return {}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function toolItem(call, startedAt) {
|
|
14
|
+
const args = parseCallArgs(call)
|
|
15
|
+
const name = call.function?.name || 'tool'
|
|
16
|
+
return {
|
|
17
|
+
kind: 'tool',
|
|
18
|
+
callId: call.id,
|
|
19
|
+
name,
|
|
20
|
+
args,
|
|
21
|
+
description: args.description,
|
|
22
|
+
title: defaultTitle(name, args),
|
|
23
|
+
status: 'running',
|
|
24
|
+
startedAt,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function settleTool(tools, event) {
|
|
29
|
+
const item = tools.get(event.call?.id)
|
|
30
|
+
if (!item) return
|
|
31
|
+
item.status = event.type === 'tool_error' ? 'error' : 'done'
|
|
32
|
+
item.error = event.error ? String(event.error) : null
|
|
33
|
+
item.fullOutput = event.result === undefined ? null : typeof event.result === 'string' ? event.result : JSON.stringify(event.result, null, 2)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function deliberationTranscript(agent) {
|
|
37
|
+
const items = [{ kind: 'user', text: agent.prompt }]
|
|
38
|
+
const tools = new Map()
|
|
39
|
+
for (const entry of agent.timeline) {
|
|
40
|
+
if (entry.kind === 'turn') {
|
|
41
|
+
const turn = entry.value
|
|
42
|
+
items.push({ kind: 'deliberation-turn', role: turn.role, round: turn.round, text: turn.text })
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
const event = entry.value
|
|
46
|
+
if (event.type === 'tool_executing') {
|
|
47
|
+
const item = toolItem(event.call || {}, event.at)
|
|
48
|
+
tools.set(item.callId, item)
|
|
49
|
+
items.push(item)
|
|
50
|
+
}
|
|
51
|
+
if (event.type === 'tool_complete' || event.type === 'tool_error') settleTool(tools, event)
|
|
52
|
+
}
|
|
53
|
+
if (agent.result) {
|
|
54
|
+
items.push({ kind: 'deliberation-turn', role: 'synthesis', text: agent.result, interrupted: agent.status === 'cancelled' })
|
|
55
|
+
} else if (agent.error) {
|
|
56
|
+
items.push({ kind: 'assistant', text: agent.error, interrupted: true })
|
|
57
|
+
}
|
|
58
|
+
return items
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function agentTranscript(agent) {
|
|
62
|
+
if (!agent) return []
|
|
63
|
+
if (agent.role === 'deliberation') return deliberationTranscript(agent)
|
|
64
|
+
const items = [{ kind: 'user', text: agent.prompt }]
|
|
65
|
+
const tools = new Map()
|
|
66
|
+
let response = ''
|
|
67
|
+
for (const event of agent.events) {
|
|
68
|
+
if (event.type === 'content') response += event.content
|
|
69
|
+
if (event.type === 'tool_executing') {
|
|
70
|
+
const item = toolItem(event.call || {}, event.at || agent.updatedAt)
|
|
71
|
+
tools.set(item.callId, item)
|
|
72
|
+
items.push(item)
|
|
73
|
+
}
|
|
74
|
+
if (event.type === 'tool_complete' || event.type === 'tool_error') settleTool(tools, event)
|
|
75
|
+
}
|
|
76
|
+
const text = agent.result || response
|
|
77
|
+
if (text) items.push({ kind: 'assistant', text, interrupted: agent.status === 'cancelled' })
|
|
78
|
+
else if (agent.error) items.push({ kind: 'assistant', text: agent.error, interrupted: true })
|
|
79
|
+
return items
|
|
80
|
+
}
|
package/src/agent.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { compose, scope, model, noToolsCalled, Inherit, getText } from '@prsm/ai'
|
|
3
|
+
|
|
4
|
+
async function hydratePart(part) {
|
|
5
|
+
if (part.type !== 'image' || part.source?.kind !== 'path') return part
|
|
6
|
+
try {
|
|
7
|
+
const data = await readFile(part.source.path)
|
|
8
|
+
return {
|
|
9
|
+
type: 'image',
|
|
10
|
+
source: { kind: 'base64', mediaType: part.source.mediaType, data: data.toString('base64') },
|
|
11
|
+
}
|
|
12
|
+
} catch {
|
|
13
|
+
return { type: 'text', text: `[image unavailable: ${part.source.path}]` }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function hydrateImages(history) {
|
|
18
|
+
return Promise.all(
|
|
19
|
+
history.map(async (message) =>
|
|
20
|
+
Array.isArray(message.content)
|
|
21
|
+
? { ...message, content: await Promise.all(message.content.map(hydratePart)) }
|
|
22
|
+
: message,
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// reasoning models with large contexts can sit minutes before the first
|
|
28
|
+
// output token; this guards against truly dead streams, not slow ones
|
|
29
|
+
const STALL_MS = 300000
|
|
30
|
+
|
|
31
|
+
export function compactionHistory(history, prompt) {
|
|
32
|
+
return hydrateImages([...history.filter((m) => m.role !== 'system'), { role: 'user', content: prompt }])
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function compactHistory({ history, modelName, auth, prompt, signal, onStream }) {
|
|
36
|
+
const out = await compose(
|
|
37
|
+
model({
|
|
38
|
+
model: modelName,
|
|
39
|
+
...(auth?.apiKey && { apiKey: auth.apiKey }),
|
|
40
|
+
...(auth?.headers && { headers: auth.headers }),
|
|
41
|
+
}),
|
|
42
|
+
)({
|
|
43
|
+
history: await compactionHistory(history, prompt),
|
|
44
|
+
tools: [],
|
|
45
|
+
abortSignal: signal,
|
|
46
|
+
...(onStream && { stream: onStream }),
|
|
47
|
+
})
|
|
48
|
+
if (signal?.aborted) throw new Error('compaction cancelled')
|
|
49
|
+
return getText(out.lastResponse?.content || '').trim()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// the summary has a fixed shape: an <analysis> scratchpad, then 8 numbered
|
|
53
|
+
// sections; watching headers stream by is real progress, not an estimate
|
|
54
|
+
export function compactProgress(streamed) {
|
|
55
|
+
const chars = streamed.length
|
|
56
|
+
const afterAnalysis = streamed.split('</analysis>')[1] ?? streamed.split('<summary>')[1]
|
|
57
|
+
if (afterAnalysis === undefined) return { phase: 'analyzing', section: 0, chars }
|
|
58
|
+
const sections = (afterAnalysis.match(/^\s*\d+\.\s/gm) || []).length
|
|
59
|
+
if (sections === 0) return { phase: 'analyzing', section: 0, chars }
|
|
60
|
+
return { phase: 'writing', section: Math.min(8, sections), chars }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function summarizeText({ text, modelName, auth }) {
|
|
64
|
+
const out = await compose(
|
|
65
|
+
model({
|
|
66
|
+
model: modelName,
|
|
67
|
+
system: 'Summarize the following conversation excerpt in 2-4 dense sentences. Capture decisions, changes made, and open questions. Output only the summary.',
|
|
68
|
+
...(auth?.apiKey && { apiKey: auth.apiKey }),
|
|
69
|
+
...(auth?.headers && { headers: auth.headers }),
|
|
70
|
+
}),
|
|
71
|
+
)(text.slice(0, 30000))
|
|
72
|
+
return getText(out.lastResponse?.content || '').trim()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function runTurn({ history, tools, recorder, modelName, effort, auth, system, signal, onStream, stallMs = STALL_MS }) {
|
|
76
|
+
const collected = []
|
|
77
|
+
let roundText = ''
|
|
78
|
+
let usageSeen = null
|
|
79
|
+
let stalled = false
|
|
80
|
+
// usage events carry cumulative totals per request round, so consecutive
|
|
81
|
+
// deltas recover each round's true input size; the last delta is the size
|
|
82
|
+
// of the current context as actually sent
|
|
83
|
+
let cumulativePrompt = 0
|
|
84
|
+
let lastPromptTokens = 0
|
|
85
|
+
|
|
86
|
+
const internal = new AbortController()
|
|
87
|
+
const onUserAbort = () => internal.abort()
|
|
88
|
+
if (signal?.aborted) internal.abort()
|
|
89
|
+
else signal?.addEventListener('abort', onUserAbort, { once: true })
|
|
90
|
+
|
|
91
|
+
let watchdog = null
|
|
92
|
+
const arm = () => {
|
|
93
|
+
clearTimeout(watchdog)
|
|
94
|
+
watchdog = setTimeout(() => {
|
|
95
|
+
stalled = true
|
|
96
|
+
internal.abort()
|
|
97
|
+
}, stallMs)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const stream = (event) => {
|
|
101
|
+
if (event.type === 'tool_executing') {
|
|
102
|
+
// a tool may legitimately run for minutes (test suites, slow fetches)
|
|
103
|
+
// and emits nothing while it does; the watchdog guards the provider
|
|
104
|
+
// stream, so pause it until the tool finishes. tools carry their own
|
|
105
|
+
// timeouts (bash 120s default, web tools via dredge)
|
|
106
|
+
clearTimeout(watchdog)
|
|
107
|
+
recorder.currentCall = event.call
|
|
108
|
+
onStream?.(event)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
arm()
|
|
112
|
+
if (event.type === 'content') {
|
|
113
|
+
roundText += event.content
|
|
114
|
+
} else if (event.type === 'tool_calls_ready') {
|
|
115
|
+
collected.push({ role: 'assistant', content: roundText, tool_calls: event.calls })
|
|
116
|
+
roundText = ''
|
|
117
|
+
} else if (event.type === 'tool_complete') {
|
|
118
|
+
collected.push({ role: 'tool', tool_call_id: event.call.id, content: JSON.stringify(event.result) })
|
|
119
|
+
} else if (event.type === 'tool_error') {
|
|
120
|
+
collected.push({ role: 'tool', tool_call_id: event.call.id, content: JSON.stringify({ error: event.error }) })
|
|
121
|
+
} else if (event.type === 'usage') {
|
|
122
|
+
usageSeen = event.usage
|
|
123
|
+
const prompt = event.usage?.promptTokens || 0
|
|
124
|
+
lastPromptTokens = Math.max(0, prompt - cumulativePrompt)
|
|
125
|
+
cumulativePrompt = prompt
|
|
126
|
+
}
|
|
127
|
+
onStream?.(event)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const base = history.length
|
|
131
|
+
const step = compose(
|
|
132
|
+
scope(
|
|
133
|
+
{ inherit: Inherit.Conversation, system, tools, until: noToolsCalled(), stream },
|
|
134
|
+
(ctx) =>
|
|
135
|
+
model({
|
|
136
|
+
model: modelName,
|
|
137
|
+
...(effort && { effort }),
|
|
138
|
+
...(auth?.apiKey && { apiKey: auth.apiKey }),
|
|
139
|
+
...(auth?.headers && { headers: auth.headers }),
|
|
140
|
+
})({ ...ctx, abortSignal: internal.signal }),
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
const partialMessages = () => {
|
|
145
|
+
const messages = [...collected]
|
|
146
|
+
if (roundText) messages.push({ role: 'assistant', content: roundText })
|
|
147
|
+
return messages
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
arm()
|
|
151
|
+
try {
|
|
152
|
+
const out = await step({ history: await hydrateImages(history), tools: [] })
|
|
153
|
+
const interrupted = !!signal?.aborted || stalled
|
|
154
|
+
if (interrupted) {
|
|
155
|
+
return { messages: partialMessages(), usage: usageSeen, lastPromptTokens, interrupted, stalled }
|
|
156
|
+
}
|
|
157
|
+
const messages = out.history.filter((m) => m.role !== 'system').slice(base)
|
|
158
|
+
return { messages, usage: out.usage || null, lastPromptTokens, interrupted: false, stalled: false }
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if (err.name === 'AbortError' || internal.signal.aborted) {
|
|
161
|
+
return { messages: partialMessages(), usage: usageSeen, lastPromptTokens, interrupted: true, stalled }
|
|
162
|
+
}
|
|
163
|
+
// a provider error mid-turn must not discard the rounds that already
|
|
164
|
+
// ran: tools mutated the world, so the record has to survive
|
|
165
|
+
return { messages: partialMessages(), usage: usageSeen, lastPromptTokens, interrupted: true, stalled: false, error: String(err.message || err) }
|
|
166
|
+
} finally {
|
|
167
|
+
clearTimeout(watchdog)
|
|
168
|
+
signal?.removeEventListener('abort', onUserAbort)
|
|
169
|
+
}
|
|
170
|
+
}
|
package/src/agents.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { getText } from '@prsm/ai'
|
|
2
|
+
|
|
3
|
+
export function resultText(messages = []) {
|
|
4
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
5
|
+
if (messages[i].role === 'assistant') {
|
|
6
|
+
const text = getText(messages[i].content || '').trim()
|
|
7
|
+
if (text) return text
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return ''
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function restoredAgent(data, at) {
|
|
14
|
+
return {
|
|
15
|
+
id: String(data.agentId),
|
|
16
|
+
description: data.description || data.prompt?.slice(0, 80) || `agent ${data.agentId}`,
|
|
17
|
+
prompt: data.prompt || '',
|
|
18
|
+
model: data.model || null,
|
|
19
|
+
role: data.role || 'worker',
|
|
20
|
+
parentId: data.parentId || null,
|
|
21
|
+
sessionId: data.sessionId || null,
|
|
22
|
+
sessionFile: data.sessionFile || null,
|
|
23
|
+
tools: data.tools || [],
|
|
24
|
+
status: 'cancelled',
|
|
25
|
+
createdAt: at,
|
|
26
|
+
updatedAt: at,
|
|
27
|
+
startedAt: at,
|
|
28
|
+
endedAt: at,
|
|
29
|
+
events: [],
|
|
30
|
+
result: '',
|
|
31
|
+
error: null,
|
|
32
|
+
usage: null,
|
|
33
|
+
options: data,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function reduceAgentEvents(events = []) {
|
|
38
|
+
const agents = new Map()
|
|
39
|
+
for (const record of events) {
|
|
40
|
+
const data = record.data || {}
|
|
41
|
+
const id = String(data.agentId || '')
|
|
42
|
+
if (!id) continue
|
|
43
|
+
if (record.type === 'agent_start') {
|
|
44
|
+
agents.set(id, restoredAgent(data, record.at))
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if (record.type === 'agent_dismiss') {
|
|
48
|
+
agents.delete(id)
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
const agent = agents.get(id)
|
|
52
|
+
if (!agent) continue
|
|
53
|
+
if (record.type === 'agent_event') {
|
|
54
|
+
agent.events.push(data.event)
|
|
55
|
+
agent.updatedAt = record.at
|
|
56
|
+
if (data.event?.type === 'usage') agent.usage = data.event.usage
|
|
57
|
+
} else if (record.type === 'agent_result') {
|
|
58
|
+
agent.result = data.result || resultText(data.messages)
|
|
59
|
+
agent.usage = data.usage || agent.usage
|
|
60
|
+
agent.error = data.error || null
|
|
61
|
+
agent.status = data.error ? 'failed' : data.interrupted ? 'cancelled' : 'completed'
|
|
62
|
+
agent.updatedAt = record.at
|
|
63
|
+
agent.endedAt = record.at
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const restored = [...agents.values()]
|
|
67
|
+
for (const agent of restored) {
|
|
68
|
+
agent.done = Promise.resolve(agent)
|
|
69
|
+
agent.resolve = () => {}
|
|
70
|
+
}
|
|
71
|
+
return restored
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createAgentManager({ run, concurrency = 8, onChange = () => {}, onCreate = () => {}, onEvent = () => {}, onFinish = () => {}, defaults = () => ({}) } = {}) {
|
|
75
|
+
const agents = new Map()
|
|
76
|
+
const queue = []
|
|
77
|
+
let nextId = 1
|
|
78
|
+
let active = 0
|
|
79
|
+
let generation = 0
|
|
80
|
+
|
|
81
|
+
const changed = () => onChange()
|
|
82
|
+
|
|
83
|
+
function pump() {
|
|
84
|
+
while (active < concurrency && queue.length) {
|
|
85
|
+
const agent = queue.shift()
|
|
86
|
+
if (agent.status !== 'queued') continue
|
|
87
|
+
agent.status = 'running'
|
|
88
|
+
agent.startedAt = Date.now()
|
|
89
|
+
agent.generation = generation
|
|
90
|
+
active++
|
|
91
|
+
changed()
|
|
92
|
+
Promise.resolve().then(() => run(agent, agent.controller.signal, (event) => {
|
|
93
|
+
if (agent.generation !== generation) return
|
|
94
|
+
agent.events.push(event)
|
|
95
|
+
if (event.type === 'usage') agent.usage = event.usage
|
|
96
|
+
agent.updatedAt = Date.now()
|
|
97
|
+
onEvent(agent, event)
|
|
98
|
+
changed()
|
|
99
|
+
})).then((result) => {
|
|
100
|
+
if (agent.generation !== generation) return
|
|
101
|
+
agent.result = typeof result === 'string' ? result : resultText(result?.messages)
|
|
102
|
+
agent.usage = result?.usage || agent.usage
|
|
103
|
+
agent.error = result?.error || null
|
|
104
|
+
agent.status = agent.error ? 'failed' : result?.interrupted ? 'cancelled' : 'completed'
|
|
105
|
+
}).catch((error) => {
|
|
106
|
+
if (agent.generation !== generation) return
|
|
107
|
+
agent.error = String(error?.message || error)
|
|
108
|
+
agent.status = agent.controller.signal.aborted ? 'cancelled' : 'failed'
|
|
109
|
+
}).finally(() => {
|
|
110
|
+
if (agent.generation !== generation) return agent.resolve(agent)
|
|
111
|
+
agent.endedAt = Date.now()
|
|
112
|
+
active--
|
|
113
|
+
onFinish(agent)
|
|
114
|
+
changed()
|
|
115
|
+
agent.resolve(agent)
|
|
116
|
+
pump()
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function start(options) {
|
|
122
|
+
options = { ...defaults(), ...options }
|
|
123
|
+
const id = String(nextId++)
|
|
124
|
+
let resolve
|
|
125
|
+
const done = new Promise((r) => { resolve = r })
|
|
126
|
+
if (!options.prompt?.trim()) throw new Error('agent prompt is required')
|
|
127
|
+
const agent = {
|
|
128
|
+
id,
|
|
129
|
+
description: options.description || options.prompt.slice(0, 80),
|
|
130
|
+
prompt: options.prompt,
|
|
131
|
+
model: options.model,
|
|
132
|
+
role: options.role || 'worker',
|
|
133
|
+
parentId: options.parentId || null,
|
|
134
|
+
sessionId: options.sessionId || null,
|
|
135
|
+
sessionFile: options.sessionFile || null,
|
|
136
|
+
tools: options.tools || [],
|
|
137
|
+
status: 'queued',
|
|
138
|
+
createdAt: Date.now(),
|
|
139
|
+
updatedAt: Date.now(),
|
|
140
|
+
startedAt: null,
|
|
141
|
+
endedAt: null,
|
|
142
|
+
events: [],
|
|
143
|
+
result: '',
|
|
144
|
+
error: null,
|
|
145
|
+
usage: null,
|
|
146
|
+
controller: new AbortController(),
|
|
147
|
+
resolve,
|
|
148
|
+
done,
|
|
149
|
+
options,
|
|
150
|
+
}
|
|
151
|
+
agents.set(id, agent)
|
|
152
|
+
onCreate(agent)
|
|
153
|
+
queue.push(agent)
|
|
154
|
+
changed()
|
|
155
|
+
pump()
|
|
156
|
+
return agent
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function restore(events) {
|
|
160
|
+
generation++
|
|
161
|
+
for (const agent of agents.values()) {
|
|
162
|
+
if (agent.status === 'queued') agent.resolve(agent)
|
|
163
|
+
else if (agent.status === 'running') agent.controller.abort()
|
|
164
|
+
}
|
|
165
|
+
agents.clear()
|
|
166
|
+
queue.length = 0
|
|
167
|
+
active = 0
|
|
168
|
+
let maxId = 0
|
|
169
|
+
for (const agent of reduceAgentEvents(events)) {
|
|
170
|
+
agents.set(agent.id, agent)
|
|
171
|
+
maxId = Math.max(maxId, Number(agent.id) || 0)
|
|
172
|
+
}
|
|
173
|
+
nextId = maxId + 1
|
|
174
|
+
changed()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
start,
|
|
179
|
+
restore,
|
|
180
|
+
clear: () => restore([]),
|
|
181
|
+
get: (id) => agents.get(String(id)) || null,
|
|
182
|
+
list: () => [...agents.values()].sort((a, b) => Number(b.id) - Number(a.id)),
|
|
183
|
+
collect: async (ids) => {
|
|
184
|
+
const selected = await Promise.all(ids.map((id) => agents.get(String(id))?.done).filter(Boolean))
|
|
185
|
+
const at = Date.now()
|
|
186
|
+
for (const agent of selected) agent.collectedAt = at
|
|
187
|
+
changed()
|
|
188
|
+
return selected
|
|
189
|
+
},
|
|
190
|
+
dismiss(id) {
|
|
191
|
+
const agent = agents.get(String(id))
|
|
192
|
+
if (!agent || ['queued', 'running'].includes(agent.status)) return false
|
|
193
|
+
agents.delete(String(id))
|
|
194
|
+
changed()
|
|
195
|
+
return true
|
|
196
|
+
},
|
|
197
|
+
cancel(id) {
|
|
198
|
+
const agent = agents.get(String(id))
|
|
199
|
+
if (!agent || ['completed', 'failed', 'cancelled'].includes(agent.status)) return false
|
|
200
|
+
if (agent.status === 'queued') {
|
|
201
|
+
agent.status = 'cancelled'
|
|
202
|
+
agent.endedAt = Date.now()
|
|
203
|
+
onFinish(agent)
|
|
204
|
+
agent.resolve(agent)
|
|
205
|
+
changed()
|
|
206
|
+
} else agent.controller.abort()
|
|
207
|
+
return true
|
|
208
|
+
},
|
|
209
|
+
cancelAll() {
|
|
210
|
+
for (const agent of agents.values()) this.cancel(agent.id)
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { basename } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const MEDIA_TYPES = {
|
|
5
|
+
png: 'image/png',
|
|
6
|
+
jpg: 'image/jpeg',
|
|
7
|
+
jpeg: 'image/jpeg',
|
|
8
|
+
gif: 'image/gif',
|
|
9
|
+
webp: 'image/webp',
|
|
10
|
+
bmp: 'image/bmp',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function mediaTypeFor(path) {
|
|
14
|
+
return MEDIA_TYPES[path.split('.').pop().toLowerCase()] || null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function unquote(token) {
|
|
18
|
+
if (/^'.*'$/.test(token) || /^".*"$/.test(token)) return token.slice(1, -1)
|
|
19
|
+
return token.replace(/\\ /g, ' ')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toPath(token) {
|
|
23
|
+
const raw = unquote(token.trim())
|
|
24
|
+
if (raw.startsWith('file://')) {
|
|
25
|
+
try {
|
|
26
|
+
return decodeURIComponent(raw.slice('file://'.length))
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return raw
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function extractImagePaths(text, exists = existsSync) {
|
|
35
|
+
const tokens = text.trim().match(/'[^']*'|"[^"]*"|(?:\\ |[^ \t\n\r])+/g)
|
|
36
|
+
if (!tokens || tokens.length === 0) return []
|
|
37
|
+
const paths = []
|
|
38
|
+
for (const token of tokens) {
|
|
39
|
+
const path = toPath(token)
|
|
40
|
+
if (!path || !path.startsWith('/')) return []
|
|
41
|
+
if (!mediaTypeFor(path) || !exists(path)) return []
|
|
42
|
+
paths.push(path)
|
|
43
|
+
}
|
|
44
|
+
return paths
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function imageLabel(part) {
|
|
48
|
+
if (part.source?.path) return `[image: ${basename(part.source.path)}]`
|
|
49
|
+
return '[image]'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const IMAGE_PATH_RE = /(["'])(\/[^"']+?\.(?:png|jpe?g|gif|webp|bmp))\1|(\/(?:\\ |[^ \t\n\r"'])+\.(?:png|jpe?g|gif|webp|bmp))/gi
|
|
53
|
+
|
|
54
|
+
export function splitTextByImagePaths(text, exists = existsSync) {
|
|
55
|
+
const parts = []
|
|
56
|
+
let last = 0
|
|
57
|
+
for (const match of text.matchAll(IMAGE_PATH_RE)) {
|
|
58
|
+
const path = match[2] || match[3].replace(/\\ /g, ' ')
|
|
59
|
+
const mediaType = mediaTypeFor(path)
|
|
60
|
+
if (!mediaType || !exists(path)) continue
|
|
61
|
+
if (match.index > last) parts.push({ type: 'text', text: text.slice(last, match.index) })
|
|
62
|
+
parts.push({ type: 'image', source: { kind: 'path', path, mediaType } })
|
|
63
|
+
last = match.index + match[0].length
|
|
64
|
+
}
|
|
65
|
+
if (parts.length === 0) return null
|
|
66
|
+
const tail = text.slice(last)
|
|
67
|
+
if (tail) parts.push({ type: 'text', text: tail })
|
|
68
|
+
return parts
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildUserContent(text, attachments) {
|
|
72
|
+
const parts = []
|
|
73
|
+
let last = 0
|
|
74
|
+
const used = []
|
|
75
|
+
for (const match of text.matchAll(/\[Image #\d+\]/g)) {
|
|
76
|
+
const attachment = attachments.get(match[0])
|
|
77
|
+
if (!attachment) continue
|
|
78
|
+
const before = text.slice(last, match.index)
|
|
79
|
+
if (before) parts.push({ type: 'text', text: before })
|
|
80
|
+
parts.push({
|
|
81
|
+
type: 'image',
|
|
82
|
+
source: { kind: 'path', path: attachment.path, mediaType: attachment.mediaType },
|
|
83
|
+
})
|
|
84
|
+
used.push(match[0])
|
|
85
|
+
last = match.index + match[0].length
|
|
86
|
+
}
|
|
87
|
+
if (parts.length === 0) return { content: text, used }
|
|
88
|
+
const tail = text.slice(last)
|
|
89
|
+
if (tail) parts.push({ type: 'text', text: tail })
|
|
90
|
+
return { content: parts, used }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function placeholderizeImagePaths(text, { attachments, nextId, exists = existsSync }) {
|
|
94
|
+
const parts = splitTextByImagePaths(text, exists)
|
|
95
|
+
if (!parts) return { text, changed: false }
|
|
96
|
+
let out = ''
|
|
97
|
+
for (const part of parts) {
|
|
98
|
+
if (part.type === 'text') {
|
|
99
|
+
out += part.text
|
|
100
|
+
} else {
|
|
101
|
+
const placeholder = `[Image #${nextId()}]`
|
|
102
|
+
attachments.set(placeholder, { path: part.source.path, mediaType: part.source.mediaType })
|
|
103
|
+
out += placeholder
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { text: out, changed: true }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function inputTextFromContent(content, { attachments, nextId }) {
|
|
110
|
+
if (!Array.isArray(content)) return String(content)
|
|
111
|
+
let out = ''
|
|
112
|
+
for (const part of content) {
|
|
113
|
+
if (part.type === 'text') {
|
|
114
|
+
out += part.text
|
|
115
|
+
} else if (part.type === 'image' && part.source?.path) {
|
|
116
|
+
const placeholder = `[Image #${nextId()}]`
|
|
117
|
+
attachments.set(placeholder, { path: part.source.path, mediaType: part.source.mediaType })
|
|
118
|
+
out += placeholder
|
|
119
|
+
} else {
|
|
120
|
+
out += '[image]'
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function finalizeUserContent(text, attachments, exists = existsSync) {
|
|
127
|
+
const { content, used } = buildUserContent(text, attachments)
|
|
128
|
+
const parts = Array.isArray(content) ? content : [{ type: 'text', text: content }]
|
|
129
|
+
const expanded = []
|
|
130
|
+
for (const part of parts) {
|
|
131
|
+
if (part.type !== 'text') {
|
|
132
|
+
expanded.push(part)
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
const split = splitTextByImagePaths(part.text, exists)
|
|
136
|
+
if (split) expanded.push(...split)
|
|
137
|
+
else expanded.push(part)
|
|
138
|
+
}
|
|
139
|
+
if (expanded.length === 1 && expanded[0].type === 'text') return { content: expanded[0].text, used }
|
|
140
|
+
return { content: expanded, used }
|
|
141
|
+
}
|
package/src/boot.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { findProjectRoot } from './paths.js'
|
|
3
|
+
import { loadStartupContext, createContextTracker } from './context.js'
|
|
4
|
+
import { createSkillIndex } from './skills.js'
|
|
5
|
+
import { createCommandIndex } from './commands.js'
|
|
6
|
+
import { createMcpRuntime } from './mcp.js'
|
|
7
|
+
import { createMemory } from './memory.js'
|
|
8
|
+
|
|
9
|
+
export async function buildProjectBoot(cwd, { onMcpChange = () => {} } = {}) {
|
|
10
|
+
const home = homedir()
|
|
11
|
+
const root = findProjectRoot(cwd)
|
|
12
|
+
const startupContext = loadStartupContext(cwd)
|
|
13
|
+
const tracker = createContextTracker({
|
|
14
|
+
stopDir: startupContext.stopDir,
|
|
15
|
+
loaded: new Set(startupContext.files.map((f) => f.path)),
|
|
16
|
+
})
|
|
17
|
+
return {
|
|
18
|
+
cwd,
|
|
19
|
+
root,
|
|
20
|
+
displayCwd: cwd.startsWith(home) ? cwd.replace(home, '~') : cwd,
|
|
21
|
+
startupContext,
|
|
22
|
+
tracker,
|
|
23
|
+
skills: await createSkillIndex(root),
|
|
24
|
+
commands: await createCommandIndex(root),
|
|
25
|
+
mcp: await createMcpRuntime({ root, onChange: onMcpChange }),
|
|
26
|
+
memory: createMemory(root),
|
|
27
|
+
}
|
|
28
|
+
}
|