@pi-unipi/unipi 2.4.2 → 2.5.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/package.json +23 -22
  3. package/packages/ask-user/package.json +2 -2
  4. package/packages/autocomplete/package.json +1 -1
  5. package/packages/btw/package.json +2 -2
  6. package/packages/cocoindex/package.json +2 -2
  7. package/packages/compactor/package.json +3 -3
  8. package/packages/core/package.json +1 -1
  9. package/packages/core/sandbox.ts +7 -6
  10. package/packages/footer/package.json +2 -2
  11. package/packages/image/package.json +2 -2
  12. package/packages/info-screen/package.json +2 -2
  13. package/packages/input-shortcuts/package.json +2 -2
  14. package/packages/kanboard/package.json +2 -2
  15. package/packages/mcp/README.md +9 -0
  16. package/packages/mcp/package.json +5 -2
  17. package/packages/mcp/src/bridge/registry.ts +244 -137
  18. package/packages/mcp/src/bridge/translator.ts +77 -9
  19. package/packages/mcp/src/index.ts +43 -67
  20. package/packages/mcp/src/tui/settings-overlay.ts +3 -9
  21. package/packages/memory/README.md +15 -11
  22. package/packages/memory/bridge/mempalace_bridge.py +636 -0
  23. package/packages/memory/mempalace.ts +169 -18
  24. package/packages/memory/package.json +3 -3
  25. package/packages/memory/storage.ts +27 -10
  26. package/packages/milestone/README.md +11 -3
  27. package/packages/milestone/hooks.ts +103 -29
  28. package/packages/milestone/index.ts +1 -1
  29. package/packages/milestone/package.json +5 -2
  30. package/packages/notify/package.json +2 -2
  31. package/packages/ralph/package.json +3 -3
  32. package/packages/subagents/package.json +1 -1
  33. package/packages/unipi/bundled.js +693 -408
  34. package/packages/updater/package.json +2 -2
  35. package/packages/utility/package.json +2 -2
  36. package/packages/web-api/package.json +2 -2
  37. package/packages/workflow/README.md +8 -0
  38. package/packages/workflow/commands.ts +16 -26
  39. package/packages/workflow/index.ts +165 -85
  40. package/packages/workflow/package.json +5 -2
@@ -12,6 +12,8 @@
12
12
  */
13
13
 
14
14
  import { spawnSync, spawn } from "node:child_process";
15
+ import { createHash } from "node:crypto";
16
+ import { createRequire } from "node:module";
15
17
  import * as fs from "node:fs";
16
18
  import * as path from "node:path";
17
19
  import * as os from "node:os";
@@ -27,11 +29,74 @@ const MIGRATED_FLAG = path.join(os.homedir(), ".unipi", "memory", ".mempalace-mi
27
29
  const PING_VERIFIED_FLAG = path.join(os.homedir(), ".unipi", "memory", ".mempalace-ping-verified");
28
30
  const PING_VERIFIED_TTL_MS = 24 * 60 * 60 * 1000; // 24h
29
31
 
30
- /** Path to the bundled bridge script. */
31
- const BRIDGE_PATH = path.join(dirname(fileURLToPath(import.meta.url)), "bridge", "mempalace_bridge.py");
32
+ /** Migration marker schema. Increment when migration semantics change. */
33
+ export const MIGRATION_STATE_VERSION = 2;
34
+
35
+ export interface MigrationResult {
36
+ discovered: number;
37
+ imported: number;
38
+ updated: number;
39
+ skipped: number;
40
+ failed: number;
41
+ verified: number;
42
+ errors?: string[];
43
+ }
44
+
45
+ export interface MigrationState {
46
+ version: number;
47
+ completedAt: string;
48
+ sourceFingerprint: string;
49
+ result: MigrationResult;
50
+ }
51
+
52
+ let cachedBridgePath: string | null | undefined;
53
+
54
+ function isReadableFile(candidate: string): boolean {
55
+ try {
56
+ return fs.statSync(candidate).isFile() && fs.accessSync(candidate, fs.constants.R_OK) === undefined;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Resolve the Python bridge in both supported layouts:
64
+ *
65
+ * - standalone @pi-unipi/memory: <package>/bridge/mempalace_bridge.py
66
+ * - bundled @pi-unipi/unipi: <umbrella>/packages/memory/bridge/...
67
+ *
68
+ * The explicit environment override is useful for custom packagers. The
69
+ * package-resolution fallback handles npm layouts where dependencies are not
70
+ * hoisted beside the umbrella package.
71
+ */
72
+ export function resolveMempalaceBridgePath(moduleUrl = import.meta.url): string | null {
73
+ const moduleDir = path.dirname(fileURLToPath(moduleUrl));
74
+ const candidates: string[] = [];
75
+ if (process.env.UNIPI_MEMPALACE_BRIDGE) {
76
+ candidates.push(path.resolve(process.env.UNIPI_MEMPALACE_BRIDGE));
77
+ }
78
+ candidates.push(
79
+ path.join(moduleDir, "bridge", "mempalace_bridge.py"),
80
+ path.join(moduleDir, "..", "memory", "bridge", "mempalace_bridge.py"),
81
+ );
82
+
83
+ try {
84
+ const require = createRequire(moduleUrl);
85
+ const memoryPackage = require.resolve("@pi-unipi/memory/package.json");
86
+ candidates.push(path.join(path.dirname(memoryPackage), "bridge", "mempalace_bridge.py"));
87
+ } catch { /* standalone/source layout may not expose package resolution */ }
88
+
89
+ return candidates.find(isReadableFile) ?? null;
90
+ }
91
+
92
+ function getBridgePath(): string | null {
93
+ if (cachedBridgePath === undefined) cachedBridgePath = resolveMempalaceBridgePath();
94
+ return cachedBridgePath;
95
+ }
32
96
 
33
- function dirname(p: string): string {
34
- return path.dirname(p);
97
+ /** Clear bridge discovery cache (primarily for recovery/tests). */
98
+ export function invalidateBridgePathCache(): void {
99
+ cachedBridgePath = undefined;
35
100
  }
36
101
 
37
102
  export interface BridgeResponse<T> {
@@ -196,22 +261,99 @@ export function invalidatePingVerified(): void {
196
261
  try { if (fs.existsSync(PING_VERIFIED_FLAG)) fs.unlinkSync(PING_VERIFIED_FLAG); } catch { /* ignore */ }
197
262
  }
198
263
 
199
- /** Has the one-way legacy migration been completed? */
200
- export function isMigrated(): boolean {
201
- return fs.existsSync(MIGRATED_FLAG);
264
+ /**
265
+ * Fingerprint all durable legacy sources. This makes migration catch-up
266
+ * automatic for existing installations instead of treating a years-old
267
+ * timestamp flag as permanently complete.
268
+ */
269
+ export function getMemorySourceFingerprint(
270
+ sourceDir = path.join(os.homedir(), ".unipi", "memory"),
271
+ ): string {
272
+ const hash = createHash("sha256");
273
+ if (!fs.existsSync(sourceDir)) return hash.update("missing").digest("hex");
274
+
275
+ const visit = (dir: string): void => {
276
+ let entries: fs.Dirent[];
277
+ try {
278
+ entries = fs.readdirSync(dir, { withFileTypes: true })
279
+ .sort((a, b) => a.name.localeCompare(b.name));
280
+ } catch {
281
+ hash.update(`unreadable:${dir}`);
282
+ return;
283
+ }
284
+ for (const entry of entries) {
285
+ if (entry.name.startsWith(".")) continue;
286
+ const full = path.join(dir, entry.name);
287
+ if (entry.isDirectory()) {
288
+ visit(full);
289
+ continue;
290
+ }
291
+ if (!entry.isFile() || (entry.name !== "memory.db" && !entry.name.endsWith(".md"))) continue;
292
+ try {
293
+ const stat = fs.statSync(full);
294
+ hash.update(`${path.relative(sourceDir, full)}\0${stat.size}\0${stat.mtimeMs}\n`);
295
+ } catch {
296
+ hash.update(`unreadable:${path.relative(sourceDir, full)}\n`);
297
+ }
298
+ }
299
+ };
300
+ visit(sourceDir);
301
+ return hash.digest("hex");
202
302
  }
203
303
 
204
- /** Mark the one-way legacy migration complete. */
205
- export function markMigrated(): void {
304
+ /** Read a verified v2 migration state. Legacy timestamp markers return null. */
305
+ export function readMigrationState(flagPath = MIGRATED_FLAG): MigrationState | null {
206
306
  try {
207
- fs.mkdirSync(path.dirname(MIGRATED_FLAG), { recursive: true });
208
- fs.writeFileSync(MIGRATED_FLAG, new Date().toISOString(), "utf-8");
209
- } catch { /* ignore */ }
307
+ const parsed = JSON.parse(fs.readFileSync(flagPath, "utf-8")) as MigrationState;
308
+ if (
309
+ parsed?.version !== MIGRATION_STATE_VERSION ||
310
+ typeof parsed.completedAt !== "string" ||
311
+ typeof parsed.sourceFingerprint !== "string" ||
312
+ !parsed.result ||
313
+ parsed.result.failed !== 0 ||
314
+ parsed.result.verified !== parsed.result.discovered
315
+ ) return null;
316
+ return parsed;
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+
322
+ /** Is the palace verified against the current durable source set? */
323
+ export function isMigrated(
324
+ sourceFingerprint = getMemorySourceFingerprint(),
325
+ flagPath = MIGRATED_FLAG,
326
+ ): boolean {
327
+ return readMigrationState(flagPath)?.sourceFingerprint === sourceFingerprint;
328
+ }
329
+
330
+ /** Mark migration complete only after the caller has verified every record. */
331
+ export function markMigrated(
332
+ sourceFingerprint: string,
333
+ result: MigrationResult,
334
+ flagPath = MIGRATED_FLAG,
335
+ ): boolean {
336
+ if (result.failed !== 0 || result.verified !== result.discovered) return false;
337
+ try {
338
+ fs.mkdirSync(path.dirname(flagPath), { recursive: true });
339
+ const state: MigrationState = {
340
+ version: MIGRATION_STATE_VERSION,
341
+ completedAt: new Date().toISOString(),
342
+ sourceFingerprint,
343
+ result,
344
+ };
345
+ const temp = `${flagPath}.${process.pid}.tmp`;
346
+ fs.writeFileSync(temp, JSON.stringify(state, null, 2), "utf-8");
347
+ fs.renameSync(temp, flagPath);
348
+ return true;
349
+ } catch {
350
+ return false;
351
+ }
210
352
  }
211
353
 
212
354
  /** Force re-migration by clearing the flag. */
213
- export function clearMigratedFlag(): void {
214
- try { if (fs.existsSync(MIGRATED_FLAG)) fs.unlinkSync(MIGRATED_FLAG); } catch { /* ignore */ }
355
+ export function clearMigratedFlag(flagPath = MIGRATED_FLAG): void {
356
+ try { if (fs.existsSync(flagPath)) fs.unlinkSync(flagPath); } catch { /* ignore */ }
215
357
  }
216
358
 
217
359
  /**
@@ -223,7 +365,10 @@ export function runBridge<T = unknown>(
223
365
  palace: string,
224
366
  cmd: string,
225
367
  args: Record<string, unknown> = {},
368
+ timeoutMs = 60_000,
226
369
  ): T | null {
370
+ const bridgePath = getBridgePath();
371
+ if (!bridgePath) return null;
227
372
  let argsJson: string;
228
373
  try {
229
374
  argsJson = JSON.stringify(args);
@@ -232,9 +377,9 @@ export function runBridge<T = unknown>(
232
377
  }
233
378
  let res;
234
379
  try {
235
- res = spawnSync(install.python, [BRIDGE_PATH, palace, cmd, argsJson], {
380
+ res = spawnSync(install.python, [bridgePath, palace, cmd, argsJson], {
236
381
  encoding: "utf-8",
237
- timeout: 60_000,
382
+ timeout: timeoutMs,
238
383
  maxBuffer: 64 * 1024 * 1024,
239
384
  });
240
385
  } catch {
@@ -266,8 +411,14 @@ export function runBridgeAsync<T = unknown>(
266
411
  palace: string,
267
412
  cmd: string,
268
413
  args: Record<string, unknown> = {},
414
+ timeoutMs = 60_000,
269
415
  ): Promise<T | null> {
270
416
  return new Promise((resolve) => {
417
+ const bridgePath = getBridgePath();
418
+ if (!bridgePath) {
419
+ resolve(null);
420
+ return;
421
+ }
271
422
  let argsJson: string;
272
423
  try {
273
424
  argsJson = JSON.stringify(args);
@@ -278,7 +429,7 @@ export function runBridgeAsync<T = unknown>(
278
429
 
279
430
  let child;
280
431
  try {
281
- child = spawn(install.python, [BRIDGE_PATH, palace, cmd, argsJson], {
432
+ child = spawn(install.python, [bridgePath, palace, cmd, argsJson], {
282
433
  stdio: ["ignore", "pipe", "ignore"],
283
434
  });
284
435
  } catch {
@@ -298,7 +449,7 @@ export function runBridgeAsync<T = unknown>(
298
449
  const timer = setTimeout(() => {
299
450
  try { child.kill(); } catch { /* already gone */ }
300
451
  finish(null);
301
- }, 60_000);
452
+ }, timeoutMs);
302
453
  // Do not hold the process open purely for a background bridge call.
303
454
  timer.unref?.();
304
455
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/memory",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "Persistent cross-session memory with MemPalace backend (auto-installed) and SQLite fallback for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -43,8 +43,8 @@
43
43
  "better-sqlite3": "^12.9.0",
44
44
  "sqlite-vec": "^0.1.9",
45
45
  "js-yaml": "^4.1.0",
46
- "@pi-unipi/core": "2.4.1",
47
- "@pi-unipi/info-screen": "2.4.1"
46
+ "@pi-unipi/core": "2.5.0",
47
+ "@pi-unipi/info-screen": "2.5.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -20,6 +20,7 @@ import {
20
20
  runBridgeAsync,
21
21
  isMigrated,
22
22
  markMigrated,
23
+ getMemorySourceFingerprint,
23
24
  isPingVerified,
24
25
  markPingVerified,
25
26
  invalidatePingVerified,
@@ -29,6 +30,7 @@ import {
29
30
  type MempalaceSearchResult,
30
31
  type MempalaceListItem,
31
32
  type MempalaceListItemAll,
33
+ type MigrationResult,
32
34
  } from "./mempalace.js";
33
35
 
34
36
  export type MemoryBackend = "mempalace" | "sqlite";
@@ -102,6 +104,7 @@ export interface SearchResult {
102
104
 
103
105
  /** Memory file frontmatter */
104
106
  interface MemoryFrontmatter {
107
+ id?: string;
105
108
  title: string;
106
109
  tags: string[];
107
110
  project: string;
@@ -201,7 +204,7 @@ export function parseMemoryContent(content: string): MemoryRecord | null {
201
204
  const frontmatter = yaml.load(frontmatterStr) as MemoryFrontmatter;
202
205
 
203
206
  return {
204
- id: "",
207
+ id: frontmatter.id || "",
205
208
  title: frontmatter.title,
206
209
  content: body.trim(),
207
210
  tags: frontmatter.tags || [],
@@ -217,6 +220,7 @@ export function parseMemoryContent(content: string): MemoryRecord | null {
217
220
  */
218
221
  export function writeMemoryFile(filePath: string, record: MemoryRecord): void {
219
222
  const frontmatter: MemoryFrontmatter = {
223
+ id: record.id,
220
224
  title: record.title,
221
225
  tags: record.tags,
222
226
  project: record.project,
@@ -383,16 +387,28 @@ export class MemoryStorage {
383
387
  this.mempalaceInstall = install;
384
388
  this.backend = "mempalace";
385
389
 
386
- // One-way auto-migration of legacy memories (idempotent).
387
- if (!isMigrated()) {
390
+ // Idempotent migration + automatic catch-up. The source fingerprint turns
391
+ // the old one-shot timestamp into a resumable state: new/changed markdown
392
+ // or SQLite sources trigger another verified upsert pass. Never mark a
393
+ // failed/partial run complete; it will retry on a later session.
394
+ const sourceFingerprint = getMemorySourceFingerprint(getMemoryBaseDir());
395
+ if (!isMigrated(sourceFingerprint)) {
388
396
  try {
389
- runBridge(install, this.palacePath, "migrate", {
397
+ // First migrations can embed thousands of records. Give the bridge a
398
+ // practical bounded window rather than the normal per-operation 60s.
399
+ const result = runBridge<MigrationResult>(install, this.palacePath, "migrate", {
390
400
  source_dir: getMemoryBaseDir(),
391
- });
392
- markMigrated();
401
+ }, 15 * 60_000);
402
+ if (
403
+ result &&
404
+ result.failed === 0 &&
405
+ result.verified === result.discovered
406
+ ) {
407
+ markMigrated(sourceFingerprint, result);
408
+ }
393
409
  } catch {
394
- // Migration failed — palace still usable for new stores; legacy
395
- // memories can be re-migrated later. Do not block.
410
+ // Palace remains available for current writes; durable markdown/SQLite
411
+ // sources are untouched and migration retries because no state is set.
396
412
  }
397
413
  }
398
414
 
@@ -629,8 +645,9 @@ export class MemoryStorage {
629
645
  const record = parseMemoryFile(filePath);
630
646
  if (!record) continue;
631
647
 
632
- // Generate ID from title (same logic as store())
633
- const id = record.title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
648
+ // New files preserve the authoritative store ID. Legacy files have no
649
+ // `id` frontmatter, so retain the historical title-derived fallback.
650
+ const id = record.id || record.title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
634
651
 
635
652
  if (existingIds.has(id)) continue; // Already in DB
636
653
 
@@ -15,9 +15,17 @@ Workflow operates at the task level — brainstorm, plan, work, review. Project
15
15
 
16
16
  ### Session Start
17
17
 
18
- On `before_agent_start`, milestone reads `.unipi/docs/MILESTONES.md` and appends a progress summary to the system prompt:
18
+ On `before_agent_start`, milestone reads `.unipi/docs/MILESTONES.md` from `ctx.cwd` and appends a hidden `unipi-milestone-snapshot` custom message to the session:
19
19
 
20
20
  ```
21
+ # UniPi Milestone Snapshot
22
+
23
+ This snapshot supersedes all prior UniPi milestone snapshots; use only this snapshot for milestone status.
24
+
25
+ Workspace: /path/to/project
26
+
27
+ Status: active
28
+
21
29
  ## Project Milestones
22
30
  Overall progress: 5/10 items (50%)
23
31
  Phase 1: Foundation: 3/5 done
@@ -25,11 +33,11 @@ Overall progress: 5/10 items (50%)
25
33
  Current focus: Phase 1: Foundation
26
34
  ```
27
35
 
28
- If MILESTONES.md doesn't exist, no context is injected.
36
+ Snapshots are append-only and hidden from the transcript. They keep the system-prompt prefix stable, persist milestone context in session history, and are deduplicated against the latest milestone custom message in the effective (compaction-aware) LLM context. If milestones disappear while an older active snapshot remains effective, an inactive snapshot is appended to supersede it. A clean workspace with no milestones and no effective snapshot receives no injected message.
29
37
 
30
38
  ### Session End
31
39
 
32
- On `session_shutdown`, milestone scans workflow docs modified during the session. Detects items that changed from `- [ ]` to `- [x]` and auto-updates MILESTONES.md using exact text matching.
40
+ On `session_shutdown`, milestone scans workflow docs modified during the session. It uses the workspace captured from `session_start`'s `ctx.cwd`, detects items that changed from `- [ ]` to `- [x]`, and auto-updates MILESTONES.md using exact text matching.
33
41
 
34
42
  ### Coexist Triggers
35
43
 
@@ -1,22 +1,33 @@
1
1
  /**
2
2
  * @pi-unipi/milestone — Lifecycle hooks
3
3
  *
4
- * Session start: inject milestone progress as system context.
4
+ * Agent start: append milestone progress as a hidden, persistent context snapshot.
5
5
  * Session end: auto-sync completed items from workflow docs.
6
6
  */
7
7
 
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ buildSessionContext,
12
+ type ExtensionAPI,
13
+ type SessionEntry,
14
+ } from "@earendil-works/pi-coding-agent";
11
15
  import { MILESTONE_DIRS, UNIPI_EVENTS, safeMtimeMs, tryRead } from "@pi-unipi/core";
12
- import { parseMilestones, getProgressSummary, updateItemStatus } from "./milestone.js";
16
+ import { getProgressSummary, updateItemStatus } from "./milestone.js";
13
17
 
14
- /** Track when the session started for diffing modified files */
15
- let sessionStartMs = 0;
18
+ export const MILESTONE_SNAPSHOT_TYPE = "unipi-milestone-snapshot";
16
19
 
17
- /**
18
- * Format a progress summary as a context string for the system prompt.
19
- */
20
+ interface MilestoneSnapshotDetails {
21
+ active: boolean;
22
+ workspace: string;
23
+ }
24
+
25
+ interface EffectiveSnapshot {
26
+ content: unknown;
27
+ details?: MilestoneSnapshotDetails;
28
+ }
29
+
30
+ /** Format the active milestone progress included in a snapshot. */
20
31
  function formatMilestoneContext(filePath: string): string | null {
21
32
  const summary = getProgressSummary(filePath);
22
33
  if (summary.totalItems === 0) return null;
@@ -39,23 +50,84 @@ function formatMilestoneContext(filePath: string): string | null {
39
50
  .join("\n");
40
51
  }
41
52
 
53
+ /** Build an append-only snapshot that explicitly invalidates earlier snapshots. */
54
+ function formatMilestoneSnapshot(workspace: string, context: string | null): string {
55
+ return [
56
+ "# UniPi Milestone Snapshot",
57
+ "This snapshot supersedes all prior UniPi milestone snapshots; use only this snapshot for milestone status.",
58
+ `Workspace: ${workspace}`,
59
+ `Status: ${context ? "active" : "inactive"}`,
60
+ context ?? "No milestones are active for this workspace.",
61
+ ].join("\n\n");
62
+ }
63
+
64
+ function latestEffectiveSnapshot(branch: SessionEntry[]): EffectiveSnapshot | undefined {
65
+ const messages = buildSessionContext(branch).messages;
66
+ for (let index = messages.length - 1; index >= 0; index--) {
67
+ const message = messages[index];
68
+ if (message.role === "custom" && message.customType === MILESTONE_SNAPSHOT_TYPE) {
69
+ return {
70
+ content: message.content,
71
+ details: message.details as MilestoneSnapshotDetails | undefined,
72
+ };
73
+ }
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ /** Find state that may have been folded into a compaction summary. */
79
+ function latestHistoricalSnapshot(branch: SessionEntry[]): EffectiveSnapshot | undefined {
80
+ for (let index = branch.length - 1; index >= 0; index--) {
81
+ const entry = branch[index];
82
+ if (entry.type === "custom_message" && entry.customType === MILESTONE_SNAPSHOT_TYPE) {
83
+ return {
84
+ content: entry.content,
85
+ details: entry.details as MilestoneSnapshotDetails | undefined,
86
+ };
87
+ }
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ function isActiveSnapshot(snapshot: EffectiveSnapshot): boolean {
93
+ if (typeof snapshot.details?.active === "boolean") return snapshot.details.active;
94
+ return typeof snapshot.content === "string" && snapshot.content.includes("Status: active");
95
+ }
96
+
42
97
  /**
43
- * Register session start hook — injects milestone progress into system context.
98
+ * Register the agent-start hook. Snapshots are hidden from the transcript but
99
+ * persist in the append-only session and therefore keep the system prefix stable.
44
100
  */
45
101
  export function registerSessionStartHook(pi: ExtensionAPI): void {
46
- pi.on("before_agent_start", (event) => {
47
- sessionStartMs = Date.now();
102
+ pi.on("before_agent_start", (_event, ctx) => {
103
+ const workspace = ctx.cwd;
104
+ const milestonesPath = path.join(workspace, MILESTONE_DIRS.MILESTONES);
105
+ const context = formatMilestoneContext(milestonesPath);
106
+ const branch = ctx.sessionManager.getBranch();
107
+ const latest = latestEffectiveSnapshot(branch);
108
+ const historical = latestHistoricalSnapshot(branch);
48
109
 
49
- const cwd = process.cwd();
50
- const milestonesPath = path.join(cwd, MILESTONE_DIRS.MILESTONES);
110
+ // A genuinely clean workspace/session needs no synthetic context. Raw
111
+ // history is checked because compaction may have folded an old active
112
+ // snapshot into summary prose while removing its custom-message identity.
113
+ if (!context && !latest && !historical) return undefined;
51
114
 
52
- const context = formatMilestoneContext(milestonesPath);
53
- if (!context) return undefined;
115
+ const prior = latest ?? historical;
116
+ if (!context && prior && !isActiveSnapshot(prior)) return undefined;
117
+
118
+ const content = formatMilestoneSnapshot(workspace, context);
119
+ if (latest?.content === content) return undefined;
54
120
 
55
- // Append milestone context to the system prompt
56
- const currentPrompt = (event as any).systemPrompt ?? "";
57
121
  return {
58
- systemPrompt: currentPrompt + "\n\n" + context,
122
+ message: {
123
+ customType: MILESTONE_SNAPSHOT_TYPE,
124
+ content,
125
+ display: false,
126
+ details: {
127
+ active: context !== null,
128
+ workspace,
129
+ } satisfies MilestoneSnapshotDetails,
130
+ },
59
131
  };
60
132
  });
61
133
  }
@@ -140,19 +212,21 @@ function scanModifiedDocs(dirs: string[], since: number): string[] {
140
212
  * scans modified docs, and auto-updates MILESTONES.md.
141
213
  */
142
214
  export function registerSessionEndHook(pi: ExtensionAPI): void {
143
- // Store baseline snapshots at session start
215
+ // Capture the session workspace because process.cwd() can change before shutdown.
144
216
  const baselineSnapshots = new Map<string, string>();
217
+ let sessionStartMs = 0;
218
+ let sessionWorkspace: string | null = null;
145
219
 
146
220
  // Capture baselines on session start
147
- pi.on("session_start", () => {
221
+ pi.on("session_start", (_event, ctx) => {
148
222
  sessionStartMs = Date.now();
223
+ sessionWorkspace = ctx.cwd;
149
224
  baselineSnapshots.clear();
150
225
 
151
- const cwd = process.cwd();
152
226
  const scanDirs = [
153
- path.join(cwd, ".unipi/docs/specs"),
154
- path.join(cwd, ".unipi/docs/plans"),
155
- path.join(cwd, ".unipi/docs/quick-work"),
227
+ path.join(sessionWorkspace, ".unipi/docs/specs"),
228
+ path.join(sessionWorkspace, ".unipi/docs/plans"),
229
+ path.join(sessionWorkspace, ".unipi/docs/quick-work"),
156
230
  ];
157
231
 
158
232
  for (const dir of scanDirs) {
@@ -168,15 +242,15 @@ export function registerSessionEndHook(pi: ExtensionAPI): void {
168
242
  });
169
243
 
170
244
  const syncModifiedDocs = () => {
171
- const cwd = process.cwd();
172
- const milestonesPath = path.join(cwd, MILESTONE_DIRS.MILESTONES);
245
+ if (!sessionWorkspace) return;
173
246
 
247
+ const milestonesPath = path.join(sessionWorkspace, MILESTONE_DIRS.MILESTONES);
174
248
  if (!fs.existsSync(milestonesPath)) return;
175
249
 
176
250
  const scanDirs = [
177
- path.join(cwd, ".unipi/docs/specs"),
178
- path.join(cwd, ".unipi/docs/plans"),
179
- path.join(cwd, ".unipi/docs/quick-work"),
251
+ path.join(sessionWorkspace, ".unipi/docs/specs"),
252
+ path.join(sessionWorkspace, ".unipi/docs/plans"),
253
+ path.join(sessionWorkspace, ".unipi/docs/quick-work"),
180
254
  ];
181
255
 
182
256
  const modifiedFiles = scanModifiedDocs(scanDirs, sessionStartMs);
@@ -2,7 +2,7 @@
2
2
  * @pi-unipi/milestone — Extension entry point
3
3
  *
4
4
  * Lifecycle layer for project-level goals. Tracks progress via MILESTONES.md,
5
- * injects context on session start, auto-syncs on session end.
5
+ * appends hidden context snapshots before agent turns, and auto-syncs on session end.
6
6
  */
7
7
 
8
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@pi-unipi/milestone",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "Lifecycle layer for project-level goals — MILESTONES.md tracking, session hooks, auto-sync",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
+ "scripts": {
8
+ "test": "npx tsx --test tests/hooks.test.ts"
9
+ },
7
10
  "license": "MIT",
8
11
  "author": "Neuron Mr White",
9
12
  "repository": {
@@ -29,7 +32,7 @@
29
32
  "access": "public"
30
33
  },
31
34
  "dependencies": {
32
- "@pi-unipi/core": "2.4.1"
35
+ "@pi-unipi/core": "2.5.0"
33
36
  },
34
37
  "peerDependencies": {
35
38
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "Cross-platform notification extension for Pi — native OS, Gotify, and Telegram notifications for agent lifecycle events",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "2.4.1",
37
+ "@pi-unipi/core": "2.5.0",
38
38
  "node-notifier": "^10.0.1"
39
39
  },
40
40
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ralph",
3
- "version": "2.4.2",
3
+ "version": "2.5.0",
4
4
  "description": "Long-running iterative development loops for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -27,8 +27,8 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@pi-unipi/core": "2.4.1",
31
- "@pi-unipi/info-screen": "2.4.1"
30
+ "@pi-unipi/core": "2.5.0",
31
+ "@pi-unipi/info-screen": "2.5.0"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@earendil-works/pi-ai": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/subagents",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "Subagents for UniPi — parallel execution, file locking, workflow integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",