picocode-core 0.9.199 → 0.9.201

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.199",
3
+ "version": "0.9.201",
4
4
  "description": "The agent runtime behind pico: sessions, tools, subagents, MCP, memory, and model access",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,35 @@
1
+ import { isDeepStrictEqual } from 'node:util'
2
+
3
+ export function diffSnapshot(previous, next, path = [], patches = []) {
4
+ if (Object.is(previous, next)) return patches
5
+ if (typeof previous === 'string' && typeof next === 'string' && next.startsWith(previous)) {
6
+ patches.push({ op: 'appendText', path, value: next.slice(previous.length) })
7
+ } else if (Array.isArray(previous) && Array.isArray(next)) {
8
+ const common = Math.min(previous.length, next.length)
9
+ for (let i = 0; i < common; i++) diffSnapshot(previous[i], next[i], [...path, i], patches)
10
+ if (next.length < previous.length) patches.push({ op: 'truncate', path, length: next.length })
11
+ if (next.length > previous.length) patches.push({ op: 'append', path, value: next.slice(previous.length) })
12
+ } else if (previous && next && Object.getPrototypeOf(previous) === Object.prototype && Object.getPrototypeOf(next) === Object.prototype) {
13
+ for (const key of Object.keys(previous)) if (!(key in next)) patches.push({ op: 'delete', path: [...path, key] })
14
+ for (const key of Object.keys(next)) diffSnapshot(previous[key], next[key], [...path, key], patches)
15
+ } else if (!isDeepStrictEqual(previous, next)) patches.push({ op: 'set', path, value: next })
16
+ return patches
17
+ }
18
+
19
+ export function applyPatches(snapshot, patches) {
20
+ function update(value, path, patch) {
21
+ if (!path.length) {
22
+ if (patch.op === 'appendText') return value + patch.value
23
+ if (patch.op === 'append') return [...value, ...patch.value]
24
+ if (patch.op === 'truncate') return value.slice(0, patch.length)
25
+ return patch.value
26
+ }
27
+ const copy = Array.isArray(value) ? [...value] : { ...value }
28
+ const [key, ...rest] = path
29
+ if (!rest.length && patch.op === 'delete') delete copy[key]
30
+ else copy[key] = update(value[key], rest, patch)
31
+ return copy
32
+ }
33
+ for (const patch of patches) snapshot = update(snapshot, patch.path, patch)
34
+ return snapshot
35
+ }
@@ -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,5 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
  import { plain } from './daemon-wire.js'
3
+ import { diffSnapshot } from './daemon-patch.js'
4
+ import { createProjection, defaultTranscriptLimit } from './daemon-projection.js'
3
5
  import { forkSession, loadSession } from './session.js'
4
6
 
5
7
  const events = ['change', 'derived', 'flash', 'input', 'question', 'turn', 'session', 'resumed', 'mcp', 'shells', 'git', 'project', 'image']
@@ -7,7 +9,11 @@ const queries = ['activity', 'shellRows', 'costSummary', 'speedApplies', 'effort
7
9
  const busy = ctl => ctl.isWorking?.() || ctl.state.busy || ctl.state.compacting || ctl.agents?.list().some(a => ['running', 'queued'].includes(a.status))
8
10
  const named = ctl => !!ctl.state.derived?.title?.trim()
9
11
 
10
- 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)
11
17
  const boot = ctl.boot
12
18
  const services = {}
13
19
  for (const [service, methods] of Object.entries({ shells: ['list', 'running'], wakeups: ['list', 'pending'], git: ['status'], mcp: ['list'], commands: ['list'], skills: ['list'] })) {
@@ -21,7 +27,7 @@ export function snapshot(ctl) {
21
27
  }
22
28
  const cached = {}
23
29
  for (const method of queries) if (typeof ctl[method] === 'function') cached[method] = plain(ctl[method]())
24
- 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)) }
25
31
  }
26
32
 
27
33
  export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs = 16 }) {
@@ -37,19 +43,38 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
37
43
  async function createRecord(client, options) {
38
44
  const created = await createSession(options)
39
45
  const ctl = created.controller || created
40
- const record = { id: randomUUID(), ctl, dispose: created.dispose, views: new Set([client]), events: [], timer: null, unsub: [] }
46
+ const record = { id: randomUUID(), ctl, dispose: created.dispose, views: new Set([client]), events: [], timer: null, unsub: [], baselines: new Map(), limits: new Map() }
41
47
  sessions.set(record.id, record)
42
48
  for (const type of events) record.unsub.push(ctl.on(type, payload => publish(record, type, payload)))
43
49
  return record
44
50
  }
51
+ function fullSnapshot(record, client) {
52
+ const value = snapshot(record.ctl, record.limits.get(client) ?? defaultTranscriptLimit)
53
+ const sequence = (record.baselines.get(client)?.sequence || 0) + 1
54
+ record.baselines.set(client, { snapshot: value, sequence })
55
+ return { snapshot: value, sequence }
56
+ }
57
+ function flush(record) {
58
+ clearTimeout(record.timer)
59
+ record.timer = null
60
+ const snapshots = new Map()
61
+ const notifications = record.events.splice(0)
62
+ for (const client of record.views) {
63
+ const baseline = record.baselines.get(client)
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)
68
+ const sequence = baseline.sequence + 1
69
+ const patches = diffSnapshot(baseline.snapshot, next)
70
+ record.baselines.set(client, { snapshot: next, sequence })
71
+ client.send({ type: 'update', id: record.id, patches, baseSequence: baseline.sequence, sequence, events: notifications })
72
+ }
73
+ for (const client of clients) client.send({ type: 'workspace' })
74
+ }
45
75
  function publish(record, type, payload) {
46
- if (type !== 'change') record.events.push({ type, payload: plain(type === 'project' ? null : payload) })
47
- if (!record.timer) record.timer = setTimeout(() => {
48
- record.timer = null
49
- const update = { type: 'update', id: record.id, snapshot: snapshot(record.ctl), events: record.events.splice(0) }
50
- for (const client of record.views) client.send(update)
51
- for (const client of clients) client.send({ type: 'workspace' })
52
- }, batchMs)
76
+ if (!['change', 'derived'].includes(type)) record.events.push({ type, payload: plain(type === 'project' ? null : payload) })
77
+ if (!record.timer) record.timer = setTimeout(() => flush(record), batchMs)
53
78
  }
54
79
  async function dispose(record) {
55
80
  if (!sessions.delete(record.id)) return
@@ -63,6 +88,8 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
63
88
  const record = sessions.get(id)
64
89
  if (!record) return
65
90
  record.views.delete(client)
91
+ record.baselines.delete(client)
92
+ record.limits.delete(client)
66
93
  if (!record.views.size && !named(record.ctl)) await dispose(record)
67
94
  }
68
95
  return {
@@ -88,11 +115,19 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
88
115
  })
89
116
  if (op === 'create') {
90
117
  const record = await createRecord(client, args[0] || {})
91
- return { id: record.id, snapshot: snapshot(record.ctl) }
118
+ return { id: record.id, ...fullSnapshot(record, client) }
92
119
  }
93
120
  const record = sessions.get(id)
94
121
  if (!record) throw new Error('session is no longer running')
95
- if (op === 'attach') { record.views.add(client); return { id, snapshot: snapshot(record.ctl) } }
122
+ if (op === 'attach') { record.views.add(client); return { id, ...fullSnapshot(record, client) } }
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
+ }
96
131
  if (op === 'switch') {
97
132
  const [method, value] = args
98
133
  if (!record.views.has(client)) throw new Error('attach to the session first')
@@ -102,7 +137,7 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
102
137
  if (existing) {
103
138
  existing.views.add(client)
104
139
  if (existing !== record) await detach(client, id)
105
- return { id: existing.id, snapshot: snapshot(existing.ctl) }
140
+ return { id: existing.id, ...fullSnapshot(existing, client) }
106
141
  }
107
142
  const saved = await loadSession(meta.file)
108
143
  meta = { ...meta, header: saved.header }
@@ -125,7 +160,7 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
125
160
  if (renamed === false || next.ctl.state.derived?.title !== name) throw new Error(`could not create session named "${name}"`)
126
161
  }
127
162
  await detach(client, id)
128
- return { id: next.id, snapshot: snapshot(next.ctl) }
163
+ return { id: next.id, ...fullSnapshot(next, client) }
129
164
  } catch (error) { await dispose(next); throw error }
130
165
  }
131
166
  if (op === 'detach') { await detach(client, id); return }
@@ -150,11 +185,12 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
150
185
  if (target === 'controller' && method === 'compact') {
151
186
  Promise.resolve(object[method](...values)).catch(error => publish(record, 'flash', error.message))
152
187
  publish(record, 'change')
153
- return { result: undefined, snapshot: snapshot(record.ctl) }
188
+ return { result: undefined, incremental: true }
154
189
  }
155
190
  const result = await object[method](...values)
156
191
  publish(record, 'change')
157
- return { result, snapshot: snapshot(record.ctl) }
192
+ flush(record)
193
+ return { result, incremental: true }
158
194
  }
159
195
  if (op === 'configure') {
160
196
  const allowed = ['researchModel', 'shellModel', 'deliberationModel', 'participantAModel', 'participantBModel', 'researchAgentLimit', 'clouds', 'compactToolHistory', 'gitFooter', 'wideSidebar']
@@ -32,12 +32,45 @@ export function wire(socket, receive, fail = () => {}) {
32
32
  } catch (error) { fail(error); socket.destroy() }
33
33
  })
34
34
  socket.on('error', fail)
35
- return message => {
35
+ const updates = new Map()
36
+ let workspacePending = false
37
+ function write(message) {
38
+ if (socket.destroyed) return false
36
39
  const body = serialize(plain(message))
37
- if (body.length > limit || socket.writableLength > limit) { socket.destroy(); return false }
40
+ if (body.length > limit) {
41
+ const error = new Error(`daemon frame exceeds limit (${body.length} bytes)`)
42
+ fail(error)
43
+ socket.destroy(error)
44
+ return false
45
+ }
38
46
  const header = Buffer.alloc(4)
39
47
  header.writeUInt32BE(body.length)
40
48
  socket.write(Buffer.concat([header, body]))
41
49
  return true
42
50
  }
51
+ socket.on('drain', () => {
52
+ for (const [id, update] of updates) {
53
+ if (socket.writableNeedDrain || socket.destroyed) break
54
+ updates.delete(id)
55
+ write(update)
56
+ }
57
+ if (workspacePending && !socket.writableNeedDrain && !socket.destroyed) {
58
+ workspacePending = false
59
+ write({ type: 'workspace' })
60
+ }
61
+ })
62
+ socket.on('close', () => updates.clear())
63
+ return message => {
64
+ if (socket.destroyed) return false
65
+ if (socket.writableNeedDrain && message.type === 'update') {
66
+ const previous = updates.get(message.id)
67
+ updates.set(message.id, { ...message, ...(message.patches && previous?.patches ? { baseSequence: previous.baseSequence, patches: [...previous.patches, ...message.patches] } : {}), events: [...(previous?.events || []), ...(message.events || [])] })
68
+ return true
69
+ }
70
+ if (socket.writableNeedDrain && message.type === 'workspace') {
71
+ workspacePending = true
72
+ return true
73
+ }
74
+ return write(message)
75
+ }
43
76
  }
@@ -1,4 +1,4 @@
1
- import { deriveState } from './derive.js'
1
+ import { applyPatches } from './daemon-patch.js'
2
2
 
3
3
  export async function createRemoteController(connection, { id, options = {}, localBoot = {} } = {}) {
4
4
  const initial = await connection.request(id ? 'attach' : 'create', id, id ? [] : [options])
@@ -6,16 +6,21 @@ export async function createRemoteController(connection, { id, options = {}, loc
6
6
  const state = {}
7
7
  const boot = { refs: {}, ...localBoot }
8
8
  const listeners = new Map()
9
+ const sequences = new Map([[id, initial.sequence]])
10
+ let recovering = false
9
11
  let current
10
12
  let previewId = null
11
13
  let previewQueue = Promise.resolve()
12
14
  const displayedId = () => previewId || id
13
15
  const emit = (type, value) => { for (const fn of listeners.get(type) || []) fn(value) }
14
- const call = async (target, method, ...args) => {
15
- const reply = await connection.request('call', id, [target, method, ...args])
16
- apply(reply.snapshot)
17
- emit('change', state)
18
- return reply.result
16
+ const call = (target, method, ...args) => {
17
+ const result = connection.request('call', id, [target, method, ...args]).then(reply => {
18
+ if (reply.snapshot) apply(reply.snapshot)
19
+ emit('change', state)
20
+ return reply.result
21
+ })
22
+ result.catch(error => emit('flash', error.message))
23
+ return result
19
24
  }
20
25
  function apply(snapshot) {
21
26
  current = snapshot
@@ -56,12 +61,12 @@ export async function createRemoteController(connection, { id, options = {}, loc
56
61
  if (nextId === id) { await clearPreview(); return }
57
62
  const previous = id
58
63
  const previousPreview = previewId
59
- const next = await connection.request('attach', nextId)
64
+ const next = previousPreview === nextId ? null : await connection.request('attach', nextId)
60
65
  previewId = null
61
66
  if (previousPreview && previousPreview !== nextId) await connection.request('detach', previousPreview)
62
- id = next.id
67
+ id = nextId
63
68
  controller.id = id
64
- apply(next.snapshot)
69
+ if (next) { sequences.set(next.id, next.sequence); apply(next.snapshot) }
65
70
  await connection.request('detach', previous)
66
71
  emit('project', boot)
67
72
  emit('session', state.session)
@@ -74,6 +79,7 @@ export async function createRemoteController(connection, { id, options = {}, loc
74
79
  const next = await connection.request('switch', id, [method, value])
75
80
  id = next.id
76
81
  controller.id = id
82
+ sequences.set(next.id, next.sequence)
77
83
  apply(next.snapshot)
78
84
  if (method === 'newSession' && typeof value === 'string' && value.trim() && state.derived?.title !== value.trim()) {
79
85
  const renamed = await call('controller', 'rename', value.trim())
@@ -83,7 +89,15 @@ export async function createRemoteController(connection, { id, options = {}, loc
83
89
  emit('session', state.session)
84
90
  emit('change', state)
85
91
  }
86
- 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
+ }
87
101
  function show(snapshot) {
88
102
  apply(snapshot)
89
103
  emit('project', boot)
@@ -96,6 +110,7 @@ export async function createRemoteController(connection, { id, options = {}, loc
96
110
  const previous = previewId
97
111
  const original = await connection.request('attach', id)
98
112
  previewId = null
113
+ sequences.set(id, original.sequence)
99
114
  show(original.snapshot)
100
115
  await connection.request('detach', previous)
101
116
  }
@@ -106,6 +121,7 @@ export async function createRemoteController(connection, { id, options = {}, loc
106
121
  const next = await connection.request('attach', nextId)
107
122
  const previous = previewId
108
123
  previewId = nextId
124
+ sequences.set(nextId, next.sequence)
109
125
  show(next.snapshot)
110
126
  if (previous) await connection.request('detach', previous)
111
127
  })
@@ -125,7 +141,26 @@ export async function createRemoteController(connection, { id, options = {}, loc
125
141
  const unsubscribe = connection.on(message => {
126
142
  if (message.type === 'disconnect') { state.busy = false; state.streaming = null; emit('flash', 'daemon disconnected; work was not restarted'); emit('change', state); return }
127
143
  if (message.type !== 'update' || message.id !== displayedId()) return
128
- apply(message.snapshot)
144
+ if (message.patches) {
145
+ if (recovering) return
146
+ if (sequences.get(message.id) !== message.baseSequence) {
147
+ recovering = true
148
+ const recoveringId = displayedId()
149
+ connection.request('snapshot', recoveringId).then(reply => {
150
+ if (displayedId() !== recoveringId) return
151
+ sequences.set(recoveringId, reply.sequence)
152
+ apply(reply.snapshot)
153
+ emit('change', state)
154
+ }).catch(error => emit('flash', error.message)).finally(() => { recovering = false })
155
+ return
156
+ }
157
+ apply(applyPatches(current, message.patches))
158
+ sequences.set(message.id, message.sequence)
159
+ emit('derived', state.derived)
160
+ } else {
161
+ apply(message.snapshot)
162
+ emit('derived', state.derived)
163
+ }
129
164
  for (const event of message.events) {
130
165
  if (previewId && ['input', 'question', 'resumed', 'flash'].includes(event.type)) continue
131
166
  emit(event.type, event.type === 'project' ? boot : event.payload)