pikiloom 0.4.71 → 0.4.73

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.
@@ -32,6 +32,7 @@ export declare class FsSessionStore implements SessionStore {
32
32
  reconcileRunning(isAlive: (pid: number) => boolean): Promise<number>;
33
33
  appendTurn(agent: string, sessionId: string, turn: UniversalSnapshot): Promise<void>;
34
34
  history(agent: string, sessionId: string): Promise<UniversalSnapshot[]>;
35
+ truncateTurns(agent: string, sessionId: string, throughTaskId: string | null): Promise<void>;
35
36
  }
36
37
  /**
37
38
  * Is a pid currently a live process? `signal 0` probes without delivering a signal: ESRCH =>
@@ -145,6 +145,26 @@ export class FsSessionStore {
145
145
  }
146
146
  return out;
147
147
  }
148
+ // Rewrite turns.jsonl to the prefix through `throughTaskId` inclusive (in-place rewind). null
149
+ // clears the transcript; an unknown id keeps everything (no-op) so a stale cut can't wipe
150
+ // history. Atomic (temp file + rename) so a crash can't leave a half-written store.
151
+ async truncateTurns(agent, sessionId, throughTaskId) {
152
+ const all = await this.history(agent, sessionId);
153
+ let kept;
154
+ if (throughTaskId == null)
155
+ kept = [];
156
+ else {
157
+ const cut = all.findIndex((t) => t.taskId === throughTaskId);
158
+ if (cut < 0)
159
+ return; // unknown cut — leave the transcript untouched
160
+ kept = all.slice(0, cut + 1);
161
+ }
162
+ const p = this.turnsPath(agent, sessionId);
163
+ fs.mkdirSync(path.dirname(p), { recursive: true });
164
+ const tmp = `${p}.tmp-${process.pid}`;
165
+ fs.writeFileSync(tmp, kept.map((t) => JSON.stringify(t)).join('\n') + (kept.length ? '\n' : ''));
166
+ fs.renameSync(tmp, p);
167
+ }
148
168
  }
149
169
  /**
150
170
  * Is a pid currently a live process? `signal 0` probes without delivering a signal: ESRCH =>
@@ -86,6 +86,14 @@ export interface UniversalSnapshot {
86
86
  toolCalls?: UniversalToolCall[];
87
87
  subAgents?: UniversalSubAgent[];
88
88
  usage?: UniversalUsage | null;
89
+ /** Set when the CLI compacted the context mid-turn (its `compact_boundary`
90
+ * event). `trigger` separates an automatic (context-full) compaction from a
91
+ * manual `/compact`. Drives a live "compacting" affordance; because a runner
92
+ * owns one turn, it naturally clears on the next turn. */
93
+ compaction?: {
94
+ trigger: 'auto' | 'manual';
95
+ atTokens?: number | null;
96
+ } | null;
89
97
  artifacts?: UniversalArtifact[];
90
98
  interactions?: UniversalInteraction[];
91
99
  queued?: UniversalQueuedTask[];
@@ -16,7 +16,7 @@ export function emptySnapshot() {
16
16
  return { phase: 'idle', updatedAt: 0 };
17
17
  }
18
18
  const APPEND_FIELDS = ['text', 'reasoning'];
19
- const STRUCT_FIELDS = ['plan', 'toolCalls', 'subAgents', 'usage', 'artifacts', 'interactions', 'queued'];
19
+ const STRUCT_FIELDS = ['plan', 'toolCalls', 'subAgents', 'usage', 'artifacts', 'interactions', 'queued', 'compaction'];
20
20
  const SCALAR_FIELDS = ['phase', 'taskId', 'sessionId', 'agent', 'model', 'effort', 'prompt', 'activity', 'error', 'incomplete', 'startedAt', 'updatedAt', 'anchor'];
21
21
  export function diffSnapshot(prev, next) {
22
22
  const patch = {};
@@ -46,6 +46,13 @@ export declare class Hub implements LoomIO {
46
46
  forkSession(input: ForkSessionInput): Promise<{
47
47
  sessionKey: string;
48
48
  }>;
49
+ rewindSession(input: {
50
+ sessionKey: string;
51
+ atTaskId: string;
52
+ anchor?: string | null;
53
+ }): Promise<{
54
+ sessionKey: string;
55
+ }>;
49
56
  private enqueue;
50
57
  private runNow;
51
58
  private promoteNext;
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { diffSnapshot, emptySnapshot, makeSessionKey, splitSessionKey, } from '../protocol/index.js';
3
3
  import { SessionRunner } from './session-runner.js';
4
4
  import { buildForkSeed } from './fork.js';
5
+ import { normalizeImageAttachments } from '../attachments.js';
5
6
  export class Hub {
6
7
  deps;
7
8
  sessions = new Map();
@@ -143,6 +144,52 @@ export class Hub {
143
144
  this.deps.log?.(`[hub] forked ${input.fromSessionKey} -> ${agent}:${newId} (${mode}${anchor ? `, anchor ${anchor}` : ''}, ${kept.length}/${turns.length} turns)`);
144
145
  return { sessionKey: makeSessionKey(agent, newId) };
145
146
  }
147
+ // Rewind `sessionKey` IN PLACE to a turn boundary: drop the transcript after `atTaskId` (the
148
+ // last KEPT turn) and stamp a pendingRewind the next prompt() consumes (resume the SAME native
149
+ // session at the kept boundary, NO fork). The session id — managed AND native — is unchanged;
150
+ // only the dropped tip leaves the active path. Requires a rewind-capable driver, a store that
151
+ // can truncate, and a resolvable native anchor at the cut; otherwise throws so the caller can
152
+ // fall back (append on the same session, or a fork).
153
+ async rewindSession(input) {
154
+ const { agent, sessionId } = splitSessionKey(input.sessionKey);
155
+ const driver = this.deps.drivers.get(agent);
156
+ if (!driver)
157
+ throw new Error(`No driver registered for agent "${agent}"`);
158
+ if (!driver.capabilities?.rewind)
159
+ throw new Error(`agent "${agent}" has no in-place rewind`);
160
+ if (!this.deps.sessionStore.truncateTurns || !this.deps.sessionStore.history) {
161
+ throw new Error('session store cannot rewind (no truncateTurns/history)');
162
+ }
163
+ const rec = await this.deps.sessionStore.get(agent, sessionId);
164
+ if (!rec)
165
+ throw new Error(`rewind: session ${input.sessionKey} not found`);
166
+ const nativeId = rec.nativeSessionId || sessionId;
167
+ const turns = await this.deps.sessionStore.history(agent, sessionId).catch(() => []);
168
+ const cut = turns.findIndex((t) => t.taskId === input.atTaskId);
169
+ if (cut < 0)
170
+ throw new Error(`rewind point ${input.atTaskId} not found in ${input.sessionKey}`);
171
+ if (cut === turns.length - 1)
172
+ throw new Error(`rewind point ${input.atTaskId} is the tail — nothing to regenerate`);
173
+ const kept = turns.slice(0, cut + 1);
174
+ // Native rebranch boundary: explicit override → the kept tail's recorded anchor. No anchor =
175
+ // the kept turn never recorded one (e.g. a native-only import) — can't rebranch, so refuse.
176
+ const anchor = input.anchor ?? kept[kept.length - 1]?.anchor ?? null;
177
+ if (!anchor)
178
+ throw new Error(`rewind: no native anchor at ${input.atTaskId} in ${input.sessionKey}`);
179
+ // Drop the tip from the managed transcript, then stamp the rewind intent. The native store is
180
+ // untouched here — the next dispatch rebranches it at `anchor` via AgentTurnInput.rewind.
181
+ await this.deps.sessionStore.truncateTurns(agent, sessionId, input.atTaskId);
182
+ const last = kept[kept.length - 1];
183
+ rec.model = last?.model ?? rec.model ?? null;
184
+ rec.effort = last?.effort ?? rec.effort ?? null;
185
+ rec.nativeSessionId = nativeId;
186
+ rec.runState = 'completed';
187
+ rec.runDetail = null;
188
+ rec.pendingRewind = { anchor };
189
+ await this.deps.sessionStore.save(rec);
190
+ this.deps.log?.(`[hub] rewound ${input.sessionKey} to ${input.atTaskId} (anchor ${anchor}, kept ${kept.length}/${turns.length} turns)`);
191
+ return { sessionKey: input.sessionKey };
192
+ }
146
193
  enqueue(item) {
147
194
  const q = this.waiting.get(item.sessionKey) ?? [];
148
195
  q.push(item);
@@ -174,6 +221,9 @@ export class Hub {
174
221
  // first turn keeps the intent, so the retry forks again). Native mode resumes the PARENT's
175
222
  // native id with the fork flag; seed mode starts fresh and replays the copied transcript.
176
223
  const pendingFork = rec && !rec.nativeSessionId ? rec.pendingFork ?? null : null;
224
+ // A rewind keeps the SAME native id (it resumes+rebranches this session), so — unlike a fork —
225
+ // it is NOT gated on a missing native id. Fork and rewind never coexist on one record.
226
+ const pendingRewind = rec?.pendingRewind ?? null;
177
227
  const injection = await this.deps.modelResolver.resolve(agent, { model: input.model, profileId: null }).catch(() => null);
178
228
  const model = injection?.model ?? input.model ?? null;
179
229
  const effort = input.effort ?? null;
@@ -191,6 +241,7 @@ export class Hub {
191
241
  const systemPrompt = await this.composeSystemPrompt(agent, workdir, !preExisted || pendingFork?.mode === 'seed');
192
242
  let driverSessionId = rec?.nativeSessionId || (input.sessionKey ? sessionId : null);
193
243
  let driverFork = null;
244
+ let driverRewind = null;
194
245
  let driverPrompt = input.prompt;
195
246
  if (pendingFork) {
196
247
  if (pendingFork.mode === 'native' && pendingFork.parentNativeSessionId) {
@@ -204,11 +255,20 @@ export class Hub {
204
255
  driverPrompt = `${seed}\n\n${input.prompt}`;
205
256
  }
206
257
  }
258
+ else if (pendingRewind) {
259
+ // In-place rewind: resume THIS session's own native id but rebranch at the kept boundary
260
+ // (no fork), so the dropped tip leaves the active path and the prompt regenerates from it.
261
+ driverSessionId = rec?.nativeSessionId || sessionId;
262
+ driverRewind = { anchor: pendingRewind.anchor };
263
+ }
207
264
  const turnInput = {
208
265
  prompt: driverPrompt,
209
- attachments: input.attachments,
266
+ // Oversized images are downscaled here and in steer() — the two dispatch chokepoints —
267
+ // so every driver (built-in or host-plugged) sends model-safe attachments.
268
+ attachments: await normalizeImageAttachments(input.attachments, this.deps.log),
210
269
  sessionId: driverSessionId,
211
270
  fork: driverFork,
271
+ rewind: driverRewind,
212
272
  workdir,
213
273
  model, effort, systemPrompt,
214
274
  env: spawn.env,
@@ -239,6 +299,20 @@ export class Hub {
239
299
  this.deps.log?.(`[hub] pendingFork clear failed ${sessionKey}: ${e?.message || e}`);
240
300
  }
241
301
  }
302
+ // A rewind materialized (the turn ran and rebranched the native session) — consume the
303
+ // intent so a normal follow-up prompt appends to the regenerated tip instead of rewinding.
304
+ if (pendingRewind && result.sessionId) {
305
+ try {
306
+ const settled = await this.deps.sessionStore.get(agent, sessionId);
307
+ if (settled?.pendingRewind) {
308
+ settled.pendingRewind = null;
309
+ await this.deps.sessionStore.save(settled);
310
+ }
311
+ }
312
+ catch (e) {
313
+ this.deps.log?.(`[hub] pendingRewind clear failed ${sessionKey}: ${e?.message || e}`);
314
+ }
315
+ }
242
316
  // Persist the completed turn's final snapshot as a transcript entry. The runner's
243
317
  // snapshot is stable once done (a follow-up prompt spawns a fresh runner+snapshot).
244
318
  try {
@@ -396,7 +470,7 @@ export class Hub {
396
470
  }
397
471
  async steer(taskId, prompt, attachments) {
398
472
  const r = this.runnersByTask.get(taskId);
399
- return r ? r.steer(prompt, attachments) : false;
473
+ return r ? r.steer(prompt, await normalizeImageAttachments(attachments, this.deps.log)) : false;
400
474
  }
401
475
  interact(promptId, action, value) {
402
476
  for (const r of this.runnersByTask.values()) {
@@ -96,6 +96,9 @@ export class SessionRunner {
96
96
  case 'usage':
97
97
  this.snapshot.usage = mergeUsage(this.snapshot.usage, e.usage);
98
98
  break;
99
+ case 'compaction':
100
+ this.snapshot.compaction = { trigger: e.trigger, atTokens: e.atTokens ?? null };
101
+ break;
99
102
  // toolCalls is the structured SSOT; activity is its derived human-readable view.
100
103
  // A driver that streams explicit `activity` lines (e.g. echo) owns activity directly;
101
104
  // any driver that emits structured tool/subagent events gets the projection for free.