@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.
- package/CHANGELOG.md +25 -0
- package/package.json +23 -22
- package/packages/ask-user/package.json +2 -2
- package/packages/autocomplete/package.json +1 -1
- package/packages/btw/package.json +2 -2
- package/packages/cocoindex/package.json +2 -2
- package/packages/compactor/package.json +3 -3
- package/packages/core/package.json +1 -1
- package/packages/core/sandbox.ts +7 -6
- package/packages/footer/package.json +2 -2
- package/packages/image/package.json +2 -2
- package/packages/info-screen/package.json +2 -2
- package/packages/input-shortcuts/package.json +2 -2
- package/packages/kanboard/package.json +2 -2
- package/packages/mcp/README.md +9 -0
- package/packages/mcp/package.json +5 -2
- package/packages/mcp/src/bridge/registry.ts +244 -137
- package/packages/mcp/src/bridge/translator.ts +77 -9
- package/packages/mcp/src/index.ts +43 -67
- package/packages/mcp/src/tui/settings-overlay.ts +3 -9
- package/packages/memory/README.md +15 -11
- package/packages/memory/bridge/mempalace_bridge.py +636 -0
- package/packages/memory/mempalace.ts +169 -18
- package/packages/memory/package.json +3 -3
- package/packages/memory/storage.ts +27 -10
- package/packages/milestone/README.md +11 -3
- package/packages/milestone/hooks.ts +103 -29
- package/packages/milestone/index.ts +1 -1
- package/packages/milestone/package.json +5 -2
- package/packages/notify/package.json +2 -2
- package/packages/ralph/package.json +3 -3
- package/packages/subagents/package.json +1 -1
- package/packages/unipi/bundled.js +693 -408
- package/packages/updater/package.json +2 -2
- package/packages/utility/package.json +2 -2
- package/packages/web-api/package.json +2 -2
- package/packages/workflow/README.md +8 -0
- package/packages/workflow/commands.ts +16 -26
- package/packages/workflow/index.ts +165 -85
- 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
|
-
/**
|
|
31
|
-
const
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
/**
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
/**
|
|
205
|
-
export function
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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(
|
|
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, [
|
|
380
|
+
res = spawnSync(install.python, [bridgePath, palace, cmd, argsJson], {
|
|
236
381
|
encoding: "utf-8",
|
|
237
|
-
timeout:
|
|
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, [
|
|
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
|
-
},
|
|
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.
|
|
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.
|
|
47
|
-
"@pi-unipi/info-screen": "2.
|
|
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
|
-
//
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
395
|
-
//
|
|
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
|
-
//
|
|
633
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
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 {
|
|
16
|
+
import { getProgressSummary, updateItemStatus } from "./milestone.js";
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
let sessionStartMs = 0;
|
|
18
|
+
export const MILESTONE_SNAPSHOT_TYPE = "unipi-milestone-snapshot";
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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", (
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
154
|
-
path.join(
|
|
155
|
-
path.join(
|
|
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
|
-
|
|
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(
|
|
178
|
-
path.join(
|
|
179
|
-
path.join(
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
31
|
-
"@pi-unipi/info-screen": "2.
|
|
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",
|