picocode-core 0.9.140 → 0.9.142

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.140",
3
+ "version": "0.9.142",
4
4
  "description": "The agent runtime behind pico: sessions, tools, subagents, MCP, memory, and model access",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { writeFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
  import { makeEvent } from './events.js'
5
- import { createSession, forkSession, openSession, loadSession, listSessions, deleteSession, appendSessionEvent, onSessionWriteError } from './session.js'
5
+ import { createSession, createEphemeralSession, forkSession, openSession, loadSession, listSessions, deleteSession, appendSessionEvent, onSessionWriteError } from './session.js'
6
6
  import { createContextTracker } from './context.js'
7
7
  import { deriveState, userEntries, rewindStats } from './derive.js'
8
8
  import { appendPrompt } from './history.js'
@@ -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' },
@@ -154,7 +154,7 @@ export function createController({ boot }) {
154
154
  }
155
155
 
156
156
  function ensureSession() {
157
- if (!state.session) state.session = createSession({ cwd: boot.cwd, root: boot.root })
157
+ if (!state.session) state.session = (boot.ephemeral ? createEphemeralSession : createSession)({ cwd: boot.cwd, root: boot.root })
158
158
  while (state.persisted < state.events.length) {
159
159
  state.session.append(state.events[state.persisted])
160
160
  state.persisted++
@@ -370,10 +370,11 @@ export function createController({ boot }) {
370
370
  // that text must not be scanned for image paths or it attaches twice
371
371
  const built = buildUserContent(text, state.attachments)
372
372
  const viewed = built.used.length > 0 && built.used.every((placeholder) => deliveredImages.has(placeholder))
373
- const { content, used } = viewed ? built : finalizeUserContent(text, state.attachments)
373
+ const { content: built2, used } = viewed ? built : finalizeUserContent(text, state.attachments)
374
374
  for (const placeholder of used) deliveredImages.delete(placeholder)
375
- persist(makeEvent('message', { message: { role: 'user', content }, ...(viewed ? { origin: 'view' } : {}) }))
376
375
  ensureSession()
376
+ const content = state.session.file ? await stashImages(built2, sessionAttachmentsDir(boot.root, state.session.id)) : built2
377
+ persist(makeEvent('message', { message: { role: 'user', content }, ...(viewed ? { origin: 'view' } : {}) }))
377
378
  reDerive()
378
379
  await runAgentTurn()
379
380
  }
@@ -738,7 +739,7 @@ export function createController({ boot }) {
738
739
  }
739
740
  try {
740
741
  await session.flush()
741
- await deleteSession(session.file)
742
+ if (session.file) await deleteSession(session.file)
742
743
  } catch (err) {
743
744
  flash(`delete failed: ${errorText(err, 80)}`)
744
745
  return false
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
@@ -90,9 +90,16 @@ export function createSession({ cwd, root, forkedFrom }) {
90
90
  return session
91
91
  }
92
92
 
93
+ // an ephemeral session keeps its events in memory only: no file, no index
94
+ // entry, nothing to resume once it is closed
95
+ export function createEphemeralSession({ cwd, root, forkedFrom }) {
96
+ const header = makeHeader({ cwd, root, forkedFrom })
97
+ return { id: header.id, file: null, header, ephemeral: true, append() {}, async flush() {} }
98
+ }
99
+
93
100
  export async function forkSession({ source, cwd, root, events, label }) {
94
101
  await source?.flush()
95
- const session = createSession({ cwd, root, forkedFrom: source?.id })
102
+ const session = source?.ephemeral ? createEphemeralSession({ cwd, root, forkedFrom: source.id }) : createSession({ cwd, root, forkedFrom: source?.id })
96
103
  for (const event of events) session.append(event)
97
104
  const title = makeEvent('title', { text: label })
98
105
  session.append(title)
@@ -138,6 +145,7 @@ export async function deleteSession(file) {
138
145
  })
139
146
  await removeFromSessionIndex(file)
140
147
  await rm(join(project, 'scratchpads', sessionId), { recursive: true, force: true })
148
+ await rm(join(project, 'attachments', sessionId), { recursive: true, force: true })
141
149
  }
142
150
 
143
151
  export function deleteProjectData(root) {