picocode-core 0.9.198 → 0.9.200

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.198",
3
+ "version": "0.9.200",
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 (isDeepStrictEqual(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 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
+ }
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
  import { plain } from './daemon-wire.js'
3
+ import { diffSnapshot } from './daemon-patch.js'
3
4
  import { forkSession, loadSession } from './session.js'
4
5
 
5
6
  const events = ['change', 'derived', 'flash', 'input', 'question', 'turn', 'session', 'resumed', 'mcp', 'shells', 'git', 'project', 'image']
@@ -37,19 +38,35 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
37
38
  async function createRecord(client, options) {
38
39
  const created = await createSession(options)
39
40
  const ctl = created.controller || created
40
- const record = { id: randomUUID(), ctl, dispose: created.dispose, views: new Set([client]), events: [], timer: null, unsub: [] }
41
+ const record = { id: randomUUID(), ctl, dispose: created.dispose, views: new Set([client]), events: [], timer: null, unsub: [], baselines: new Map() }
41
42
  sessions.set(record.id, record)
42
43
  for (const type of events) record.unsub.push(ctl.on(type, payload => publish(record, type, payload)))
43
44
  return record
44
45
  }
46
+ function fullSnapshot(record, client) {
47
+ const value = snapshot(record.ctl)
48
+ const sequence = (record.baselines.get(client)?.sequence || 0) + 1
49
+ record.baselines.set(client, { snapshot: value, sequence })
50
+ return { snapshot: value, sequence }
51
+ }
52
+ function flush(record) {
53
+ clearTimeout(record.timer)
54
+ record.timer = null
55
+ const next = snapshot(record.ctl)
56
+ const notifications = record.events.splice(0)
57
+ for (const client of record.views) {
58
+ const baseline = record.baselines.get(client)
59
+ if (!baseline) continue
60
+ const sequence = baseline.sequence + 1
61
+ const patches = diffSnapshot(baseline.snapshot, next)
62
+ record.baselines.set(client, { snapshot: next, sequence })
63
+ client.send({ type: 'update', id: record.id, patches, baseSequence: baseline.sequence, sequence, events: notifications })
64
+ }
65
+ for (const client of clients) client.send({ type: 'workspace' })
66
+ }
45
67
  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)
68
+ if (!['change', 'derived'].includes(type)) record.events.push({ type, payload: plain(type === 'project' ? null : payload) })
69
+ if (!record.timer) record.timer = setTimeout(() => flush(record), batchMs)
53
70
  }
54
71
  async function dispose(record) {
55
72
  if (!sessions.delete(record.id)) return
@@ -63,6 +80,7 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
63
80
  const record = sessions.get(id)
64
81
  if (!record) return
65
82
  record.views.delete(client)
83
+ record.baselines.delete(client)
66
84
  if (!record.views.size && !named(record.ctl)) await dispose(record)
67
85
  }
68
86
  return {
@@ -88,11 +106,12 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
88
106
  })
89
107
  if (op === 'create') {
90
108
  const record = await createRecord(client, args[0] || {})
91
- return { id: record.id, snapshot: snapshot(record.ctl) }
109
+ return { id: record.id, ...fullSnapshot(record, client) }
92
110
  }
93
111
  const record = sessions.get(id)
94
112
  if (!record) throw new Error('session is no longer running')
95
- if (op === 'attach') { record.views.add(client); return { id, snapshot: snapshot(record.ctl) } }
113
+ if (op === 'attach') { record.views.add(client); return { id, ...fullSnapshot(record, client) } }
114
+ if (op === 'snapshot') { if (!record.views.has(client)) throw new Error('attach first'); return fullSnapshot(record, client) }
96
115
  if (op === 'switch') {
97
116
  const [method, value] = args
98
117
  if (!record.views.has(client)) throw new Error('attach to the session first')
@@ -102,7 +121,7 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
102
121
  if (existing) {
103
122
  existing.views.add(client)
104
123
  if (existing !== record) await detach(client, id)
105
- return { id: existing.id, snapshot: snapshot(existing.ctl) }
124
+ return { id: existing.id, ...fullSnapshot(existing, client) }
106
125
  }
107
126
  const saved = await loadSession(meta.file)
108
127
  meta = { ...meta, header: saved.header }
@@ -125,7 +144,7 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
125
144
  if (renamed === false || next.ctl.state.derived?.title !== name) throw new Error(`could not create session named "${name}"`)
126
145
  }
127
146
  await detach(client, id)
128
- return { id: next.id, snapshot: snapshot(next.ctl) }
147
+ return { id: next.id, ...fullSnapshot(next, client) }
129
148
  } catch (error) { await dispose(next); throw error }
130
149
  }
131
150
  if (op === 'detach') { await detach(client, id); return }
@@ -150,11 +169,12 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
150
169
  if (target === 'controller' && method === 'compact') {
151
170
  Promise.resolve(object[method](...values)).catch(error => publish(record, 'flash', error.message))
152
171
  publish(record, 'change')
153
- return { result: undefined, snapshot: snapshot(record.ctl) }
172
+ return { result: undefined, incremental: true }
154
173
  }
155
174
  const result = await object[method](...values)
156
175
  publish(record, 'change')
157
- return { result, snapshot: snapshot(record.ctl) }
176
+ flush(record)
177
+ return { result, incremental: true }
158
178
  }
159
179
  if (op === 'configure') {
160
180
  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,3 +1,4 @@
1
+ import { applyPatches } from './daemon-patch.js'
1
2
  import { deriveState } from './derive.js'
2
3
 
3
4
  export async function createRemoteController(connection, { id, options = {}, localBoot = {} } = {}) {
@@ -6,16 +7,21 @@ export async function createRemoteController(connection, { id, options = {}, loc
6
7
  const state = {}
7
8
  const boot = { refs: {}, ...localBoot }
8
9
  const listeners = new Map()
10
+ const sequences = new Map([[id, initial.sequence]])
11
+ let recovering = false
9
12
  let current
10
13
  let previewId = null
11
14
  let previewQueue = Promise.resolve()
12
15
  const displayedId = () => previewId || id
13
16
  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
17
+ const call = (target, method, ...args) => {
18
+ const result = connection.request('call', id, [target, method, ...args]).then(reply => {
19
+ if (reply.snapshot) apply(reply.snapshot)
20
+ emit('change', state)
21
+ return reply.result
22
+ })
23
+ result.catch(error => emit('flash', error.message))
24
+ return result
19
25
  }
20
26
  function apply(snapshot) {
21
27
  current = snapshot
@@ -61,6 +67,7 @@ export async function createRemoteController(connection, { id, options = {}, loc
61
67
  if (previousPreview && previousPreview !== nextId) await connection.request('detach', previousPreview)
62
68
  id = next.id
63
69
  controller.id = id
70
+ sequences.set(next.id, next.sequence)
64
71
  apply(next.snapshot)
65
72
  await connection.request('detach', previous)
66
73
  emit('project', boot)
@@ -74,6 +81,7 @@ export async function createRemoteController(connection, { id, options = {}, loc
74
81
  const next = await connection.request('switch', id, [method, value])
75
82
  id = next.id
76
83
  controller.id = id
84
+ sequences.set(next.id, next.sequence)
77
85
  apply(next.snapshot)
78
86
  if (method === 'newSession' && typeof value === 'string' && value.trim() && state.derived?.title !== value.trim()) {
79
87
  const renamed = await call('controller', 'rename', value.trim())
@@ -96,6 +104,7 @@ export async function createRemoteController(connection, { id, options = {}, loc
96
104
  const previous = previewId
97
105
  const original = await connection.request('attach', id)
98
106
  previewId = null
107
+ sequences.set(id, original.sequence)
99
108
  show(original.snapshot)
100
109
  await connection.request('detach', previous)
101
110
  }
@@ -106,6 +115,7 @@ export async function createRemoteController(connection, { id, options = {}, loc
106
115
  const next = await connection.request('attach', nextId)
107
116
  const previous = previewId
108
117
  previewId = nextId
118
+ sequences.set(nextId, next.sequence)
109
119
  show(next.snapshot)
110
120
  if (previous) await connection.request('detach', previous)
111
121
  })
@@ -125,7 +135,26 @@ export async function createRemoteController(connection, { id, options = {}, loc
125
135
  const unsubscribe = connection.on(message => {
126
136
  if (message.type === 'disconnect') { state.busy = false; state.streaming = null; emit('flash', 'daemon disconnected; work was not restarted'); emit('change', state); return }
127
137
  if (message.type !== 'update' || message.id !== displayedId()) return
128
- apply(message.snapshot)
138
+ if (message.patches) {
139
+ if (recovering) return
140
+ if (sequences.get(message.id) !== message.baseSequence) {
141
+ recovering = true
142
+ const recoveringId = displayedId()
143
+ connection.request('snapshot', recoveringId).then(reply => {
144
+ if (displayedId() !== recoveringId) return
145
+ sequences.set(recoveringId, reply.sequence)
146
+ apply(reply.snapshot)
147
+ emit('change', state)
148
+ }).catch(error => emit('flash', error.message)).finally(() => { recovering = false })
149
+ return
150
+ }
151
+ apply(applyPatches(current, message.patches))
152
+ sequences.set(message.id, message.sequence)
153
+ emit('derived', state.derived)
154
+ } else {
155
+ apply(message.snapshot)
156
+ emit('derived', state.derived)
157
+ }
129
158
  for (const event of message.events) {
130
159
  if (previewId && ['input', 'question', 'resumed', 'flash'].includes(event.type)) continue
131
160
  emit(event.type, event.type === 'project' ? boot : event.payload)