picocode-core 0.9.155 → 0.9.157

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.155",
3
+ "version": "0.9.157",
4
4
  "description": "The agent runtime behind pico: sessions, tools, subagents, MCP, memory, and model access",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/agent.js CHANGED
@@ -1,11 +1,31 @@
1
1
  import { readFile } from 'node:fs/promises'
2
+ import { execFile } from 'node:child_process'
3
+ import { promisify } from 'node:util'
2
4
  import { compose, scope, model, noToolsCalled, Inherit, getText } from '@prsm/ai'
3
- import { fileLabel, mediaTypeFor, selectionLabel } from './attachments.js'
5
+ import { commitLabel, fileLabel, mediaTypeFor, selectionLabel } from './attachments.js'
6
+
7
+ const exec = promisify(execFile)
8
+ const COMMIT_PATCH_LIMIT = 60 * 1024
9
+
10
+ // a commit part carries the commit itself: its header, stat, and patch,
11
+ // cut off past the limit, so the model reads what happened without a
12
+ // git call of its own
13
+ export async function hydrateCommit(part) {
14
+ const head = commitLabel(part)
15
+ try {
16
+ const { stdout } = await exec('git', ['--no-optional-locks', 'show', '--stat', '--patch', '--format=%H%nAuthor: %an <%ae>%nDate: %ad%n%n%B', part.hash], { cwd: part.root, maxBuffer: COMMIT_PATCH_LIMIT * 4 })
17
+ const body = stdout.length > COMMIT_PATCH_LIMIT ? `${stdout.slice(0, COMMIT_PATCH_LIMIT)}\n[patch truncated at ${COMMIT_PATCH_LIMIT} bytes]` : stdout
18
+ return { type: 'text', text: `${head}\n${body.trimEnd()}\n[/commit]` }
19
+ } catch {
20
+ return { type: 'text', text: `${head}\n[commit unavailable]\n[/commit]` }
21
+ }
22
+ }
4
23
 
5
24
  // file parts reach the model as a labelled path; it reads them itself
6
25
  async function hydratePart(part) {
7
26
  if (part.type === 'file') return { type: 'text', text: fileLabel(part.path) }
8
27
  if (part.type === 'selection') return { type: 'text', text: selectionLabel(part) }
28
+ if (part.type === 'commit') return hydrateCommit(part)
9
29
  if (part.type !== 'image' || part.source?.kind !== 'path') return part
10
30
  const mediaType = part.source.mediaType || mediaTypeFor(part.source.path)
11
31
  if (!mediaType) return { type: 'text', text: `[image unavailable: ${part.source.path}]` }
@@ -72,6 +72,7 @@ export function splitTextByImagePaths(text, exists = existsSync) {
72
72
 
73
73
  export const fileLabel = (path) => `[file: ${path}]`
74
74
  export const selectionLabel = (part) => `[selection: ${part.path}:${part.fromLine}-${part.toLine}]\n${part.text}\n[/selection]`
75
+ export const commitLabel = (part) => `[commit: ${part.hash.slice(0, 7)} ${JSON.stringify(part.subject ?? '')}]`
75
76
 
76
77
  // [Image #n] and [File #n] placeholders in the composed text stand for
77
78
  // attachments; they become image, file, or selection parts of the user message
@@ -86,6 +87,8 @@ export function buildUserContent(text, attachments) {
86
87
  if (before) parts.push({ type: 'text', text: before })
87
88
  parts.push(attachment.kind === 'selection'
88
89
  ? { type: 'selection', path: attachment.path, text: attachment.text, fromLine: attachment.fromLine, toLine: attachment.toLine, ...(Number.isInteger(attachment.fromColumn) ? { fromColumn: attachment.fromColumn } : {}), ...(Number.isInteger(attachment.toColumn) ? { toColumn: attachment.toColumn } : {}) }
90
+ : attachment.kind === 'commit'
91
+ ? { type: 'commit', hash: attachment.hash, subject: attachment.subject, root: attachment.root }
89
92
  : attachment.kind === 'file'
90
93
  ? { type: 'file', path: attachment.path }
91
94
  : { type: 'image', source: { kind: 'path', path: attachment.path, mediaType: attachment.mediaType } })
@@ -128,6 +131,10 @@ export function inputTextFromContent(content, { attachments, nextId }) {
128
131
  const placeholder = `[File #${nextId()}]`
129
132
  attachments.set(placeholder, { path: part.path, kind: 'file' })
130
133
  out += placeholder
134
+ } else if (part.type === 'commit' && part.hash) {
135
+ const placeholder = `[File #${nextId()}]`
136
+ attachments.set(placeholder, { hash: part.hash, subject: part.subject, root: part.root, kind: 'commit' })
137
+ out += placeholder
131
138
  } else if (part.type === 'selection' && part.path) {
132
139
  const placeholder = `[File #${nextId()}]`
133
140
  attachments.set(placeholder, { path: part.path, text: part.text, fromLine: part.fromLine, toLine: part.toLine, ...(Number.isInteger(part.fromColumn) ? { fromColumn: part.fromColumn } : {}), ...(Number.isInteger(part.toColumn) ? { toColumn: part.toColumn } : {}), kind: 'selection' })
package/src/controller.js CHANGED
@@ -30,6 +30,7 @@ import { loadCodexModels } from './codex-models.js'
30
30
  import { fuzzyScore } from './fuzzy.js'
31
31
  import { MAX_DELIBERATION_ROUNDS } from './deliberation.js'
32
32
  import { buildUserContent, finalizeUserContent, inputTextFromContent, mediaTypeFor, stashImages } from './attachments.js'
33
+ import { settlePending } from './pending.js'
33
34
 
34
35
  export const EFFORT_LEVELS = [
35
36
  { key: null, desc: 'let the provider decide how much to think' },
@@ -118,6 +119,7 @@ export function createController({ boot }) {
118
119
  streaming: null,
119
120
  queued: [],
120
121
  expedited: [],
122
+ views: [],
121
123
  sent: [],
122
124
  question: null,
123
125
  rewindUndo: null,
@@ -396,16 +398,18 @@ export function createController({ boot }) {
396
398
  state.streaming = null
397
399
  }
398
400
 
399
- async function executeTurn(text) {
400
- // an image the model asked to view arrives with its path in the label;
401
- // that text must not be scanned for image paths or it attaches twice
402
- const built = buildUserContent(text, state.attachments)
403
- const viewed = built.used.length > 0 && built.used.every((placeholder) => deliveredImages.has(placeholder))
404
- const { content: built2, used } = viewed ? built : finalizeUserContent(text, state.attachments)
405
- for (const placeholder of used) deliveredImages.delete(placeholder)
401
+ async function persistUserMessage(text, { viewed = false } = {}) {
402
+ // a view label carries the image's path; only user text is scanned
403
+ // for paths, or the viewed image would attach twice
404
+ const { content: built } = viewed ? buildUserContent(text, state.attachments) : finalizeUserContent(text, state.attachments)
406
405
  ensureSession()
407
- const content = state.session.file ? await stashImages(built2, sessionAttachmentsDir(boot.root, state.session.id)) : built2
406
+ const content = state.session.file ? await stashImages(built, sessionAttachmentsDir(boot.root, state.session.id)) : built
408
407
  persist(makeEvent('message', { message: { role: 'user', content }, ...(viewed ? { origin: 'view' } : {}) }))
408
+ }
409
+
410
+ async function executeTurn(text, { views = [] } = {}) {
411
+ for (const view of views) await persistUserMessage(view, { viewed: true })
412
+ if (text) await persistUserMessage(text)
409
413
  reDerive()
410
414
  await runAgentTurn()
411
415
  }
@@ -463,11 +467,12 @@ export function createController({ boot }) {
463
467
  set({ compacting: false, compactStatus: null, busy: false })
464
468
  }
465
469
 
466
- if (state.expedited.length > 0 || state.queued.length > 0) {
467
- const next = takePending()
468
- if (controller.signal.aborted) emit('input', next.join('\n'))
470
+ if (state.expedited.length > 0 || state.queued.length > 0 || state.views.length > 0) {
471
+ const settled = settlePending({ ...state, interrupted: controller.signal.aborted })
472
+ set({ expedited: [], queued: settled.queued, views: [] })
473
+ if (settled.recall.length > 0) emit('input', settled.recall.join('\n'))
469
474
  else {
470
- executeTurn(next.join('\n'))
475
+ executeTurn(settled.messages.join('\n'), { views: settled.views })
471
476
  return
472
477
  }
473
478
  }
@@ -571,7 +576,7 @@ export function createController({ boot }) {
571
576
  : item,
572
577
  ),
573
578
  )
574
- if (state.expedited.length > 0) {
579
+ if (state.expedited.length > 0 || state.views.length > 0) {
575
580
  sendAfterToolTriggered = true
576
581
  controller.abort()
577
582
  }
@@ -680,18 +685,15 @@ export function createController({ boot }) {
680
685
  )
681
686
  }
682
687
 
683
- const expeditedMessages = state.expedited
684
- const pendingMessages = state.queued
685
- if (expeditedMessages.length > 0 || pendingMessages.length > 0) {
686
- set({ expedited: [], queued: [] })
688
+ if (state.expedited.length > 0 || state.queued.length > 0 || state.views.length > 0) {
687
689
  // an interrupt is the user taking the wheel: nothing pending may
688
- // auto-send, expedited or not; it all returns to the composer
689
- if (result.interrupted && !sendAfterToolTriggered) {
690
- emit('input', [...expeditedMessages, ...pendingMessages].join('\n'))
691
- } else {
692
- const next = sendAfterToolTriggered ? expeditedMessages : [...expeditedMessages, ...pendingMessages]
693
- if (sendAfterToolTriggered && pendingMessages.length > 0) set({ queued: pendingMessages })
694
- executeTurn(next.join('\n'))
690
+ // auto-send, expedited or not; it all returns to the composer, and
691
+ // an image the model asked for is dropped with the turn
692
+ const settled = settlePending({ ...state, afterTool: sendAfterToolTriggered, interrupted: result.interrupted })
693
+ set({ expedited: [], queued: settled.queued, views: [] })
694
+ if (settled.recall.length > 0) emit('input', settled.recall.join('\n'))
695
+ else {
696
+ executeTurn(settled.messages.join('\n'), { views: settled.views })
695
697
  return
696
698
  }
697
699
  }
@@ -749,6 +751,7 @@ export function createController({ boot }) {
749
751
  state.rewindUndo = null
750
752
  state.queued = []
751
753
  state.expedited = []
754
+ state.views = []
752
755
  state.sent = []
753
756
  state.model = model ?? state.defaultModel
754
757
  state.effort = effort === undefined ? state.defaultEffort : effort
@@ -795,6 +798,7 @@ export function createController({ boot }) {
795
798
  state.rewindUndo = null
796
799
  state.queued = []
797
800
  state.expedited = []
801
+ state.views = []
798
802
  state.sent = []
799
803
  agents.restore(forked.events)
800
804
  reDerive()
@@ -854,12 +858,10 @@ export function createController({ boot }) {
854
858
 
855
859
  // an image the model asked to see rides in as an expedited user message,
856
860
  // which the tool-completion path sends right after the current call
857
- const deliveredImages = new Set()
858
861
  function deliverImage(path, label) {
859
862
  const placeholder = attachImage(path)
860
863
  if (!placeholder) return false
861
- deliveredImages.add(placeholder)
862
- set({ expedited: [...state.expedited, `${label}\n${placeholder}`] })
864
+ set({ views: [...state.views, `${label}\n${placeholder}`] })
863
865
  return true
864
866
  }
865
867
 
@@ -877,6 +879,15 @@ export function createController({ boot }) {
877
879
  return placeholder
878
880
  }
879
881
 
882
+ // a commit of the session's repository; the model receives the commit
883
+ // itself when the message is sent
884
+ function attachCommit({ hash, subject = '' }) {
885
+ if (!/^[0-9a-f]{7,40}$/i.test(String(hash ?? ''))) return null
886
+ const placeholder = `[File #${++state.imageCount}]`
887
+ state.attachments.set(placeholder, { hash, subject: String(subject), root: boot.root, kind: 'commit' })
888
+ return placeholder
889
+ }
890
+
880
891
  // a project-relative pick: images attach as images, anything else as a
881
892
  // file reference
882
893
  function attachProjectFile(file) {
@@ -949,7 +960,7 @@ export function createController({ boot }) {
949
960
  boot.git.retarget(next.root)
950
961
  next.mcp.connectAll()
951
962
  emit('mcp', next.mcp.list())
952
- set({ queued: [], expedited: [] })
963
+ set({ queued: [], expedited: [], views: [] })
953
964
  emit('project', boot)
954
965
  await resume(meta)
955
966
  flash(`switched to ${next.displayCwd}`)
@@ -1372,6 +1383,7 @@ export function createController({ boot }) {
1372
1383
  attachImage,
1373
1384
  attachFile,
1374
1385
  attachSelection,
1386
+ attachCommit,
1375
1387
  attachProjectFile,
1376
1388
  detachImage,
1377
1389
  costSummary,
package/src/derive.js CHANGED
@@ -101,7 +101,7 @@ function foldMessage(state, event) {
101
101
  if (message.role === 'user') {
102
102
  const text = Array.isArray(message.content)
103
103
  ? message.content
104
- .map((p) => (p.type === 'text' ? p.text : p.type === 'file' ? `[file: ${p.path}]` : p.type === 'selection' ? `[selection: ${p.path}:${p.fromLine}-${p.toLine}]` : `[image: ${String(p.source?.path || '').split('/').pop() || 'attached'}]`))
104
+ .map((p) => (p.type === 'text' ? p.text : p.type === 'file' ? `[file: ${p.path}]` : p.type === 'selection' ? `[selection: ${p.path}:${p.fromLine}-${p.toLine}]` : p.type === 'commit' ? `[commit: ${String(p.hash).slice(0, 7)}]` : `[image: ${String(p.source?.path || '').split('/').pop() || 'attached'}]`))
105
105
  .join('')
106
106
  : String(message.content)
107
107
  state.transcript.push({ ...base, kind: 'user', text, content: message.content, ...(event.data.origin ? { origin: event.data.origin } : {}) })
package/src/pending.js ADDED
@@ -0,0 +1,8 @@
1
+ // what happens to the messages waiting on a turn once it ends. images the
2
+ // model asked to view travel apart from the user's own text, so a view
3
+ // delivery is never folded into a user message
4
+ export function settlePending({ views = [], expedited = [], queued = [], afterTool = false, interrupted = false }) {
5
+ if (interrupted && !afterTool) return { views: [], messages: [], queued: [], recall: [...expedited, ...queued] }
6
+ if (afterTool) return { views, messages: expedited, queued, recall: [] }
7
+ return { views, messages: [...expedited, ...queued], queued: [], recall: [] }
8
+ }