pi-spark 0.24.0 → 0.25.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.0](https://github.com/zlliang/pi-spark/compare/v0.24.0...v0.25.0) (2026-09-14)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * refine recap lifecycle and feature event handling ([4bb7474](https://github.com/zlliang/pi-spark/commit/4bb7474b3d04a6d92179b1873afdd6155e5e0368))
9
+
3
10
  ## [0.24.0](https://github.com/zlliang/pi-spark/compare/v0.23.1...v0.24.0) (2026-09-14)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Pi package that polishes your daily experience",
5
5
  "keywords": [
6
6
  "pi-coding-agent",
@@ -168,11 +168,6 @@ export function registerEditor(pi: ExtensionAPI, events: EventCollector): void {
168
168
  editor?.setWorkingMessage("Running tools");
169
169
  });
170
170
 
171
- pi.on("tool_call", (event) => {
172
- runningToolCallIds.add(event.toolCallId);
173
- editor?.setWorkingMessage("Running tools");
174
- });
175
-
176
171
  pi.on("tool_execution_end", (event) => {
177
172
  runningToolCallIds.delete(event.toolCallId);
178
173
  editor?.setWorkingMessage(runningToolCallIds.size > 0 ? "Running tools" : undefined);
@@ -1,5 +1,8 @@
1
1
  import parseDuration from "parse-duration";
2
2
  import * as z from "zod";
3
+ import { convertToLlm, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
4
+
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
6
 
4
7
  const MIN_IDLE_MS = 5_000;
5
8
 
@@ -17,105 +20,79 @@ export const idleTimeoutSchema = z
17
20
  })
18
21
  .pipe(z.number().min(MIN_IDLE_MS));
19
22
 
20
- type IdleTimeout = z.infer<typeof idleTimeoutSchema>;
21
- type IdleHash = string | number | boolean;
22
-
23
23
  const DEFAULT_IDLE_MS = 5 * 60 * 1000;
24
24
  const POLL_MS = 1_000;
25
25
 
26
- export class IdleListener<T> {
27
- private state: "active" | "watching" | "idle" = "active";
28
- private computeStateHash: (ctx: T) => IdleHash;
29
- private lastStateHash: IdleHash | undefined;
30
-
26
+ export class IdleListener {
27
+ private entered = false;
28
+ private lastEditorText: string | undefined;
29
+ private stableSince = 0;
31
30
  private idleMs: number;
32
- private pollMs: number;
33
- private idleTimer: ReturnType<typeof setTimeout> | undefined;
34
31
  private pollTimer: ReturnType<typeof setInterval> | undefined;
35
32
 
36
- private enterCallbacks: Set<(ctx: T) => void> = new Set();
37
- private wakeCallbacks: Set<(ctx: T) => void> = new Set();
33
+ private enterCallbacks: Set<(ctx: ExtensionContext) => void> = new Set();
34
+ private resetCallbacks: Set<(ctx: ExtensionContext) => void> = new Set();
38
35
 
39
- constructor(computeStateHash: (ctx: T) => IdleHash, idleMs: IdleTimeout = DEFAULT_IDLE_MS, pollMs: number = POLL_MS) {
40
- this.computeStateHash = computeStateHash;
36
+ constructor(idleMs: number = DEFAULT_IDLE_MS) {
41
37
  this.idleMs = idleMs;
42
- this.pollMs = pollMs;
43
38
  }
44
39
 
45
- on(event: "enter" | "wake", callback: (ctx: T) => void): () => void {
46
- const callbackSet = event === "enter" ? this.enterCallbacks : this.wakeCallbacks;
47
- callbackSet.add(callback);
40
+ on(event: "enter" | "reset", callback: (ctx: ExtensionContext) => void): () => void {
41
+ const callbacks = event === "enter" ? this.enterCallbacks : this.resetCallbacks;
42
+ callbacks.add(callback);
48
43
 
49
- return () => callbackSet.delete(callback);
44
+ return () => callbacks.delete(callback);
50
45
  }
51
46
 
52
- watch(ctx: T): void {
53
- if (this.state === "idle") return;
54
-
55
- this.stop();
56
- this.state = "watching";
57
-
58
- this.lastStateHash = this.computeStateHash(ctx);
59
- this.reset(ctx);
60
-
61
- this.pollTimer = setInterval(() => {
62
- const current = this.computeStateHash(ctx);
63
- if (current === this.lastStateHash) return;
47
+ /** Clears any recap and starts a fresh idle period, keeping observation active while Pi is busy. */
48
+ reset(ctx: ExtensionContext): void {
49
+ this.entered = false;
50
+ this.lastEditorText = undefined;
51
+ this.resetCallbacks.forEach((callback) => callback(ctx));
64
52
 
65
- this.lastStateHash = current;
66
- this.reset(ctx);
67
- }, this.pollMs);
68
- }
69
-
70
- enter(ctx: T): void {
71
- if (this.state === "idle") return;
72
-
73
- this.stop();
74
- this.state = "idle";
75
-
76
- this.enterCallbacks.forEach((callback) => callback(ctx));
77
- }
78
-
79
- /** Emits on every wake signal, even when the listener is already active. */
80
- wake(ctx: T): void {
81
- this.stop();
82
- this.state = "active";
83
-
84
- this.wakeCallbacks.forEach((callback) => callback(ctx));
53
+ this.check(ctx);
54
+ if (!this.pollTimer) {
55
+ this.pollTimer = setInterval(() => this.check(ctx), POLL_MS);
56
+ }
85
57
  }
86
58
 
87
59
  dispose(): void {
88
- this.stop();
89
- this.state = "active";
60
+ if (this.pollTimer) {
61
+ clearInterval(this.pollTimer);
62
+ this.pollTimer = undefined;
63
+ }
90
64
 
65
+ this.entered = false;
66
+ this.lastEditorText = undefined;
91
67
  this.enterCallbacks.clear();
92
- this.wakeCallbacks.clear();
93
- }
94
-
95
- private reset(ctx: T): void {
96
- this.clearIdleTimer();
97
- this.idleTimer = setTimeout(() => {
98
- this.idleTimer = undefined;
99
- this.enter(ctx);
100
- }, this.idleMs);
101
- }
102
-
103
- private stop(): void {
104
- this.clearIdleTimer();
105
- this.clearPollTimer();
68
+ this.resetCallbacks.clear();
106
69
  }
107
70
 
108
- private clearIdleTimer(): void {
109
- if (!this.idleTimer) return;
110
-
111
- clearTimeout(this.idleTimer);
112
- this.idleTimer = undefined;
113
- }
114
-
115
- private clearPollTimer(): void {
116
- if (!this.pollTimer) return;
117
-
118
- clearInterval(this.pollTimer);
119
- this.pollTimer = undefined;
71
+ private check(ctx: ExtensionContext): void {
72
+ const canEnter = ctx.isIdle() && ctx.sessionManager.buildContextEntries().some((entry) => convertToLlm(sessionEntryToContextMessages(entry)).length > 0);
73
+ if (!canEnter) {
74
+ this.lastEditorText = undefined;
75
+ if (this.entered) {
76
+ this.entered = false;
77
+ this.resetCallbacks.forEach((callback) => callback(ctx));
78
+ }
79
+ return;
80
+ }
81
+
82
+ // Keep the recap while the user edits; only a reset or Pi becoming busy clears it.
83
+ if (this.entered) return;
84
+
85
+ const editorText = ctx.ui.getEditorText();
86
+ const now = Date.now();
87
+ if (editorText !== this.lastEditorText) {
88
+ this.lastEditorText = editorText;
89
+ this.stableSince = now;
90
+ return;
91
+ }
92
+
93
+ if (now - this.stableSince >= this.idleMs) {
94
+ this.entered = true;
95
+ this.enterCallbacks.forEach((callback) => callback(ctx));
96
+ }
120
97
  }
121
98
  }
@@ -2,13 +2,13 @@ import { IdleListener } from "./idle";
2
2
  import { RecapManager } from "./manager";
3
3
  import { loadConfig } from "../../config";
4
4
 
5
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
 
7
7
  export function registerRecap(pi: ExtensionAPI): void {
8
- let idleListener: IdleListener<ExtensionContext> | undefined = undefined;
8
+ let idleListener: IdleListener | undefined = undefined;
9
9
  let recapManager: RecapManager | undefined = undefined;
10
10
 
11
- pi.on("session_start", (event, ctx) => {
11
+ pi.on("session_start", (_event, ctx) => {
12
12
  const config = loadConfig(ctx).recap;
13
13
  if (ctx.mode !== "tui" || !config) return;
14
14
 
@@ -16,52 +16,34 @@ export function registerRecap(pi: ExtensionAPI): void {
16
16
 
17
17
  pi.registerCommand("recap", {
18
18
  description: "Generate a short recap of the current session",
19
- handler: async () => await recapManager?.run(ctx, { force: true }),
19
+ handler: async (_args, ctx) => await recapManager?.run(ctx, { force: true }),
20
20
  });
21
21
 
22
- idleListener = new IdleListener((c) => `idle:${c.isIdle()};editor:${c.ui.getEditorText()}`, config.idle);
23
- idleListener.on("enter", (c) => recapManager?.run(c));
24
- idleListener.on("wake", (c) => recapManager?.clear(c));
22
+ idleListener = new IdleListener(config.idle);
23
+ idleListener.on("enter", (ctx) => recapManager?.run(ctx));
24
+ idleListener.on("reset", (ctx) => recapManager?.clear(ctx));
25
25
 
26
- if (event.reason === "resume" || event.reason === "fork") {
27
- idleListener.watch(ctx);
28
- }
26
+ idleListener.reset(ctx);
29
27
  });
30
28
 
31
29
  pi.on("input", (_event, ctx) => {
32
- idleListener?.wake(ctx);
30
+ idleListener?.reset(ctx);
33
31
  });
34
32
 
35
33
  pi.on("user_bash", (_event, ctx) => {
36
- idleListener?.wake(ctx);
34
+ idleListener?.reset(ctx);
37
35
  });
38
36
 
39
37
  pi.on("agent_start", (_event, ctx) => {
40
- idleListener?.wake(ctx);
38
+ idleListener?.reset(ctx);
41
39
  });
42
40
 
43
41
  pi.on("session_before_compact", (_event, ctx) => {
44
- idleListener?.wake(ctx);
42
+ idleListener?.reset(ctx);
45
43
  });
46
44
 
47
45
  pi.on("session_before_tree", (_event, ctx) => {
48
- idleListener?.wake(ctx);
49
- });
50
-
51
- pi.on("agent_settled", (_event, ctx) => {
52
- idleListener?.watch(ctx);
53
- });
54
-
55
- pi.on("session_compact", (_event, ctx) => {
56
- idleListener?.watch(ctx);
57
- });
58
-
59
- pi.on("session_compact_failed", (_event, ctx) => {
60
- idleListener?.watch(ctx);
61
- });
62
-
63
- pi.on("session_tree", (_event, ctx) => {
64
- idleListener?.watch(ctx);
46
+ idleListener?.reset(ctx);
65
47
  });
66
48
 
67
49
  pi.on("session_shutdown", (_event, ctx) => {
@@ -1,4 +1,4 @@
1
- import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
1
+ import { convertToLlm, serializeConversation, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import { clearRecapWidget, setRecapLoadingWidget, setRecapTextWidget } from "./widget";
4
4
  import { sanitizeText } from "../../utils/format";
@@ -32,7 +32,7 @@ export class RecapManager {
32
32
  }
33
33
 
34
34
  async run(ctx: ExtensionContext, options: { force?: boolean } = {}): Promise<void> {
35
- if (this.active && !options.force) return;
35
+ if (!options.force && (this.active || this.inflight || !ctx.isIdle())) return;
36
36
 
37
37
  this.cancelInflight();
38
38
  const controller = new AbortController();
@@ -41,6 +41,7 @@ export class RecapManager {
41
41
  try {
42
42
  const modelSettings = await resolveModelSettings(ctx, this.config, "recap");
43
43
  if (controller.signal.aborted || this.inflight !== controller || !modelSettings) return;
44
+ if (!options.force && !ctx.isIdle()) return;
44
45
 
45
46
  const { model, thinkingLevel, warning } = modelSettings;
46
47
 
@@ -113,7 +114,7 @@ export class RecapManager {
113
114
  }
114
115
 
115
116
  private buildPrompt(ctx: ExtensionContext): string {
116
- const messages = ctx.sessionManager.getBranch().filter((entry) => entry.type === "message").map((entry) => entry.message);
117
+ const messages = ctx.sessionManager.buildContextEntries().flatMap(sessionEntryToContextMessages);
117
118
  const text = serializeConversation(convertToLlm(messages));
118
119
  const conversation = text.length > MAX_CONVERSATION_CHARS ? text.slice(-MAX_CONVERSATION_CHARS) : text;
119
120
 
@@ -16,12 +16,12 @@ export function registerTitle(pi: ExtensionAPI): void {
16
16
  // Generate the title after the first completed turn, without waiting for the full agent run.
17
17
  // The manager skips sessions that already have a name and prevents concurrent attempts.
18
18
  pi.on("turn_end", (_event, ctx) => {
19
- void titleManager?.run(ctx);
19
+ titleManager?.run(ctx);
20
20
  });
21
21
 
22
22
  // Fall back to settlement if the run ends without a completed turn or an early attempt fails.
23
23
  pi.on("agent_settled", (_event, ctx) => {
24
- void titleManager?.run(ctx);
24
+ titleManager?.run(ctx);
25
25
  });
26
26
 
27
27
  pi.on("session_shutdown", () => {