pikiloom 0.4.71 → 0.4.72

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": "pikiloom",
3
- "version": "0.4.71",
3
+ "version": "0.4.72",
4
4
  "description": "Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via IM. | 让最好用的 IM 变成你电脑上的顶级 Agent 控制台",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,6 +6,9 @@ export interface AgentTurnInput {
6
6
  fork?: {
7
7
  anchor?: string | null;
8
8
  } | null;
9
+ rewind?: {
10
+ anchor?: string | null;
11
+ } | null;
9
12
  workdir: string;
10
13
  model?: string | null;
11
14
  effort?: string | null;
@@ -55,6 +58,10 @@ export type DriverEvent = {
55
58
  } | {
56
59
  type: 'activity';
57
60
  line: string;
61
+ } | {
62
+ type: 'compaction';
63
+ trigger: 'auto' | 'manual';
64
+ atTokens?: number | null;
58
65
  };
59
66
  export type SteerFn = (prompt: string, attachments?: string[]) => Promise<boolean>;
60
67
  export interface DriverContext {
@@ -106,6 +113,7 @@ export interface AgentDriver {
106
113
  resume?: boolean;
107
114
  tui?: boolean;
108
115
  fork?: boolean;
116
+ rewind?: boolean;
109
117
  };
110
118
  run(input: AgentTurnInput, ctx: DriverContext): Promise<DriverResult>;
111
119
  tui?(input: TuiInput): TuiSpec;
@@ -25,6 +25,9 @@ export interface CoreSessionRecord {
25
25
  anchor: string | null;
26
26
  mode: 'native' | 'seed';
27
27
  } | null;
28
+ pendingRewind?: {
29
+ anchor: string | null;
30
+ } | null;
28
31
  }
29
32
  export interface SessionStore {
30
33
  ensure(agent: string, opts: {
@@ -48,6 +51,7 @@ export interface SessionStore {
48
51
  reconcileRunning?(isAlive: (pid: number) => boolean): Promise<number>;
49
52
  appendTurn?(agent: string, sessionId: string, turn: UniversalSnapshot): Promise<void>;
50
53
  history?(agent: string, sessionId: string): Promise<UniversalSnapshot[]>;
54
+ truncateTurns?(agent: string, sessionId: string, throughTaskId: string | null): Promise<void>;
51
55
  }
52
56
  export interface ModelInjection {
53
57
  model?: string | null;
@@ -23,6 +23,13 @@ export interface LoomIO {
23
23
  forkSession(input: ForkSessionInput): Promise<{
24
24
  sessionKey: string;
25
25
  }>;
26
+ rewindSession(input: {
27
+ sessionKey: string;
28
+ atTaskId: string;
29
+ anchor?: string | null;
30
+ }): Promise<{
31
+ sessionKey: string;
32
+ }>;
26
33
  stop(sessionKey: string): boolean;
27
34
  steer(taskId: string, prompt: string, attachments?: string[]): Promise<boolean>;
28
35
  interact(promptId: string, action: 'select' | 'text' | 'skip' | 'cancel', value?: string): boolean;
@@ -9,6 +9,7 @@ export declare class ClaudeDriver implements AgentDriver {
9
9
  resume: boolean;
10
10
  tui: boolean;
11
11
  fork: boolean;
12
+ rewind: boolean;
12
13
  };
13
14
  constructor(bin?: string);
14
15
  tui(input: TuiInput): TuiSpec;
@@ -25,6 +26,8 @@ export declare class ClaudeDriver implements AgentDriver {
25
26
  }
26
27
  export declare function claudeResumeArgs(sessionId: string, fork?: {
27
28
  anchor?: string | null;
29
+ } | null, rewind?: {
30
+ anchor?: string | null;
28
31
  } | null): string[];
29
32
  export declare function handleClaudeEvent(ev: any, s: any, emit: (e: DriverEvent) => void): void;
30
33
  export declare function claudeBgHoldCapMs(): number;
@@ -9,7 +9,7 @@ import { attachedFileNote, contextPercent, createLineBuffer, imageMimeForFile, p
9
9
  export class ClaudeDriver {
10
10
  bin;
11
11
  id = 'claude';
12
- capabilities = { steer: true, interact: false, resume: true, tui: true, fork: true };
12
+ capabilities = { steer: true, interact: false, resume: true, tui: true, fork: true, rewind: true };
13
13
  constructor(bin = 'claude') {
14
14
  this.bin = bin;
15
15
  }
@@ -43,7 +43,7 @@ export class ClaudeDriver {
43
43
  if (input.effort)
44
44
  args.push('--effort', input.effort === 'ultra' ? 'max' : input.effort); // request extended thinking (ultra is a display-only alias for max)
45
45
  if (input.sessionId)
46
- args.push(...claudeResumeArgs(input.sessionId, input.fork));
46
+ args.push(...claudeResumeArgs(input.sessionId, input.fork, input.rewind));
47
47
  if (input.systemPrompt)
48
48
  args.push('--append-system-prompt', input.systemPrompt);
49
49
  if (input.mcpConfigPath)
@@ -369,14 +369,19 @@ export class ClaudeDriver {
369
369
  }
370
370
  // The --resume flag family for one turn: plain resume appends to the session; a fork branches
371
371
  // a NEW session off it (--fork-session), optionally cut at an inclusive keep-boundary record
372
- // uuid (--resume-session-at). Pure so tests can pin the arg contract without spawning.
373
- export function claudeResumeArgs(sessionId, fork) {
372
+ // uuid (--resume-session-at); a rewind cuts at that boundary WITHOUT --fork-session, so claude
373
+ // rebranches the SAME session in place (the transcript is a parentUuid tree — the dropped tip
374
+ // becomes a dead sibling and leaves the active path). Pure so tests pin the arg contract.
375
+ export function claudeResumeArgs(sessionId, fork, rewind) {
374
376
  const args = ['--resume', sessionId];
375
377
  if (fork) {
376
378
  args.push('--fork-session');
377
379
  if (fork.anchor)
378
380
  args.push('--resume-session-at', fork.anchor);
379
381
  }
382
+ else if (rewind?.anchor) {
383
+ args.push('--resume-session-at', rewind.anchor);
384
+ }
380
385
  return args;
381
386
  }
382
387
  function claudeUsageOf(s) {
@@ -468,6 +473,15 @@ export function handleClaudeEvent(ev, s, emit) {
468
473
  }
469
474
  s.model = ev.model ?? s.model;
470
475
  s.contextWindow = claudeEffectiveContextWindow(claudeContextWindowFromModel(s.model)) ?? s.contextWindow;
476
+ // Claude compacted the running context (subtype `compact_boundary`): `trigger` is
477
+ // `auto` (the context filled up) or `manual` (a `/compact` command). Surface it live
478
+ // so a terminal can show a "compacting" affordance; the compacted summary itself lands
479
+ // in the native transcript and settles into a divider separately.
480
+ if (ev.subtype === 'compact_boundary') {
481
+ const meta = (ev.compact_metadata ?? {});
482
+ emit({ type: 'compaction', trigger: meta.trigger === 'manual' ? 'manual' : 'auto', atTokens: typeof meta.pre_tokens === 'number' ? meta.pre_tokens : null });
483
+ return;
484
+ }
471
485
  // Live thinking progress (system/thinking_tokens, ~every 1.4s of sustained thinking): during
472
486
  // extended thinking a subscription account streams no plaintext (signature_delta only) and no
473
487
  // usage until the message settles, so without projecting these the terminal shows a dead
@@ -320,6 +320,9 @@ export class CodexDriver {
320
320
  state.sessionId = threadId;
321
321
  ctx.emit({ type: 'session', sessionId: threadId });
322
322
  }
323
+ // Codex emits no explicit compaction event, so track peak occupancy: a sharp
324
+ // mid-turn drop is an auto-compaction, surfaced as a live `compaction` signal.
325
+ let compactPeakTokens = 0;
323
326
  srv.onNotification((method, params) => {
324
327
  if (params?.threadId && params.threadId !== state.sessionId && method !== 'turn/started')
325
328
  return;
@@ -444,6 +447,21 @@ export class CodexDriver {
444
447
  case 'thread/tokenUsage/updated': {
445
448
  applyCodexTokenUsage(state, params?.tokenUsage || params?.usage);
446
449
  ctx.emit({ type: 'usage', usage: codexUsageOf(state) });
450
+ // No explicit boundary event: a sharp drop from a high peak IS a compaction.
451
+ // Conservative thresholds (peak ≥ 50% of window, drop ≥ 25% of window) so
452
+ // ordinary turn churn never trips it; re-baseline so it fires once per drop.
453
+ {
454
+ const cw = state.contextWindow ?? 0;
455
+ const used = state.contextUsed ?? 0;
456
+ if (cw > 0 && used >= 0) {
457
+ if (used > compactPeakTokens)
458
+ compactPeakTokens = used;
459
+ else if (compactPeakTokens / cw >= 0.5 && (compactPeakTokens - used) / cw >= 0.25) {
460
+ ctx.emit({ type: 'compaction', trigger: 'auto', atTokens: compactPeakTokens });
461
+ compactPeakTokens = used;
462
+ }
463
+ }
464
+ }
447
465
  break;
448
466
  }
449
467
  case 'turn/completed': {
@@ -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[];
@@ -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;
@@ -143,6 +143,52 @@ export class Hub {
143
143
  this.deps.log?.(`[hub] forked ${input.fromSessionKey} -> ${agent}:${newId} (${mode}${anchor ? `, anchor ${anchor}` : ''}, ${kept.length}/${turns.length} turns)`);
144
144
  return { sessionKey: makeSessionKey(agent, newId) };
145
145
  }
146
+ // Rewind `sessionKey` IN PLACE to a turn boundary: drop the transcript after `atTaskId` (the
147
+ // last KEPT turn) and stamp a pendingRewind the next prompt() consumes (resume the SAME native
148
+ // session at the kept boundary, NO fork). The session id — managed AND native — is unchanged;
149
+ // only the dropped tip leaves the active path. Requires a rewind-capable driver, a store that
150
+ // can truncate, and a resolvable native anchor at the cut; otherwise throws so the caller can
151
+ // fall back (append on the same session, or a fork).
152
+ async rewindSession(input) {
153
+ const { agent, sessionId } = splitSessionKey(input.sessionKey);
154
+ const driver = this.deps.drivers.get(agent);
155
+ if (!driver)
156
+ throw new Error(`No driver registered for agent "${agent}"`);
157
+ if (!driver.capabilities?.rewind)
158
+ throw new Error(`agent "${agent}" has no in-place rewind`);
159
+ if (!this.deps.sessionStore.truncateTurns || !this.deps.sessionStore.history) {
160
+ throw new Error('session store cannot rewind (no truncateTurns/history)');
161
+ }
162
+ const rec = await this.deps.sessionStore.get(agent, sessionId);
163
+ if (!rec)
164
+ throw new Error(`rewind: session ${input.sessionKey} not found`);
165
+ const nativeId = rec.nativeSessionId || sessionId;
166
+ const turns = await this.deps.sessionStore.history(agent, sessionId).catch(() => []);
167
+ const cut = turns.findIndex((t) => t.taskId === input.atTaskId);
168
+ if (cut < 0)
169
+ throw new Error(`rewind point ${input.atTaskId} not found in ${input.sessionKey}`);
170
+ if (cut === turns.length - 1)
171
+ throw new Error(`rewind point ${input.atTaskId} is the tail — nothing to regenerate`);
172
+ const kept = turns.slice(0, cut + 1);
173
+ // Native rebranch boundary: explicit override → the kept tail's recorded anchor. No anchor =
174
+ // the kept turn never recorded one (e.g. a native-only import) — can't rebranch, so refuse.
175
+ const anchor = input.anchor ?? kept[kept.length - 1]?.anchor ?? null;
176
+ if (!anchor)
177
+ throw new Error(`rewind: no native anchor at ${input.atTaskId} in ${input.sessionKey}`);
178
+ // Drop the tip from the managed transcript, then stamp the rewind intent. The native store is
179
+ // untouched here — the next dispatch rebranches it at `anchor` via AgentTurnInput.rewind.
180
+ await this.deps.sessionStore.truncateTurns(agent, sessionId, input.atTaskId);
181
+ const last = kept[kept.length - 1];
182
+ rec.model = last?.model ?? rec.model ?? null;
183
+ rec.effort = last?.effort ?? rec.effort ?? null;
184
+ rec.nativeSessionId = nativeId;
185
+ rec.runState = 'completed';
186
+ rec.runDetail = null;
187
+ rec.pendingRewind = { anchor };
188
+ await this.deps.sessionStore.save(rec);
189
+ this.deps.log?.(`[hub] rewound ${input.sessionKey} to ${input.atTaskId} (anchor ${anchor}, kept ${kept.length}/${turns.length} turns)`);
190
+ return { sessionKey: input.sessionKey };
191
+ }
146
192
  enqueue(item) {
147
193
  const q = this.waiting.get(item.sessionKey) ?? [];
148
194
  q.push(item);
@@ -174,6 +220,9 @@ export class Hub {
174
220
  // first turn keeps the intent, so the retry forks again). Native mode resumes the PARENT's
175
221
  // native id with the fork flag; seed mode starts fresh and replays the copied transcript.
176
222
  const pendingFork = rec && !rec.nativeSessionId ? rec.pendingFork ?? null : null;
223
+ // A rewind keeps the SAME native id (it resumes+rebranches this session), so — unlike a fork —
224
+ // it is NOT gated on a missing native id. Fork and rewind never coexist on one record.
225
+ const pendingRewind = rec?.pendingRewind ?? null;
177
226
  const injection = await this.deps.modelResolver.resolve(agent, { model: input.model, profileId: null }).catch(() => null);
178
227
  const model = injection?.model ?? input.model ?? null;
179
228
  const effort = input.effort ?? null;
@@ -191,6 +240,7 @@ export class Hub {
191
240
  const systemPrompt = await this.composeSystemPrompt(agent, workdir, !preExisted || pendingFork?.mode === 'seed');
192
241
  let driverSessionId = rec?.nativeSessionId || (input.sessionKey ? sessionId : null);
193
242
  let driverFork = null;
243
+ let driverRewind = null;
194
244
  let driverPrompt = input.prompt;
195
245
  if (pendingFork) {
196
246
  if (pendingFork.mode === 'native' && pendingFork.parentNativeSessionId) {
@@ -204,11 +254,18 @@ export class Hub {
204
254
  driverPrompt = `${seed}\n\n${input.prompt}`;
205
255
  }
206
256
  }
257
+ else if (pendingRewind) {
258
+ // In-place rewind: resume THIS session's own native id but rebranch at the kept boundary
259
+ // (no fork), so the dropped tip leaves the active path and the prompt regenerates from it.
260
+ driverSessionId = rec?.nativeSessionId || sessionId;
261
+ driverRewind = { anchor: pendingRewind.anchor };
262
+ }
207
263
  const turnInput = {
208
264
  prompt: driverPrompt,
209
265
  attachments: input.attachments,
210
266
  sessionId: driverSessionId,
211
267
  fork: driverFork,
268
+ rewind: driverRewind,
212
269
  workdir,
213
270
  model, effort, systemPrompt,
214
271
  env: spawn.env,
@@ -239,6 +296,20 @@ export class Hub {
239
296
  this.deps.log?.(`[hub] pendingFork clear failed ${sessionKey}: ${e?.message || e}`);
240
297
  }
241
298
  }
299
+ // A rewind materialized (the turn ran and rebranched the native session) — consume the
300
+ // intent so a normal follow-up prompt appends to the regenerated tip instead of rewinding.
301
+ if (pendingRewind && result.sessionId) {
302
+ try {
303
+ const settled = await this.deps.sessionStore.get(agent, sessionId);
304
+ if (settled?.pendingRewind) {
305
+ settled.pendingRewind = null;
306
+ await this.deps.sessionStore.save(settled);
307
+ }
308
+ }
309
+ catch (e) {
310
+ this.deps.log?.(`[hub] pendingRewind clear failed ${sessionKey}: ${e?.message || e}`);
311
+ }
312
+ }
242
313
  // Persist the completed turn's final snapshot as a transcript entry. The runner's
243
314
  // snapshot is stable once done (a follow-up prompt spawns a fresh runner+snapshot).
244
315
  try {
@@ -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.