@astrosheep/pi-context 0.20.0 → 0.22.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/README.md +22 -1
- package/dist/src/budget.js +10 -8
- package/dist/src/dream/cli.js +108 -24
- package/dist/src/dream/gates.js +13 -8
- package/dist/src/dream/git.js +71 -0
- package/dist/src/dream/lock.js +78 -37
- package/dist/src/dream/runner.js +90 -21
- package/dist/src/history-tools.js +5 -5
- package/dist/src/history.js +11 -6
- package/dist/src/index.js +14 -15
- package/dist/src/notes/address.js +31 -0
- package/dist/src/{memory → notes}/frontmatter.js +7 -5
- package/dist/src/{notes.js → notes/model.js} +1 -1
- package/dist/src/{memory → notes}/paths.js +7 -3
- package/dist/src/{memory → notes}/store.js +47 -74
- package/dist/src/notes/tools.js +153 -0
- package/dist/src/prompts.js +38 -29
- package/dist/src/protocol.js +9 -4
- package/dist/src/thresholds.js +33 -3
- package/dist/src/tool-output.js +4 -1
- package/dist/src/warning.js +3 -3
- package/dist/test/agent-loop.test.js +6 -4
- package/dist/test/coherence.test.js +5 -1
- package/dist/test/dream.test.js +419 -35
- package/dist/test/history.test.js +6 -1
- package/dist/test/integration.test.js +107 -47
- package/dist/test/{memory.test.js → notes.test.js} +154 -50
- package/dist/test/pagination.property.test.js +1 -1
- package/package.json +5 -5
- package/playbook.md +30 -3
- package/src/budget.ts +11 -9
- package/src/dream/cli.ts +95 -17
- package/src/dream/gates.ts +14 -7
- package/src/dream/git.ts +73 -0
- package/src/dream/lock.ts +67 -24
- package/src/dream/runner.ts +87 -20
- package/src/history-tools.ts +5 -5
- package/src/history.ts +12 -7
- package/src/index.ts +13 -14
- package/src/notes/address.ts +33 -0
- package/src/{memory → notes}/frontmatter.ts +7 -5
- package/src/{notes.ts → notes/model.ts} +2 -2
- package/src/{memory → notes}/paths.ts +8 -3
- package/src/{memory → notes}/store.ts +49 -79
- package/src/notes/tools.ts +132 -0
- package/src/prompts.ts +39 -29
- package/src/protocol.ts +9 -4
- package/src/thresholds.ts +38 -6
- package/src/tool-output.ts +4 -1
- package/src/warning.ts +3 -3
- package/dist/src/dream/apply.js +0 -87
- package/dist/src/dream/manifest.js +0 -16
- package/dist/src/memory/tools.js +0 -175
- package/src/dream/apply.ts +0 -47
- package/src/dream/manifest.ts +0 -21
- package/src/memory/tools.ts +0 -175
package/src/dream/gates.ts
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { sessionHomesRoot } from "../notes/paths.js";
|
|
3
4
|
|
|
4
5
|
export type GateResult = { ok: boolean; reason: string };
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The scheduler reads the last-run sidecar, not the lock: the lock's lifetime says
|
|
9
|
+
* nothing about when the last dream ran, while the sidecar records exactly that.
|
|
10
|
+
*/
|
|
11
|
+
export function timeGate(stampPath: string, minHours: number, now = Date.now()): GateResult {
|
|
12
|
+
if (!existsSync(stampPath)) return { ok: true, reason: "time gate: no prior dream" };
|
|
13
|
+
const age = now - statSync(stampPath).mtimeMs;
|
|
14
|
+
return age >= minHours * 3600000 ? { ok: true, reason: "time gate: stale" } : { ok: false, reason: "time gate: last dream is too recent" };
|
|
9
15
|
}
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
|
|
17
|
+
export function materialGate(home: string, sinceMtime: number, minSessions: number): GateResult {
|
|
18
|
+
const root = sessionHomesRoot(home);
|
|
12
19
|
let changed = 0;
|
|
13
20
|
if (existsSync(root)) for (const dir of readdirSync(root, { withFileTypes: true })) {
|
|
14
21
|
if (!dir.isDirectory()) continue;
|
|
15
22
|
const files = readdirSync(join(root, dir.name), { withFileTypes: true });
|
|
16
|
-
if (files.some((f) => f.isFile() && statSync(join(root, dir.name, f.name)).mtimeMs >
|
|
23
|
+
if (files.some((f) => f.isFile() && statSync(join(root, dir.name, f.name)).mtimeMs > sinceMtime)) changed++;
|
|
17
24
|
}
|
|
18
25
|
return changed >= minSessions ? { ok: true, reason: `material gate: ${changed} changed sessions` } : { ok: false, reason: `material gate: only ${changed} changed sessions` };
|
|
19
26
|
}
|
package/src/dream/git.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Outcome of one audit commit. `ok: true` always carries a real `commit` snapshot;
|
|
7
|
+
* `empty: true` only means no files changed (an empty baseline commit or no commit was
|
|
8
|
+
* needed). `ok: false` means the audit layer could not guarantee a snapshot.
|
|
9
|
+
*/
|
|
10
|
+
export type AuditResult =
|
|
11
|
+
| { ok: true; commit: string; empty: boolean }
|
|
12
|
+
| { ok: false; error: string };
|
|
13
|
+
|
|
14
|
+
/** Lock runtime artifacts are not notes and must not appear in snapshots or `git status`. */
|
|
15
|
+
const RUNTIME_IGNORE = ".dream.lock*";
|
|
16
|
+
|
|
17
|
+
/** Add the runtime-artifact pattern to the repository's local exclude, once. */
|
|
18
|
+
function ensureRuntimeIgnored(home: string): void {
|
|
19
|
+
try {
|
|
20
|
+
const exclude = join(home, ".git", "info", "exclude");
|
|
21
|
+
const current = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
|
|
22
|
+
if (current.split(/\r?\n/).includes(RUNTIME_IGNORE)) return;
|
|
23
|
+
mkdirSync(dirname(exclude), { recursive: true });
|
|
24
|
+
const prefix = current.length > 0 && !current.endsWith("\n") ? `${current}\n` : current;
|
|
25
|
+
writeFileSync(exclude, `${prefix}${RUNTIME_IGNORE}\n`);
|
|
26
|
+
} catch { /* best effort: an unignored lock only adds noise to the audit */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Git audit layer for a dream run: one commit before (baseline) and one after (dream),
|
|
31
|
+
* so the human gate reviews `git show` instead of trusting a report, and rollback is
|
|
32
|
+
* `git revert`. The caller decides how loud a failure is; this function only reports it.
|
|
33
|
+
* A clean tree on an established repository commits nothing; a repository with no HEAD
|
|
34
|
+
* gets an empty baseline commit, because an audit run with no snapshot is not a success.
|
|
35
|
+
*/
|
|
36
|
+
export function gitCommit(home: string, message: string): AuditResult {
|
|
37
|
+
try {
|
|
38
|
+
if (!existsSync(join(home, ".git"))) {
|
|
39
|
+
execFileSync("git", ["init", "-q"], { cwd: home, stdio: "ignore" });
|
|
40
|
+
}
|
|
41
|
+
ensureRuntimeIgnored(home);
|
|
42
|
+
execFileSync("git", ["add", "-A"], { cwd: home, stdio: "ignore" });
|
|
43
|
+
let clean = false;
|
|
44
|
+
try {
|
|
45
|
+
execFileSync("git", ["diff", "--cached", "--quiet"], { cwd: home, stdio: "ignore" });
|
|
46
|
+
clean = true; // clean tree — no empty commit on an established repository
|
|
47
|
+
} catch { clean = false; }
|
|
48
|
+
const before = headCommit(home);
|
|
49
|
+
if (!clean) {
|
|
50
|
+
execFileSync("git", ["commit", "-q", "-m", message], { cwd: home, stdio: "ignore" });
|
|
51
|
+
console.log(`git: committed "${message}"`);
|
|
52
|
+
} else if (before === undefined) {
|
|
53
|
+
// A newly initialized repository has no snapshot at all; give the audit one.
|
|
54
|
+
execFileSync("git", ["commit", "-q", "--allow-empty", "-m", message], { cwd: home, stdio: "ignore" });
|
|
55
|
+
console.log(`git: committed "${message}"`);
|
|
56
|
+
}
|
|
57
|
+
const commit = headCommit(home);
|
|
58
|
+
if (commit === undefined) return { ok: false, error: "audit commit produced no snapshot (no HEAD)" };
|
|
59
|
+
return { ok: true, commit, empty: clean };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
62
|
+
console.log(`git audit layer failed: ${reason}`);
|
|
63
|
+
return { ok: false, error: reason };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** HEAD sha, or undefined when the repository has no commit yet. */
|
|
68
|
+
function headCommit(home: string): string | undefined {
|
|
69
|
+
try {
|
|
70
|
+
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: home, encoding: "utf8" }).trim();
|
|
71
|
+
return head.length > 0 ? head : undefined;
|
|
72
|
+
} catch { return undefined; }
|
|
73
|
+
}
|
package/src/dream/lock.ts
CHANGED
|
@@ -1,39 +1,82 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
3
|
|
|
3
|
-
export type LockState = {
|
|
4
|
-
|
|
4
|
+
export type LockState = {
|
|
5
|
+
path: string;
|
|
6
|
+
held: boolean;
|
|
7
|
+
reason?: string;
|
|
8
|
+
startedAt: number;
|
|
9
|
+
/** mtime of the last-run sidecar before this run took the lock; failLock restores it. */
|
|
10
|
+
priorStampMtime?: number;
|
|
11
|
+
/** Random identity written into the lock file; cleanup only removes the lock it wrote. */
|
|
12
|
+
token?: string;
|
|
13
|
+
};
|
|
5
14
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The scheduler's last-run timestamp lives in a sidecar beside the lock, never in the
|
|
17
|
+
* lock file itself: acquiring, releasing or cleaning up the lock touches only the PID
|
|
18
|
+
* marker, so lock lifecycle does not destroy the timestamp the time gate reads.
|
|
19
|
+
*/
|
|
20
|
+
export function lastRunPath(lockPath: string): string {
|
|
21
|
+
return `${lockPath}.last-run`;
|
|
9
22
|
}
|
|
10
23
|
|
|
24
|
+
function readText(path: string): string | undefined {
|
|
25
|
+
try { return readFileSync(path, "utf8"); } catch { return undefined; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stampMtime(stampPath: string): number | undefined {
|
|
29
|
+
try { return statSync(stampPath).mtimeMs; } catch { return undefined; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Cleanup ownership: only the exact marker this run wrote may be removed. The token is
|
|
34
|
+
* diagnostic and guards cleanup; it never grants permission to take an existing lock.
|
|
35
|
+
*/
|
|
36
|
+
function ownsLock(lock: LockState): boolean {
|
|
37
|
+
if (!lock.held || !lock.token) return false;
|
|
38
|
+
return readText(lock.path)?.trim() === `${process.pid} ${lock.token}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Acquire the dream lock with Git-style exclusive existence locking: one O_CREAT|O_EXCL
|
|
43
|
+
* creation. An existing path refuses acquisition regardless of its contents, PID, or age,
|
|
44
|
+
* and is never read for permission, replaced, or removed. There is no automatic stale
|
|
45
|
+
* recovery; a crash-left lock is human cleanup after confirming no dream is running.
|
|
46
|
+
*/
|
|
11
47
|
export function acquireLock(path: string): LockState {
|
|
12
48
|
const now = Date.now();
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (now - stat.mtimeMs <= HOUR && live(pid)) return { path, held: false, reason: "lock gate: live process holds the lock", startedAt: now };
|
|
20
|
-
try { unlinkSync(path); } catch { return { path, held: false, reason: "lock gate: lock could not be reclaimed", startedAt: now }; }
|
|
49
|
+
const priorStampMtime = stampMtime(lastRunPath(path));
|
|
50
|
+
const token = randomUUID();
|
|
51
|
+
try {
|
|
52
|
+
writeFileSync(path, `${process.pid} ${token}`, { flag: "wx" });
|
|
53
|
+
} catch {
|
|
54
|
+
return { path, held: false, reason: "lock gate: lock already exists", startedAt: now };
|
|
21
55
|
}
|
|
22
|
-
|
|
23
|
-
|
|
56
|
+
// Only a held lock advances the scheduler timestamp.
|
|
57
|
+
try { writeFileSync(lastRunPath(path), new Date(now).toISOString()); } catch { /* advisory */ }
|
|
58
|
+
return { path, held: true, startedAt: now, priorStampMtime, token };
|
|
24
59
|
}
|
|
25
60
|
|
|
61
|
+
/** Release only the lock this run acquired. Idempotent: repeated cleanup does nothing. */
|
|
26
62
|
export function releaseLock(lock: LockState): void {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
export function restoreMtime(path: string, mtimeMs: number): void {
|
|
32
|
-
try { utimesSync(path, new Date(), new Date(mtimeMs)); } catch { /* advisory */ }
|
|
63
|
+
if (!lock.held) return;
|
|
64
|
+
if (ownsLock(lock)) { try { unlinkSync(lock.path); } catch { /* best effort */ } }
|
|
65
|
+
lock.held = false;
|
|
33
66
|
}
|
|
34
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A failed run must not advance the scheduler: restore the previous timestamp, or remove
|
|
70
|
+
* the one this run wrote when there was none. Only this run's own marker is removed, and
|
|
71
|
+
* the state is marked released so a later cleanup attempt is harmless.
|
|
72
|
+
*/
|
|
35
73
|
export function failLock(lock: LockState): void {
|
|
36
74
|
if (!lock.held) return;
|
|
37
|
-
if (
|
|
38
|
-
|
|
75
|
+
if (ownsLock(lock)) {
|
|
76
|
+
const stampPath = lastRunPath(lock.path);
|
|
77
|
+
if (lock.priorStampMtime === undefined) { try { unlinkSync(stampPath); } catch { /* best effort */ } }
|
|
78
|
+
else { try { utimesSync(stampPath, new Date(), new Date(lock.priorStampMtime)); } catch { /* best effort */ } }
|
|
79
|
+
try { unlinkSync(lock.path); } catch { /* best effort */ }
|
|
80
|
+
}
|
|
81
|
+
lock.held = false;
|
|
39
82
|
}
|
package/src/dream/runner.ts
CHANGED
|
@@ -1,12 +1,77 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
2
|
+
import { lstat, mkdir, realpath } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import { createAgentSession, createEditToolDefinition, createWriteToolDefinition, ModelRuntime, resolveModelScopeWithDiagnostics, SessionManager, type AgentSession, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
5
|
-
import {
|
|
6
|
+
import { contentText } from "../history.js";
|
|
6
7
|
|
|
8
|
+
export type DreamWrite = { tool: "write" | "edit"; path: string };
|
|
9
|
+
export type DreamResult = { report: string; writes: DreamWrite[]; error?: string };
|
|
7
10
|
export type DreamerSession = Pick<AgentSession, "prompt" | "subscribe" | "dispose">;
|
|
8
11
|
export type DreamerSessionFactory = (options: { cwd: string; modelPattern?: string; tools: string[] }) => Promise<DreamerSession>;
|
|
9
|
-
export const
|
|
12
|
+
export const DREAMER_TOOLS = ["read", "grep", "find", "ls", "write", "edit"];
|
|
13
|
+
|
|
14
|
+
function isOutside(notesHome: string, target: string): boolean {
|
|
15
|
+
const fromHome = relative(notesHome, target);
|
|
16
|
+
return fromHome === ".." || fromHome.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(fromHome);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function jailWritePath(notesHome: string, path: string): Promise<void> {
|
|
20
|
+
let realNotesHome: string;
|
|
21
|
+
try {
|
|
22
|
+
realNotesHome = await realpath(notesHome);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(`write jail: cannot resolve notes home ${notesHome}`);
|
|
25
|
+
}
|
|
26
|
+
const target = resolve(realNotesHome, path);
|
|
27
|
+
const targetParent = dirname(target);
|
|
28
|
+
if (isOutside(realNotesHome, targetParent)) throw new Error(`write jail: ${path} is outside notes home ${notesHome}`);
|
|
29
|
+
// write creates parent directories itself. Create only after the lexical check, then
|
|
30
|
+
// canonicalize the parent so a symlink cannot lead the underlying tool out of home.
|
|
31
|
+
await mkdir(targetParent, { recursive: true });
|
|
32
|
+
let realTargetParent: string;
|
|
33
|
+
try {
|
|
34
|
+
realTargetParent = await realpath(targetParent);
|
|
35
|
+
} catch {
|
|
36
|
+
throw new Error(`write jail: cannot resolve target parent in notes home ${notesHome}`);
|
|
37
|
+
}
|
|
38
|
+
if (isOutside(realNotesHome, realTargetParent)) throw new Error(`write jail: ${path} is outside notes home ${notesHome}`);
|
|
39
|
+
let targetStats;
|
|
40
|
+
try {
|
|
41
|
+
targetStats = await lstat(target);
|
|
42
|
+
} catch (error: any) {
|
|
43
|
+
if (error.code !== "ENOENT") throw new Error(`write jail: cannot inspect target in notes home ${notesHome}`);
|
|
44
|
+
}
|
|
45
|
+
if (targetStats?.isSymbolicLink()) {
|
|
46
|
+
let realTarget: string;
|
|
47
|
+
try {
|
|
48
|
+
realTarget = await realpath(target);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(`write jail: cannot resolve target in notes home ${notesHome}`);
|
|
51
|
+
}
|
|
52
|
+
if (isOutside(realNotesHome, realTarget)) throw new Error(`write jail: ${path} is outside notes home ${notesHome}`);
|
|
53
|
+
}
|
|
54
|
+
if (targetStats && targetStats.nlink > 1) throw new Error(`write jail: ${path} has hard links and is not allowed in notes home ${notesHome}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function jailToolDefinition<T extends ToolDefinition<any, any, any>>(definition: T, notesHome: string): T {
|
|
58
|
+
const execute = definition.execute;
|
|
59
|
+
return {
|
|
60
|
+
...definition,
|
|
61
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
62
|
+
await jailWritePath(notesHome, (params as { path: string }).path);
|
|
63
|
+
return execute(toolCallId, params, signal, onUpdate, ctx);
|
|
64
|
+
},
|
|
65
|
+
} as T;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The only custom definitions in the dream session replace the two built-ins with jailed versions. */
|
|
69
|
+
export function dreamerWriteToolDefinitions(notesHome: string): ToolDefinition<any, any, any>[] {
|
|
70
|
+
return [
|
|
71
|
+
jailToolDefinition(createWriteToolDefinition(notesHome), notesHome),
|
|
72
|
+
jailToolDefinition(createEditToolDefinition(notesHome), notesHome),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
10
75
|
|
|
11
76
|
export const defaultDreamerSessionFactory: DreamerSessionFactory = async ({ cwd, modelPattern, tools }) => {
|
|
12
77
|
let model: Model<Api> | undefined;
|
|
@@ -16,38 +81,40 @@ export const defaultDreamerSessionFactory: DreamerSessionFactory = async ({ cwd,
|
|
|
16
81
|
model = result.scopedModels[0]?.model;
|
|
17
82
|
if (!model) throw new Error(`dreamer model pattern "${modelPattern}" did not resolve to an available model`);
|
|
18
83
|
}
|
|
19
|
-
const { session } = await createAgentSession({ cwd, sessionManager: SessionManager.inMemory(cwd), tools, noTools: "all", model });
|
|
84
|
+
const { session } = await createAgentSession({ cwd, sessionManager: SessionManager.inMemory(cwd), tools, customTools: dreamerWriteToolDefinitions(cwd), noTools: "all", model, thinkingLevel: "off" });
|
|
20
85
|
return session;
|
|
21
86
|
};
|
|
22
87
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (options.command) return runExternalDreamer(options.command, playbook, cwd);
|
|
31
|
-
const session = await (options.sessionFactory ?? defaultDreamerSessionFactory)({ cwd, modelPattern: options.modelPattern, tools: READ_ONLY_TOOLS });
|
|
88
|
+
/**
|
|
89
|
+
* Run one dream turn. A dreamer failure is returned as `error` together with the partial
|
|
90
|
+
* writes observed so far, so the caller can record partial state instead of losing it;
|
|
91
|
+
* only a failure to even start the session throws.
|
|
92
|
+
*/
|
|
93
|
+
export async function runDreamer(playbook: string, cwd: string, options: { modelPattern?: string; sessionFactory?: DreamerSessionFactory } = {}): Promise<DreamResult> {
|
|
94
|
+
const session = await (options.sessionFactory ?? defaultDreamerSessionFactory)({ cwd, modelPattern: options.modelPattern, tools: DREAMER_TOOLS });
|
|
32
95
|
let answer = "";
|
|
33
96
|
let providerError: string | undefined;
|
|
97
|
+
const writes: DreamWrite[] = [];
|
|
34
98
|
const unsubscribe = session.subscribe((event: any) => {
|
|
99
|
+
const tool = event.toolName ?? event.tool?.name;
|
|
100
|
+
const args = event.args ?? event.arguments ?? event.tool?.arguments;
|
|
101
|
+
if ((tool === "write" || tool === "edit") && args && typeof args === "object" && typeof args.path === "string") writes.push({ tool, path: args.path });
|
|
35
102
|
if (event.type !== "message_end" || event.message?.role !== "assistant") return;
|
|
36
103
|
if (event.message.stopReason === "error") { providerError = event.message.errorMessage ?? "unknown provider error"; return; }
|
|
37
|
-
|
|
38
|
-
answer = typeof content === "string" ? content : Array.isArray(content) ? content.filter((part: any) => part.type === "text").map((part: any) => part.text).join("") : "";
|
|
104
|
+
answer = contentText(event.message.content);
|
|
39
105
|
});
|
|
40
106
|
try {
|
|
41
|
-
await session.prompt(`${playbook}\n\nReturn exactly one JSON manifest matching this schema: { merge?, promote?, trash?, pending?, skillCandidates?, report }.`);
|
|
42
107
|
try {
|
|
43
|
-
|
|
108
|
+
await session.prompt(playbook);
|
|
44
109
|
} catch (error) {
|
|
45
|
-
|
|
46
|
-
throw error;
|
|
110
|
+
return { report: answer, writes, error: error instanceof Error ? error.message : String(error) };
|
|
47
111
|
}
|
|
112
|
+
if (providerError) return { report: answer, writes, error: `dreamer failed: ${providerError}` };
|
|
113
|
+
return { report: answer, writes };
|
|
48
114
|
} finally {
|
|
49
115
|
unsubscribe?.();
|
|
50
116
|
session.dispose();
|
|
51
117
|
}
|
|
52
118
|
}
|
|
119
|
+
|
|
53
120
|
export function loadPlaybook(path: string): string { return readFileSync(path, "utf8"); }
|
package/src/history-tools.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Type } from "@earendil-works/pi-ai";
|
|
2
2
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, characterWindowHeader, withinTextBudget } from "./tool-output.js";
|
|
3
|
+
import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, characterWindowHeader, withinTextBudget, DEFAULT_READ_WINDOW_CHARS, HISTORY_PREVIEW_CHARS, MAX_READ_WINDOW_CHARS } from "./tool-output.js";
|
|
4
4
|
import { positiveInteger, recentFirst, nullableString, role, cursor, searchQuery, searchQueries } from "./tool-schema.js";
|
|
5
5
|
import { historyFromSession, filteredItems, visibleItem, allItems, vacuousRoleToolCombo, unknownWindowId } from "./history.js";
|
|
6
6
|
|
|
@@ -53,7 +53,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
53
53
|
if (invalid) return output({ error: invalid, role: params.role, tool_name: params.tool_name });
|
|
54
54
|
const badWindow = unknownWindowId(ctx, params);
|
|
55
55
|
if (badWindow) return output({ error: badWindow.message, window_id: params.window_id, known_windows: badWindow.known });
|
|
56
|
-
const items = filteredItems(ctx, params).map((item) => visibleItem(item, params.max_chars_per_item ??
|
|
56
|
+
const items = filteredItems(ctx, params).map((item) => visibleItem(item, params.max_chars_per_item ?? HISTORY_PREVIEW_CHARS));
|
|
57
57
|
return output(page(items, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
|
|
58
58
|
},
|
|
59
59
|
}));
|
|
@@ -62,7 +62,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
62
62
|
name: "history_read",
|
|
63
63
|
label: "History read item",
|
|
64
64
|
description: "Read a bounded character range from one session item. Each response delivers the longest contiguous prefix of the requested window that fits the wire budget: follow the resume cursor to reconstruct the item exactly. A negative offset_chars counts back from the item's end. Offsets and counts are code points (an emoji or CJK character counts as one). The response is the raw item text behind a one-line [bracketed] header naming the item, the resolved offset, the delivered char range, and the resume cursor (continue at offset_chars=N, or end).",
|
|
65
|
-
parameters: Type.Object({ item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from. A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum:
|
|
65
|
+
parameters: Type.Object({ item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from. A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_READ_WINDOW_CHARS, description: `Largest requested window in code points (default ${DEFAULT_READ_WINDOW_CHARS}). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes.` })), window_id: Type.String() }, { additionalProperties: false }),
|
|
66
66
|
async execute(_id, params, _signal, _update, ctx) {
|
|
67
67
|
const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
|
|
68
68
|
if (!item) return output({ error: "unknown item_id or window_id" });
|
|
@@ -72,7 +72,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
72
72
|
if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
|
|
73
73
|
return output({ error: `offset_chars ${params.offset_chars} is past the end: the item has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, window_id: item.windowId, item_id: item.itemId, offset_chars: params.offset_chars, total_chars: totalChars });
|
|
74
74
|
}
|
|
75
|
-
const limit_chars = Math.min(params.limit_chars ??
|
|
75
|
+
const limit_chars = Math.min(params.limit_chars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS);
|
|
76
76
|
return readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => {
|
|
77
77
|
const { content, ...cursor } = window;
|
|
78
78
|
return outputRaw(characterWindowHeader(`${item.windowId} · item ${item.itemId}`, window), content, { window_id: item.windowId, item_id: item.itemId, ...cursor, limit_chars });
|
|
@@ -93,7 +93,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
93
93
|
const queries = searchQueries(params.query);
|
|
94
94
|
const matching = filteredItems(ctx, params)
|
|
95
95
|
.filter((item) => queries.some((query) => item.content.includes(query)))
|
|
96
|
-
.map((item) => ({ ...visibleItem(item, params.max_chars_per_item ??
|
|
96
|
+
.map((item) => ({ ...visibleItem(item, params.max_chars_per_item ?? HISTORY_PREVIEW_CHARS), match_offset_chars: earliestMatchOffsetChars(item.content, queries) }));
|
|
97
97
|
return output(page(matching, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
|
|
98
98
|
},
|
|
99
99
|
}));
|
package/src/history.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { TextContent, ToolCall } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
3
|
import type { SessionReader } from "./session-reader.js";
|
|
4
4
|
import { RESET_V2 } from "./protocol.js";
|
|
5
|
+
import { HISTORY_PREVIEW_CHARS } from "./tool-output.js";
|
|
5
6
|
|
|
6
7
|
type HistoryItem = {
|
|
7
8
|
windowId: string;
|
|
@@ -29,9 +30,9 @@ function isTextContent(part: unknown): part is TextContent {
|
|
|
29
30
|
return typeof part === "object" && part !== null && (part as TextContent).type === "text" && typeof (part as TextContent).text === "string";
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
function contentText(content:
|
|
33
|
+
export function contentText(content: unknown): string {
|
|
33
34
|
if (typeof content === "string") return content;
|
|
34
|
-
return content.filter(isTextContent).map((part) => part.text).join("\n");
|
|
35
|
+
return Array.isArray(content) ? content.filter(isTextContent).map((part) => part.text).join("\n") : "";
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
function mapRole(role: AgentMessage["role"]): HistoryItem["role"] | undefined {
|
|
@@ -102,14 +103,19 @@ export function resetV2WindowId(details: unknown): string | undefined {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
/** A compaction entry's window id: the extension-minted id for reset-v2, else Pi's entry id. */
|
|
105
|
-
function windowIdOf(sessionId: string, entry: { id: string; details?: unknown }): string {
|
|
106
|
+
export function windowIdOf(sessionId: string, entry: { id: string; details?: unknown }): string {
|
|
106
107
|
return resetV2WindowId(entry.details) ?? `pcw:${sessionId.slice(0, 8)}:${entry.id}`;
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
/** Mint the durable identity of a session's root history window. */
|
|
111
|
+
export function rootWindowId(sessionId: string): string {
|
|
112
|
+
return `pcw:${sessionId.slice(0, 8)}:root`;
|
|
113
|
+
}
|
|
114
|
+
|
|
109
115
|
/** Build durable, on-demand history directly from every entry on the current session branch. */
|
|
110
116
|
export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
|
|
111
117
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
112
|
-
let window: HistoryWindow = { windowId:
|
|
118
|
+
let window: HistoryWindow = { windowId: rootWindowId(sessionId), items: [] };
|
|
113
119
|
const windows = [window];
|
|
114
120
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
115
121
|
if (entry.type === "compaction") {
|
|
@@ -153,7 +159,7 @@ export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
|
|
|
153
159
|
return windows;
|
|
154
160
|
}
|
|
155
161
|
|
|
156
|
-
export function visibleItem(item: HistoryItem, maxChars =
|
|
162
|
+
export function visibleItem(item: HistoryItem, maxChars = HISTORY_PREVIEW_CHARS) {
|
|
157
163
|
const characters = Array.from(item.content);
|
|
158
164
|
const truncated = characters.length > maxChars;
|
|
159
165
|
return {
|
|
@@ -230,6 +236,5 @@ export function currentWindowId(ctx: SessionReader): string {
|
|
|
230
236
|
const entry = branch[i];
|
|
231
237
|
if (entry?.type === "compaction") return windowIdOf(sessionId, entry);
|
|
232
238
|
}
|
|
233
|
-
return
|
|
239
|
+
return rootWindowId(sessionId);
|
|
234
240
|
}
|
|
235
|
-
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { registerHistoryTools } from "./history-tools.js";
|
|
2
|
-
import {
|
|
3
|
-
import { registerBudget
|
|
2
|
+
import { registerNotesTools } from "./notes/tools.js";
|
|
3
|
+
import { registerBudget } from "./budget.js";
|
|
4
4
|
import { output } from "./tool-output.js";
|
|
5
|
-
|
|
5
|
+
import { deriveThresholds, mergePiContextSettings } from "./thresholds.js";
|
|
6
6
|
import { STATE_TYPE, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, RESET_V2, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS, RESET_SUMMARY, CONTINUATION, WARNING_PROMPT } from "./protocol.js";
|
|
7
|
-
import { historyFromSession, hasWindowMessage, currentWindowId, resetV2WindowId } from "./history.js";
|
|
8
|
-
import { assertVirtualPath } from "./notes.js";
|
|
7
|
+
import { historyFromSession, hasWindowMessage, currentWindowId, resetV2WindowId, rootWindowId, windowIdOf } from "./history.js";
|
|
8
|
+
import { assertVirtualPath } from "./notes/model.js";
|
|
9
9
|
import { bootBlock } from "./prompts.js";
|
|
10
10
|
export { historyFromSession } from "./history.js";
|
|
11
|
-
export { notesFromSession } from "./notes.js";
|
|
11
|
+
export { notesFromSession } from "./notes/model.js";
|
|
12
12
|
import { registerResetLifecycle } from "./reset-lifecycle.js";
|
|
13
13
|
import { registerWarning } from "./warning.js";
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
@@ -25,8 +25,7 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
25
25
|
// The root window has no compaction entry to carry the boot block, so persist
|
|
26
26
|
// it once as a hidden custom message. Reset windows already carry theirs at
|
|
27
27
|
// position 0 in the compaction summary, so a resumed session adds nothing.
|
|
28
|
-
const
|
|
29
|
-
const rootId = `pcw:${sessionId.slice(0, 8)}:root`;
|
|
28
|
+
const rootId = rootWindowId(ctx.sessionManager.getSessionId());
|
|
30
29
|
if (currentWindowId(ctx) !== rootId || hasWindowMessage(ctx, BOOT_TYPE)) return;
|
|
31
30
|
pi.sendMessage({ customType: BOOT_TYPE, content: bootBlock(ctx, rootId, undefined, false), display: false }, { triggerTurn: false });
|
|
32
31
|
});
|
|
@@ -48,7 +47,7 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
48
47
|
});
|
|
49
48
|
|
|
50
49
|
registerHistoryTools(pi);
|
|
51
|
-
|
|
50
|
+
registerNotesTools(pi);
|
|
52
51
|
|
|
53
52
|
pi.registerTool(defineTool({
|
|
54
53
|
name: "new_context",
|
|
@@ -70,15 +69,15 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
70
69
|
},
|
|
71
70
|
onReset: (entryId) => pi.appendEntry(STATE_TYPE, { version: 1, lastResetEntryId: entryId }),
|
|
72
71
|
buildReset: (event, ctx, explicit) => {
|
|
73
|
-
const
|
|
72
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
74
73
|
// Window IDs are independent of Pi entry IDs. Avoid reusing a window
|
|
75
74
|
// identity already present on this branch.
|
|
76
75
|
const windows = historyFromSession(ctx);
|
|
77
76
|
const usedIds = new Set(windows.map((window) => window.windowId));
|
|
78
|
-
let minted = randomUUID().slice(0, 8);
|
|
79
|
-
while (usedIds.has(
|
|
80
|
-
const windowId =
|
|
81
|
-
const previousId = windows[windows.length - 1]?.windowId ??
|
|
77
|
+
let minted = { id: randomUUID().slice(0, 8) };
|
|
78
|
+
while (usedIds.has(windowIdOf(sessionId, minted))) minted = { id: randomUUID().slice(0, 8) };
|
|
79
|
+
const windowId = windowIdOf(sessionId, minted);
|
|
80
|
+
const previousId = windows[windows.length - 1]?.windowId ?? rootWindowId(sessionId);
|
|
82
81
|
// The reset marker stays as firstKeptEntryId; it no longer names the window.
|
|
83
82
|
pi.appendEntry(RESET_MARKER_TYPE, { version: 1, reason: event.reason, requested: explicit });
|
|
84
83
|
const markerId = ctx.sessionManager.getLeafId();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { assertVirtualPath } from "./model.js";
|
|
2
|
+
import type { Scope } from "./paths.js";
|
|
3
|
+
|
|
4
|
+
export type NoteAddress = { scope: Scope; path: string };
|
|
5
|
+
|
|
6
|
+
const ADDRESS_FORMS = "legal prefixes are @project/ and @personal/; bare names are the session home";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Decode the one public note address into its physical home and virtual path. This is a
|
|
10
|
+
* tool-boundary rule: replay paths keep using assertVirtualPath directly and are untouched.
|
|
11
|
+
*/
|
|
12
|
+
export function assertAddress(value: unknown): NoteAddress {
|
|
13
|
+
if (typeof value !== "string") throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
|
|
14
|
+
let scope: Scope = "session";
|
|
15
|
+
let path = value;
|
|
16
|
+
if (value.startsWith("@project/")) {
|
|
17
|
+
scope = "project";
|
|
18
|
+
path = value.slice("@project/".length);
|
|
19
|
+
} else if (value.startsWith("@personal/")) {
|
|
20
|
+
scope = "personal";
|
|
21
|
+
path = value.slice("@personal/".length);
|
|
22
|
+
} else if (value.startsWith("@")) {
|
|
23
|
+
throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
|
|
24
|
+
}
|
|
25
|
+
if (path.includes("@")) throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
|
|
26
|
+
assertVirtualPath(path);
|
|
27
|
+
return { scope, path };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Render a virtual path in its one unambiguous public address form. */
|
|
31
|
+
export function addressFor(scope: Scope, path: string): string {
|
|
32
|
+
return scope === "session" ? path : `@${scope}/${path}`;
|
|
33
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { localIso } from "
|
|
1
|
+
import { localIso } from "./model.js";
|
|
2
2
|
import type { Scope } from "./paths.js";
|
|
3
3
|
|
|
4
4
|
export type NoteStatus = "active" | "superseded" | "pending" | "archived";
|
|
@@ -25,12 +25,12 @@ export type NoteMeta = {
|
|
|
25
25
|
[key: string]: unknown;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
const SCOPES: readonly Scope[] = ["session", "project", "
|
|
28
|
+
const SCOPES: readonly Scope[] = ["session", "project", "personal"];
|
|
29
29
|
const ORIGINS: readonly Origin[] = ["user", "self", "external"];
|
|
30
30
|
const STATUSES: readonly NoteStatus[] = ["active", "superseded", "pending", "archived"];
|
|
31
31
|
const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"] as const;
|
|
32
32
|
/** Emission order, exactly the Design's key list. */
|
|
33
|
-
const KNOWN_KEYS = ["
|
|
33
|
+
const KNOWN_KEYS = ["origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"] as const;
|
|
34
34
|
|
|
35
35
|
export function isScope(value: unknown): value is Scope {
|
|
36
36
|
return typeof value === "string" && (SCOPES as readonly string[]).includes(value);
|
|
@@ -112,7 +112,7 @@ function parseFrontmatter(raw: string): { fields: Record<string, unknown>; body:
|
|
|
112
112
|
export function parseNote(raw: string, now = Date.now()): { meta: NoteMeta; body: string } {
|
|
113
113
|
const { fields, body } = parseFrontmatter(raw);
|
|
114
114
|
const meta = { ...fields } as Record<string, unknown>;
|
|
115
|
-
meta.scope = isScope(meta.scope) ? meta.scope : "
|
|
115
|
+
meta.scope = isScope(meta.scope) ? meta.scope : "personal";
|
|
116
116
|
meta.origin = isOrigin(meta.origin) ? meta.origin : "self";
|
|
117
117
|
meta.status = isStatus(meta.status) ? meta.status : "active";
|
|
118
118
|
meta.stale = meta.stale === true;
|
|
@@ -140,7 +140,9 @@ export function serializeNote(meta: NoteMeta, body: string): string {
|
|
|
140
140
|
else lines.push(`${key}: ${yamlScalar(value)}`);
|
|
141
141
|
}
|
|
142
142
|
for (const key of Object.keys(meta)) {
|
|
143
|
-
|
|
143
|
+
// scope is a legacy on-disk field. Store callers derive it from the home's location,
|
|
144
|
+
// but serialization intentionally drops it on the next write.
|
|
145
|
+
if (key === "scope" || (KNOWN_KEYS as readonly string[]).includes(key)) continue;
|
|
144
146
|
if (meta[key] === undefined) continue;
|
|
145
147
|
lines.push(`${key}: ${yamlScalar(meta[key])}`);
|
|
146
148
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { SessionReader } from "
|
|
2
|
-
import { MAX_NOTE_BYTES, NOTE_TYPE } from "
|
|
1
|
+
import type { SessionReader } from "../session-reader.js";
|
|
2
|
+
import { MAX_NOTE_BYTES, NOTE_TYPE } from "../protocol.js";
|
|
3
3
|
|
|
4
4
|
export type NoteFile = { text: string; stale: boolean; createdAt: number; updatedAt: number };
|
|
5
5
|
export type NoteOperation = {
|