@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.
Files changed (56) hide show
  1. package/README.md +22 -1
  2. package/dist/src/budget.js +10 -8
  3. package/dist/src/dream/cli.js +108 -24
  4. package/dist/src/dream/gates.js +13 -8
  5. package/dist/src/dream/git.js +71 -0
  6. package/dist/src/dream/lock.js +78 -37
  7. package/dist/src/dream/runner.js +90 -21
  8. package/dist/src/history-tools.js +5 -5
  9. package/dist/src/history.js +11 -6
  10. package/dist/src/index.js +14 -15
  11. package/dist/src/notes/address.js +31 -0
  12. package/dist/src/{memory → notes}/frontmatter.js +7 -5
  13. package/dist/src/{notes.js → notes/model.js} +1 -1
  14. package/dist/src/{memory → notes}/paths.js +7 -3
  15. package/dist/src/{memory → notes}/store.js +47 -74
  16. package/dist/src/notes/tools.js +153 -0
  17. package/dist/src/prompts.js +38 -29
  18. package/dist/src/protocol.js +9 -4
  19. package/dist/src/thresholds.js +33 -3
  20. package/dist/src/tool-output.js +4 -1
  21. package/dist/src/warning.js +3 -3
  22. package/dist/test/agent-loop.test.js +6 -4
  23. package/dist/test/coherence.test.js +5 -1
  24. package/dist/test/dream.test.js +419 -35
  25. package/dist/test/history.test.js +6 -1
  26. package/dist/test/integration.test.js +107 -47
  27. package/dist/test/{memory.test.js → notes.test.js} +154 -50
  28. package/dist/test/pagination.property.test.js +1 -1
  29. package/package.json +5 -5
  30. package/playbook.md +30 -3
  31. package/src/budget.ts +11 -9
  32. package/src/dream/cli.ts +95 -17
  33. package/src/dream/gates.ts +14 -7
  34. package/src/dream/git.ts +73 -0
  35. package/src/dream/lock.ts +67 -24
  36. package/src/dream/runner.ts +87 -20
  37. package/src/history-tools.ts +5 -5
  38. package/src/history.ts +12 -7
  39. package/src/index.ts +13 -14
  40. package/src/notes/address.ts +33 -0
  41. package/src/{memory → notes}/frontmatter.ts +7 -5
  42. package/src/{notes.ts → notes/model.ts} +2 -2
  43. package/src/{memory → notes}/paths.ts +8 -3
  44. package/src/{memory → notes}/store.ts +49 -79
  45. package/src/notes/tools.ts +132 -0
  46. package/src/prompts.ts +39 -29
  47. package/src/protocol.ts +9 -4
  48. package/src/thresholds.ts +38 -6
  49. package/src/tool-output.ts +4 -1
  50. package/src/warning.ts +3 -3
  51. package/dist/src/dream/apply.js +0 -87
  52. package/dist/src/dream/manifest.js +0 -16
  53. package/dist/src/memory/tools.js +0 -175
  54. package/src/dream/apply.ts +0 -47
  55. package/src/dream/manifest.ts +0 -21
  56. package/src/memory/tools.ts +0 -175
@@ -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
- export function timeGate(lockPath: string, minHours: number, now = Date.now()): GateResult {
6
- if (!existsSync(lockPath)) return { ok: true, reason: "time gate: no prior lock" };
7
- const age = now - statSync(lockPath).mtimeMs;
8
- return age >= minHours * 3600000 ? { ok: true, reason: "time gate: stale" } : { ok: false, reason: "time gate: lock is too fresh" };
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
- export function materialGate(home: string, lockMtime: number, minSessions: number): GateResult {
11
- const root = join(home, "pi", "session");
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 > lockMtime)) changed++;
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
  }
@@ -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 { existsSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
2
3
 
3
- export type LockState = { path: string; held: boolean; reason?: string; startedAt: number; priorMtime?: number };
4
- const HOUR = 60 * 60 * 1000;
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
- function live(pid: number): boolean {
7
- if (!Number.isInteger(pid) || pid <= 0) return false;
8
- try { process.kill(pid, 0); return true; } catch { return false; }
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
- let priorMtime: number | undefined;
14
- if (existsSync(path)) {
15
- const stat = statSync(path);
16
- priorMtime = stat.mtimeMs;
17
- let pid = 0;
18
- try { pid = Number.parseInt(readFileSync(path, "utf8").trim(), 10); } catch { /* reclaim */ }
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
- writeFileSync(path, String(process.pid), { flag: "wx" });
23
- return { path, held: true, startedAt: now, priorMtime };
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
- // The lock is also the durable last-dream timestamp. Leave the PID marker in place;
28
- // the next acquisition reclaims it once the PID is dead or it is older than an hour.
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 (lock.priorMtime === undefined) { try { unlinkSync(lock.path); } catch { /* best effort */ } }
38
- else restoreMtime(lock.path, lock.priorMtime);
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
  }
@@ -1,12 +1,77 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { readFileSync } from "node:fs";
3
- import { createAgentSession, ModelRuntime, resolveModelScopeWithDiagnostics, SessionManager, type AgentSession } from "@earendil-works/pi-coding-agent";
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 { parseManifest, type Manifest } from "./manifest.js";
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 READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "notes_read", "notes_list", "notes_search"];
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
- export function runExternalDreamer(command: string, playbook: string, cwd: string): Manifest {
24
- const result = spawnSync(command, { shell: true, cwd, input: playbook, encoding: "utf8" });
25
- if (result.error || result.status !== 0) throw new Error(`dreamer failed: ${result.error?.message ?? result.stderr ?? `exit ${result.status}`}`);
26
- return parseManifest(result.stdout);
27
- }
28
-
29
- export async function runDreamer(playbook: string, cwd: string, options: { command?: string; modelPattern?: string; sessionFactory?: DreamerSessionFactory } = {}): Promise<Manifest> {
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
- const content = event.message.content;
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
- return parseManifest(answer);
108
+ await session.prompt(playbook);
44
109
  } catch (error) {
45
- if (providerError) throw new Error(`dreamer failed: ${providerError}`);
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"); }
@@ -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 ?? 1200));
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: 50000, description: "Largest requested window in code points (default 12000). 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 }),
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 ?? 12000, 50000);
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 ?? 1200), match_offset_chars: earliestMatchOffsetChars(item.content, queries) }));
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: string | unknown[]): string {
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: `pcw:${sessionId.slice(0, 8)}:root`, items: [] };
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 = 1200) {
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 `pcw:${sessionId.slice(0, 8)}:root`;
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 { registerMemoryTools } from "./memory/tools.js";
3
- import { registerBudget, deriveThresholds, mergePiContextSettings } from "./budget.js";
2
+ import { registerNotesTools } from "./notes/tools.js";
3
+ import { registerBudget } from "./budget.js";
4
4
  import { output } from "./tool-output.js";
5
- export { deriveThresholds, mergePiContextSettings };
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 sessionId = ctx.sessionManager.getSessionId();
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
- registerMemoryTools(pi);
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 session8 = ctx.sessionManager.getSessionId().slice(0, 8);
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(`pcw:${session8}:${minted}`)) minted = randomUUID().slice(0, 8);
80
- const windowId = `pcw:${session8}:${minted}`;
81
- const previousId = windows[windows.length - 1]?.windowId ?? `pcw:${session8}:root`;
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 "../notes.js";
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", "global"];
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 = ["scope", "origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"] as const;
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 : "global";
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
- if ((KNOWN_KEYS as readonly string[]).includes(key)) continue;
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 "./session-reader.js";
2
- import { MAX_NOTE_BYTES, NOTE_TYPE } from "./protocol.js";
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 = {