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.
Files changed (57) hide show
  1. package/README.md +15 -0
  2. package/package.json +33 -0
  3. package/src/agent-transcript.js +80 -0
  4. package/src/agent.js +170 -0
  5. package/src/agents.js +213 -0
  6. package/src/attachments.js +141 -0
  7. package/src/boot.js +28 -0
  8. package/src/catalog-snapshot.json +1 -0
  9. package/src/catalog.js +86 -0
  10. package/src/codex-models.js +56 -0
  11. package/src/commands.js +61 -0
  12. package/src/compaction.js +82 -0
  13. package/src/completion.js +21 -0
  14. package/src/config.js +32 -0
  15. package/src/context.js +82 -0
  16. package/src/controller.js +1263 -0
  17. package/src/conversation-search.js +101 -0
  18. package/src/deliberation-history.js +65 -0
  19. package/src/deliberation.js +61 -0
  20. package/src/derive.js +307 -0
  21. package/src/events.js +54 -0
  22. package/src/export.js +16 -0
  23. package/src/files.js +39 -0
  24. package/src/format.js +6 -0
  25. package/src/fuzzy.js +41 -0
  26. package/src/git.js +156 -0
  27. package/src/history.js +51 -0
  28. package/src/init.js +25 -0
  29. package/src/keys.js +26 -0
  30. package/src/mcp.js +280 -0
  31. package/src/memory.js +120 -0
  32. package/src/models.js +14 -0
  33. package/src/openai-auth.js +204 -0
  34. package/src/paths.js +67 -0
  35. package/src/reversible-edit.js +79 -0
  36. package/src/rewind.js +84 -0
  37. package/src/session-index.js +264 -0
  38. package/src/session-lock.js +27 -0
  39. package/src/session.js +160 -0
  40. package/src/shells.js +166 -0
  41. package/src/skills.js +164 -0
  42. package/src/steer.js +129 -0
  43. package/src/system-prompt.js +49 -0
  44. package/src/terminal-theme.js +49 -0
  45. package/src/tools/bash.js +184 -0
  46. package/src/tools/diff.js +18 -0
  47. package/src/tools/edit.js +95 -0
  48. package/src/tools/glob.js +43 -0
  49. package/src/tools/grep.js +59 -0
  50. package/src/tools/index.js +296 -0
  51. package/src/tools/read.js +49 -0
  52. package/src/tools/recorder.js +74 -0
  53. package/src/tools/web.js +84 -0
  54. package/src/tools/write.js +48 -0
  55. package/src/update.js +82 -0
  56. package/src/user-tools.js +59 -0
  57. 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
+ }