picocode-core 0.9.200 → 0.9.202

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.200",
3
+ "version": "0.9.202",
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/controller.js CHANGED
@@ -105,7 +105,7 @@ function errorText(err, limit) {
105
105
  return String(err?.message || err).slice(0, limit)
106
106
  }
107
107
 
108
- export function createController({ boot, run = runTurn, peerDirectory } = {}) {
108
+ export function createController({ boot, run = runTurn, compactRun = compactHistory, peerDirectory } = {}) {
109
109
  if (!Object.hasOwn(boot, 'participantAModel')) boot.participantAModel = boot.proposerModel ?? null
110
110
  if (!Object.hasOwn(boot, 'participantBModel')) boot.participantBModel = boot.reviewerModel ?? null
111
111
  const { on, emit } = createEmitter()
@@ -599,7 +599,7 @@ export function createController({ boot, run = runTurn, peerDirectory } = {}) {
599
599
  return next
600
600
  }
601
601
 
602
- async function compact(instructions = '') {
602
+ async function compact(instructions = '', { automatic = false } = {}) {
603
603
  if (state.busy || state.compacting || peerTransition) return flash('finish or interrupt the current turn first')
604
604
  const current = state.derived
605
605
  if (current.providerHistory.length < 4) return flash('nothing to compact yet')
@@ -618,7 +618,7 @@ export function createController({ boot, run = runTurn, peerDirectory } = {}) {
618
618
  const keepFrom = compactionKeepFrom(current, state.model.context)
619
619
  let streamed = ''
620
620
  try {
621
- const raw = await compactHistory({
621
+ const raw = await compactRun({
622
622
  history: current.providerHistory,
623
623
  modelName: state.model.name,
624
624
  ...(state.model.provider === 'codex' && { speed: speedApplies() ? state.speed : 'standard' }),
@@ -631,6 +631,7 @@ export function createController({ boot, run = runTurn, peerDirectory } = {}) {
631
631
  set({ compactStatus: compactProgress(streamed) })
632
632
  },
633
633
  })
634
+ controller.signal.throwIfAborted()
634
635
  const summary = formatCompactSummary(raw)
635
636
  if (!summary) throw new Error('empty summary')
636
637
  if (summarySections(summary) < 5) throw new Error('malformed summary, conversation left untouched')
@@ -638,6 +639,12 @@ export function createController({ boot, run = runTurn, peerDirectory } = {}) {
638
639
  reDerive()
639
640
  set({ compactOutcome: 'done' })
640
641
  flash('compacted · recent messages kept verbatim')
642
+ if (automatic) {
643
+ noteSystem(
644
+ '[system notification] this conversation was automatically compacted to free context. Continue any unfinished work from where you left off. If the task is already complete or you are waiting for user input, stop. Do not recap or acknowledge the compaction.',
645
+ { wake: true },
646
+ )
647
+ }
641
648
  } catch (err) {
642
649
  set({ compactOutcome: controller.signal.aborted ? 'cancelled' : 'failed' })
643
650
  if (controller.signal.aborted) flash('compaction cancelled')
@@ -667,7 +674,7 @@ export function createController({ boot, run = runTurn, peerDirectory } = {}) {
667
674
  const ratio = used / limit
668
675
  if (ratio >= 0.85) {
669
676
  flash(`context ${Math.round(ratio * 100)}% full · auto-compacting`)
670
- compact()
677
+ compact('', { automatic: true })
671
678
  }
672
679
  }
673
680
 
@@ -1,7 +1,7 @@
1
1
  import { isDeepStrictEqual } from 'node:util'
2
2
 
3
3
  export function diffSnapshot(previous, next, path = [], patches = []) {
4
- if (isDeepStrictEqual(previous, next)) return patches
4
+ if (Object.is(previous, next)) return patches
5
5
  if (typeof previous === 'string' && typeof next === 'string' && next.startsWith(previous)) {
6
6
  patches.push({ op: 'appendText', path, value: next.slice(previous.length) })
7
7
  } else if (Array.isArray(previous) && Array.isArray(next)) {
@@ -12,7 +12,7 @@ export function diffSnapshot(previous, next, path = [], patches = []) {
12
12
  } else if (previous && next && Object.getPrototypeOf(previous) === Object.prototype && Object.getPrototypeOf(next) === Object.prototype) {
13
13
  for (const key of Object.keys(previous)) if (!(key in next)) patches.push({ op: 'delete', path: [...path, key] })
14
14
  for (const key of Object.keys(next)) diffSnapshot(previous[key], next[key], [...path, key], patches)
15
- } else patches.push({ op: 'set', path, value: next })
15
+ } else if (!isDeepStrictEqual(previous, next)) patches.push({ op: 'set', path, value: next })
16
16
  return patches
17
17
  }
18
18
 
@@ -0,0 +1,34 @@
1
+ import { plain } from './daemon-wire.js'
2
+
3
+ export const defaultTranscriptLimit = 100
4
+ const stateExcluded = new Set(['events', 'persisted', 'derived', 'attachments', 'rewindUndo'])
5
+ const derivedExcluded = new Set(['transcript', 'providerHistory', 'historyEventIds', 'loadedContext', 'toolItems', 'peerMessages', 'consumedPeerIds', 'pendingPeerMessages', 'deferredConsumedPeers', 'openToolCalls', 'trimmedToolIds', 'toolTrimVersions'])
6
+ const agentExcluded = new Set(['events', 'timeline', 'options', 'controller', 'done', 'resolve'])
7
+ const select = (value, excluded) => Object.fromEntries(Object.entries(value || {}).filter(([key]) => !excluded.has(key)))
8
+
9
+ export function createProjection() {
10
+ const clones = new WeakMap()
11
+ const derivedCache = new WeakMap()
12
+ const windows = new WeakMap()
13
+ function immutable(value) {
14
+ if (!value || typeof value !== 'object') return plain(value)
15
+ if (!clones.has(value)) clones.set(value, plain(value))
16
+ return clones.get(value)
17
+ }
18
+ return {
19
+ state(source, limit = defaultTranscriptLimit) {
20
+ const derived = source.derived || {}
21
+ if (!derivedCache.has(derived)) derivedCache.set(derived, plain(select(derived, derivedExcluded)))
22
+ const transcript = derived.transcript || []
23
+ let byLimit = windows.get(derived)
24
+ if (!byLimit) windows.set(derived, byLimit = new Map())
25
+ const offset = Math.max(0, transcript.length - limit)
26
+ if (!byLimit.has(limit)) byLimit.set(limit, transcript.slice(offset).map(immutable))
27
+ const attachments = new Map([...source.attachments || []].map(([key, value]) => [key, plain(Object.fromEntries(['kind', 'path', 'mediaType', 'hash', 'subject', 'root', 'url', 'selector', 'tag', 'component', 'fromLine', 'toLine', 'fromColumn', 'toColumn'].filter(k => value[k] !== undefined).map(k => [k, value[k]])))]))
28
+ return { ...plain(select(source, stateExcluded)), attachments, rewindUndo: !!source.rewindUndo, derived: { ...derivedCache.get(derived), transcript: byLimit.get(limit), transcriptOffset: offset, transcriptTotal: transcript.length } }
29
+ },
30
+ agents(values) {
31
+ return values.map(value => ({ ...plain(select(value, agentExcluded)), ...(value.timeline ? { timeline: value.timeline.map(immutable) } : {}), events: (value.events || []).filter(event => ['content', 'tool_executing', 'tool_complete', 'tool_error'].includes(event.type)).map(immutable) }))
32
+ },
33
+ }
34
+ }
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
  import { plain } from './daemon-wire.js'
3
3
  import { diffSnapshot } from './daemon-patch.js'
4
+ import { createProjection, defaultTranscriptLimit } from './daemon-projection.js'
4
5
  import { forkSession, loadSession } from './session.js'
5
6
 
6
7
  const events = ['change', 'derived', 'flash', 'input', 'question', 'turn', 'session', 'resumed', 'mcp', 'shells', 'git', 'project', 'image']
@@ -8,7 +9,11 @@ const queries = ['activity', 'shellRows', 'costSummary', 'speedApplies', 'effort
8
9
  const busy = ctl => ctl.isWorking?.() || ctl.state.busy || ctl.state.compacting || ctl.agents?.list().some(a => ['running', 'queued'].includes(a.status))
9
10
  const named = ctl => !!ctl.state.derived?.title?.trim()
10
11
 
11
- export function snapshot(ctl) {
12
+ const projections = new WeakMap()
13
+
14
+ export function snapshot(ctl, limit = defaultTranscriptLimit) {
15
+ if (!projections.has(ctl)) projections.set(ctl, createProjection())
16
+ const projection = projections.get(ctl)
12
17
  const boot = ctl.boot
13
18
  const services = {}
14
19
  for (const [service, methods] of Object.entries({ shells: ['list', 'running'], wakeups: ['list', 'pending'], git: ['status'], mcp: ['list'], commands: ['list'], skills: ['list'] })) {
@@ -22,7 +27,7 @@ export function snapshot(ctl) {
22
27
  }
23
28
  const cached = {}
24
29
  for (const method of queries) if (typeof ctl[method] === 'function') cached[method] = plain(ctl[method]())
25
- return { state: plain(ctl.state), boot: plain(Object.fromEntries(Object.entries(boot).filter(([key]) => !['refs', 'shells', 'wakeups', 'git', 'mcp', 'memory', 'commands', 'skills', 'tracker'].includes(key)))), services, cached, agents: plain(ctl.agents?.list() || []), methods: Object.keys(ctl).filter(k => typeof ctl[k] === 'function' && !['on', 'shutdown'].includes(k)) }
30
+ return { state: projection.state(ctl.state, limit), boot: plain(Object.fromEntries(Object.entries(boot).filter(([key]) => !['refs', 'shells', 'wakeups', 'git', 'mcp', 'memory', 'commands', 'skills', 'tracker'].includes(key)))), services, cached, agents: projection.agents(ctl.agents?.list() || []), methods: Object.keys(ctl).filter(k => typeof ctl[k] === 'function' && !['on', 'shutdown'].includes(k)) }
26
31
  }
27
32
 
28
33
  export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs = 16 }) {
@@ -38,13 +43,13 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
38
43
  async function createRecord(client, options) {
39
44
  const created = await createSession(options)
40
45
  const ctl = created.controller || created
41
- const record = { id: randomUUID(), ctl, dispose: created.dispose, views: new Set([client]), events: [], timer: null, unsub: [], baselines: new Map() }
46
+ const record = { id: randomUUID(), ctl, dispose: created.dispose, views: new Set([client]), events: [], timer: null, unsub: [], baselines: new Map(), limits: new Map() }
42
47
  sessions.set(record.id, record)
43
48
  for (const type of events) record.unsub.push(ctl.on(type, payload => publish(record, type, payload)))
44
49
  return record
45
50
  }
46
51
  function fullSnapshot(record, client) {
47
- const value = snapshot(record.ctl)
52
+ const value = snapshot(record.ctl, record.limits.get(client) ?? defaultTranscriptLimit)
48
53
  const sequence = (record.baselines.get(client)?.sequence || 0) + 1
49
54
  record.baselines.set(client, { snapshot: value, sequence })
50
55
  return { snapshot: value, sequence }
@@ -52,11 +57,14 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
52
57
  function flush(record) {
53
58
  clearTimeout(record.timer)
54
59
  record.timer = null
55
- const next = snapshot(record.ctl)
60
+ const snapshots = new Map()
56
61
  const notifications = record.events.splice(0)
57
62
  for (const client of record.views) {
58
63
  const baseline = record.baselines.get(client)
59
64
  if (!baseline) continue
65
+ const limit = record.limits.get(client) ?? defaultTranscriptLimit
66
+ if (!snapshots.has(limit)) snapshots.set(limit, snapshot(record.ctl, limit))
67
+ const next = snapshots.get(limit)
60
68
  const sequence = baseline.sequence + 1
61
69
  const patches = diffSnapshot(baseline.snapshot, next)
62
70
  record.baselines.set(client, { snapshot: next, sequence })
@@ -81,6 +89,7 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
81
89
  if (!record) return
82
90
  record.views.delete(client)
83
91
  record.baselines.delete(client)
92
+ record.limits.delete(client)
84
93
  if (!record.views.size && !named(record.ctl)) await dispose(record)
85
94
  }
86
95
  return {
@@ -112,6 +121,13 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
112
121
  if (!record) throw new Error('session is no longer running')
113
122
  if (op === 'attach') { record.views.add(client); return { id, ...fullSnapshot(record, client) } }
114
123
  if (op === 'snapshot') { if (!record.views.has(client)) throw new Error('attach first'); return fullSnapshot(record, client) }
124
+ if (op === 'history') {
125
+ if (!record.views.has(client)) throw new Error('attach first')
126
+ const [limit] = args
127
+ if (!Number.isSafeInteger(limit) || limit < 1) throw new Error('history limit must be a positive safe integer')
128
+ record.limits.set(client, Math.max(record.limits.get(client) ?? defaultTranscriptLimit, limit))
129
+ return fullSnapshot(record, client)
130
+ }
115
131
  if (op === 'switch') {
116
132
  const [method, value] = args
117
133
  if (!record.views.has(client)) throw new Error('attach to the session first')
@@ -1,5 +1,4 @@
1
1
  import { applyPatches } from './daemon-patch.js'
2
- import { deriveState } from './derive.js'
3
2
 
4
3
  export async function createRemoteController(connection, { id, options = {}, localBoot = {} } = {}) {
5
4
  const initial = await connection.request(id ? 'attach' : 'create', id, id ? [] : [options])
@@ -62,13 +61,12 @@ export async function createRemoteController(connection, { id, options = {}, loc
62
61
  if (nextId === id) { await clearPreview(); return }
63
62
  const previous = id
64
63
  const previousPreview = previewId
65
- const next = await connection.request('attach', nextId)
64
+ const next = previousPreview === nextId ? null : await connection.request('attach', nextId)
66
65
  previewId = null
67
66
  if (previousPreview && previousPreview !== nextId) await connection.request('detach', previousPreview)
68
- id = next.id
67
+ id = nextId
69
68
  controller.id = id
70
- sequences.set(next.id, next.sequence)
71
- apply(next.snapshot)
69
+ if (next) { sequences.set(next.id, next.sequence); apply(next.snapshot) }
72
70
  await connection.request('detach', previous)
73
71
  emit('project', boot)
74
72
  emit('session', state.session)
@@ -91,7 +89,15 @@ export async function createRemoteController(connection, { id, options = {}, loc
91
89
  emit('session', state.session)
92
90
  emit('change', state)
93
91
  }
94
- controller.previewSteer = changes => changes?.length ? deriveState([...state.events, { id: '__steer_preview__', at: Date.now(), type: 'steer', data: { changes } }]) : state.derived
92
+ controller.previewSteer = changes => call('controller', 'previewSteer', changes)
93
+ controller.loadHistory = async limit => {
94
+ const target = displayedId()
95
+ const reply = await connection.request('history', target, [Number.isFinite(limit) ? limit : Number.MAX_SAFE_INTEGER])
96
+ if (displayedId() !== target) return
97
+ sequences.set(target, reply.sequence)
98
+ apply(reply.snapshot)
99
+ emit('change', state)
100
+ }
95
101
  function show(snapshot) {
96
102
  apply(snapshot)
97
103
  emit('project', boot)