picocode-core 0.9.141 → 0.9.143

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "picocode-core",
3
- "version": "0.9.141",
3
+ "version": "0.9.143",
4
4
  "description": "The agent runtime behind pico: sessions, tools, subagents, MCP, memory, and model access",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -65,8 +65,16 @@ function deliberationTranscript(agent) {
65
65
  }
66
66
  if (event.type === 'tool_complete' || event.type === 'tool_error') settleTool(tools, event)
67
67
  }
68
+ // whoever is speaking right now shows their words as they arrive
69
+ const live = agent.live
70
+ if (live?.text && live.role !== 'synthesis') {
71
+ const turn = turnFor(live.role, live.round)
72
+ if (turn.text == null) turn.text = live.text
73
+ }
68
74
  if (agent.result) {
69
75
  items.push({ kind: 'deliberation-turn', role: 'synthesis', text: agent.result, tools: [], interrupted: agent.status === 'cancelled' })
76
+ } else if (live?.text && live.role === 'synthesis') {
77
+ items.push({ kind: 'deliberation-turn', role: 'synthesis', text: live.text, tools: [], active: true })
70
78
  } else if (agent.error) {
71
79
  items.push({ kind: 'assistant', text: agent.error, interrupted: true })
72
80
  }
@@ -1,5 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { basename } from 'node:path'
2
+ import { copyFile, mkdir, stat } from 'node:fs/promises'
3
+ import { createHash } from 'node:crypto'
4
+ import { basename, join } from 'node:path'
3
5
 
4
6
  const MEDIA_TYPES = {
5
7
  png: 'image/png',
@@ -146,3 +148,28 @@ export function finalizeUserContent(text, attachments, exists = existsSync) {
146
148
  if (expanded.length === 1 && expanded[0].type === 'text') return { content: expanded[0].text, used }
147
149
  return { content: expanded, used }
148
150
  }
151
+
152
+ // images ride into a session by path, and the path is what the log keeps,
153
+ // so each one is copied into the session's own attachments directory
154
+ // before the message is written and the part points at the copy. later
155
+ // turns and later readers then never depend on the original still being
156
+ // where it was
157
+ async function stashImage(part, dir) {
158
+ const path = part.source?.path
159
+ if (!path || part.source.kind !== 'path' || path.startsWith(dir)) return part
160
+ try {
161
+ const { size, mtimeMs } = await stat(path)
162
+ const key = createHash('sha1').update(`${path}\n${size}\n${Math.floor(mtimeMs)}`).digest('hex').slice(0, 10)
163
+ const copy = join(dir, `${key}-${basename(path)}`)
164
+ await mkdir(dir, { recursive: true })
165
+ if (!existsSync(copy)) await copyFile(path, copy)
166
+ return { ...part, source: { ...part.source, path: copy } }
167
+ } catch {
168
+ return part
169
+ }
170
+ }
171
+
172
+ export async function stashImages(content, dir) {
173
+ if (!Array.isArray(content)) return content
174
+ return Promise.all(content.map((part) => (part.type === 'image' ? stashImage(part, dir) : part)))
175
+ }
package/src/controller.js CHANGED
@@ -25,11 +25,11 @@ import { findModel, estimateCost } from './models.js'
25
25
  import { adhocModel } from './catalog.js'
26
26
  import { writeConfig } from './config.js'
27
27
  import { connectOpenAI, openaiCredentials, disconnectOpenAI } from './openai-auth.js'
28
- import { agentScratchDir, ensureDir } from './paths.js'
28
+ import { agentScratchDir, ensureDir, sessionAttachmentsDir } from './paths.js'
29
29
  import { loadCodexModels } from './codex-models.js'
30
30
  import { fuzzyScore } from './fuzzy.js'
31
31
  import { MAX_DELIBERATION_ROUNDS } from './deliberation.js'
32
- import { buildUserContent, finalizeUserContent, inputTextFromContent, mediaTypeFor } from './attachments.js'
32
+ import { buildUserContent, finalizeUserContent, inputTextFromContent, mediaTypeFor, stashImages } from './attachments.js'
33
33
 
34
34
  export const EFFORT_LEVELS = [
35
35
  { key: null, desc: 'let the provider decide how much to think' },
@@ -225,6 +225,10 @@ export function createController({ boot }) {
225
225
  },
226
226
  })
227
227
 
228
+ // a running deliberation's current speaker streams into this buffer; it
229
+ // is never persisted, the turn event carries the settled text
230
+ const liveDeliberations = new Map()
231
+
228
232
  const deliberations = {
229
233
  run: async ({ brief, rounds, signal }) => {
230
234
  const options = validateDeliberation({ brief, rounds })
@@ -240,6 +244,18 @@ export function createController({ boot }) {
240
244
  if (!sessionId) throw new Error('deliberation requires an active session')
241
245
  persist(makeEvent('deliberation_start', { deliberationId: id, brief, rounds, model: modelName }))
242
246
  bumpActivity()
247
+ const live = { role: null, round: null, text: '' }
248
+ liveDeliberations.set(id, live)
249
+ const speak = (role, round) => (event) => {
250
+ if (event.type === 'content') {
251
+ if (live.role !== role || live.round !== round) Object.assign(live, { role, round, text: '' })
252
+ live.text += event.content
253
+ bumpActivity()
254
+ } else if (event.type === 'tool_calls_ready' && live.text) {
255
+ live.text = ''
256
+ bumpActivity()
257
+ }
258
+ }
243
259
 
244
260
  const persistDeliberation = (event) => {
245
261
  persist(event)
@@ -281,14 +297,18 @@ export function createController({ boot }) {
281
297
  history,
282
298
  role,
283
299
  onStream: (event) => {
300
+ speak(role, round)(event)
284
301
  if (['tool_executing', 'tool_complete', 'tool_error'].includes(event.type)) {
285
302
  persistDeliberation(makeEvent('deliberation_event', { deliberationId: id, role, round, event }))
286
303
  }
287
304
  },
288
305
  }),
289
- runSynthesis: ({ history }) => runWorker({ history, role: 'synthesizer', tools: false }),
290
- onEvent: (event) => persistDeliberation(makeEvent('deliberation_turn', { deliberationId: id, ...event })),
291
- })
306
+ runSynthesis: ({ history }) => runWorker({ history, role: 'synthesizer', tools: false, onStream: speak('synthesis', null) }),
307
+ onEvent: (event) => {
308
+ Object.assign(live, { role: null, round: null, text: '' })
309
+ persistDeliberation(makeEvent('deliberation_turn', { deliberationId: id, ...event }))
310
+ },
311
+ }).finally(() => liveDeliberations.delete(id))
292
312
  persistDeliberation(makeEvent('deliberation_result', { deliberationId: id, result: result.result, usage: result.usage, interrupted: result.interrupted, error: result.error }))
293
313
  if (result.error) throw new Error(result.error)
294
314
  return {
@@ -370,10 +390,11 @@ export function createController({ boot }) {
370
390
  // that text must not be scanned for image paths or it attaches twice
371
391
  const built = buildUserContent(text, state.attachments)
372
392
  const viewed = built.used.length > 0 && built.used.every((placeholder) => deliveredImages.has(placeholder))
373
- const { content, used } = viewed ? built : finalizeUserContent(text, state.attachments)
393
+ const { content: built2, used } = viewed ? built : finalizeUserContent(text, state.attachments)
374
394
  for (const placeholder of used) deliveredImages.delete(placeholder)
375
- persist(makeEvent('message', { message: { role: 'user', content }, ...(viewed ? { origin: 'view' } : {}) }))
376
395
  ensureSession()
396
+ const content = state.session.file ? await stashImages(built2, sessionAttachmentsDir(boot.root, state.session.id)) : built2
397
+ persist(makeEvent('message', { message: { role: 'user', content }, ...(viewed ? { origin: 'view' } : {}) }))
377
398
  reDerive()
378
399
  await runAgentTurn()
379
400
  }
@@ -1217,7 +1238,11 @@ export function createController({ boot }) {
1217
1238
  }
1218
1239
 
1219
1240
  function activity() {
1220
- const rows = [...agents.list(), ...deliberationsFromEvents(state.events)]
1241
+ const withLive = (item) => {
1242
+ const live = liveDeliberations.get(item.deliberationId)
1243
+ return live?.role ? { ...item, live: { ...live } } : item
1244
+ }
1245
+ const rows = [...agents.list(), ...deliberationsFromEvents(state.events).map(withLive)]
1221
1246
  return rows.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0))
1222
1247
  }
1223
1248
 
package/src/paths.js CHANGED
@@ -33,6 +33,10 @@ export function sessionScratchDir(root, sessionId) {
33
33
  return join(projectDir(root), 'scratchpads', sessionId)
34
34
  }
35
35
 
36
+ export function sessionAttachmentsDir(root, sessionId) {
37
+ return join(projectDir(root), 'attachments', sessionId)
38
+ }
39
+
36
40
  export function agentScratchDir(root, sessionId, agentId) {
37
41
  return join(sessionScratchDir(root, sessionId), `agent-${agentId}`)
38
42
  }
package/src/session.js CHANGED
@@ -145,6 +145,7 @@ export async function deleteSession(file) {
145
145
  })
146
146
  await removeFromSessionIndex(file)
147
147
  await rm(join(project, 'scratchpads', sessionId), { recursive: true, force: true })
148
+ await rm(join(project, 'attachments', sessionId), { recursive: true, force: true })
148
149
  }
149
150
 
150
151
  export function deleteProjectData(root) {