@astrosheep/pi-context 0.20.0 → 0.21.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 (53) hide show
  1. package/dist/src/budget.js +10 -8
  2. package/dist/src/dream/cli.js +9 -8
  3. package/dist/src/dream/gates.js +2 -1
  4. package/dist/src/dream/git.js +28 -0
  5. package/dist/src/dream/runner.js +84 -25
  6. package/dist/src/history-tools.js +5 -5
  7. package/dist/src/history.js +11 -6
  8. package/dist/src/index.js +14 -15
  9. package/dist/src/notes/address.js +31 -0
  10. package/dist/src/{memory → notes}/frontmatter.js +5 -3
  11. package/dist/src/{notes.js → notes/model.js} +1 -1
  12. package/dist/src/{memory → notes}/paths.js +5 -1
  13. package/dist/src/{memory → notes}/store.js +45 -72
  14. package/dist/src/notes/tools.js +153 -0
  15. package/dist/src/prompts.js +31 -29
  16. package/dist/src/protocol.js +8 -4
  17. package/dist/src/thresholds.js +4 -1
  18. package/dist/src/tool-output.js +4 -1
  19. package/dist/src/warning.js +3 -3
  20. package/dist/test/agent-loop.test.js +6 -4
  21. package/dist/test/coherence.test.js +5 -1
  22. package/dist/test/dream.test.js +133 -34
  23. package/dist/test/history.test.js +6 -1
  24. package/dist/test/integration.test.js +84 -34
  25. package/dist/test/{memory.test.js → notes.test.js} +138 -34
  26. package/dist/test/pagination.property.test.js +1 -1
  27. package/package.json +5 -5
  28. package/playbook.md +30 -3
  29. package/src/budget.ts +11 -9
  30. package/src/dream/cli.ts +8 -8
  31. package/src/dream/gates.ts +2 -1
  32. package/src/dream/git.ts +27 -0
  33. package/src/dream/runner.ts +81 -23
  34. package/src/history-tools.ts +5 -5
  35. package/src/history.ts +12 -7
  36. package/src/index.ts +13 -14
  37. package/src/notes/address.ts +33 -0
  38. package/src/{memory → notes}/frontmatter.ts +5 -3
  39. package/src/{notes.ts → notes/model.ts} +2 -2
  40. package/src/{memory → notes}/paths.ts +6 -1
  41. package/src/{memory → notes}/store.ts +47 -77
  42. package/src/notes/tools.ts +132 -0
  43. package/src/prompts.ts +31 -29
  44. package/src/protocol.ts +8 -4
  45. package/src/thresholds.ts +4 -1
  46. package/src/tool-output.ts +4 -1
  47. package/src/warning.ts +3 -3
  48. package/dist/src/dream/apply.js +0 -87
  49. package/dist/src/dream/manifest.js +0 -16
  50. package/dist/src/memory/tools.js +0 -175
  51. package/src/dream/apply.ts +0 -47
  52. package/src/dream/manifest.ts +0 -21
  53. package/src/memory/tools.ts +0 -175
@@ -5,7 +5,11 @@ import { thresholdsFor, resetThresholds } from "./thresholds.js";
5
5
  import { currentWindowId, hasWindowMessage } from "./history.js";
6
6
  import { tokenBudgetGuidance } from "./prompts.js";
7
7
  import { output } from "./tool-output.js";
8
- export { deriveThresholds, mergePiContextSettings } from "./thresholds.js";
8
+ /** Remaining tokens in the current context window, or null when Pi has no usage estimate. */
9
+ export function remainingTokens(ctx) {
10
+ const usage = ctx.getContextUsage();
11
+ return !usage || usage.tokens === null ? null : Math.max(0, usage.contextWindow - usage.tokens);
12
+ }
9
13
  export function registerBudget(pi, isEnabled) {
10
14
  let guidancePersistedInWindow;
11
15
  pi.on("session_start", (_event, ctx) => { guidancePersistedInWindow = undefined; resetThresholds(); thresholdsFor(ctx); });
@@ -15,10 +19,9 @@ export function registerBudget(pi, isEnabled) {
15
19
  return undefined;
16
20
  // The early reminder persists once per window the first time remaining crosses
17
21
  // reserve+margin. It never edits the outgoing request.
18
- const usage = ctx.getContextUsage();
19
- if (!usage || usage.tokens === null)
22
+ const remaining = remainingTokens(ctx);
23
+ if (remaining === null)
20
24
  return undefined;
21
- const remaining = Math.max(0, usage.contextWindow - usage.tokens);
22
25
  const windowId = currentWindowId(ctx);
23
26
  const { reminder, reserve, warning } = thresholdsFor(ctx);
24
27
  // The final warning owns the deep band: when it has fired (or is due now),
@@ -26,7 +29,7 @@ export function registerBudget(pi, isEnabled) {
26
29
  // the wipe, at a worse position. See warning.ts.
27
30
  if (remaining <= warning || hasWindowMessage(ctx, WARNING_TYPE))
28
31
  return undefined;
29
- if (remaining <= reminder && guidancePersistedInWindow !== windowId && !hasWindowMessage(ctx, GUIDANCE_TYPE)) {
32
+ if (remaining <= reminder && guidancePersistedInWindow !== windowId) {
30
33
  guidancePersistedInWindow = windowId;
31
34
  // Persist once per window — no transient copy. A transient bridge would
32
35
  // cover the crossing request, but history would record the reminder after
@@ -53,11 +56,10 @@ export function registerBudget(pi, isEnabled) {
53
56
  description: "Return estimated context tokens left before your memory is wiped; null when Pi cannot estimate usage.",
54
57
  parameters: Type.Object({}, { additionalProperties: false }),
55
58
  async execute(_id, _params, _signal, _update, ctx) {
56
- const usage = ctx.getContextUsage();
57
59
  // The countdown the model sees ends at the warning line (reserve + runway);
58
60
  // the runway below it is overdraft the model never sees. See protocol.ts.
59
- const remaining = usage?.tokens === null || usage === undefined ? null : Math.max(0, usage.contextWindow - usage.tokens - thresholdsFor(ctx).warning);
60
- return output({ remaining_tokens: remaining });
61
+ const remaining = remainingTokens(ctx);
62
+ return output({ remaining_tokens: remaining === null ? null : Math.max(0, remaining - thresholdsFor(ctx).warning) });
61
63
  },
62
64
  }));
63
65
  }
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
3
- import { homedir } from "node:os";
4
3
  import { dirname, join, resolve } from "node:path";
5
4
  import { acquireLock, failLock, releaseLock } from "./lock.js";
6
5
  import { materialGate, timeGate } from "./gates.js";
7
6
  import { loadPlaybook, runDreamer } from "./runner.js";
8
- import { applyManifest } from "./apply.js";
7
+ import { gitCommit } from "./git.js";
8
+ import { notesRoot } from "../notes/paths.js";
9
9
  function args(argv) { const out = {}; for (let i = 0; i < argv.length; i++) {
10
10
  const a = argv[i];
11
11
  if (a === "--force" || a === "--help")
@@ -27,10 +27,10 @@ function packageRoot() {
27
27
  export async function main(argv = process.argv.slice(2)) {
28
28
  const a = args(argv);
29
29
  if (a.help) {
30
- console.log("dream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <cmd>] [--dreamer-model <pattern>] [--playbook <path>]\nDefault dreamer: in-process pi SDK session with read-only tools; use a cheap model in production. Default playbook: <installed package root>/playbook.md; --playbook overrides it.");
30
+ console.log("dream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <model pattern>] [--playbook <path>]\nDefault dreamer: in-process pi SDK session with jailed file tools. Default playbook: <installed package root>/playbook.md; --playbook overrides it.");
31
31
  return 0;
32
32
  }
33
- const home = resolve(String(a["notes-home"] ?? process.env.PI_NOTES_HOME ?? join(homedir(), ".agents", "notes")));
33
+ const home = resolve(String(a["notes-home"] ?? notesRoot()));
34
34
  process.env.PI_NOTES_HOME = home;
35
35
  mkdirSync(home, { recursive: true });
36
36
  const lockPath = join(home, ".dream.lock");
@@ -58,15 +58,16 @@ export async function main(argv = process.argv.slice(2)) {
58
58
  }
59
59
  const stamp = new Date(lock.startedAt).toISOString().replace(/[:.]/g, "-");
60
60
  const reportPath = join(home, "dreams", `${stamp}.md`);
61
+ gitCommit(home, `baseline ${stamp}`);
61
62
  try {
62
63
  const defaultBook = join(packageRoot(), "playbook.md");
63
64
  const playbookPath = String(a.playbook ?? defaultBook);
64
65
  const playbook = loadPlaybook(playbookPath);
65
- const manifest = await runDreamer(playbook, home, { command: a.dreamer ? String(a.dreamer) : undefined, modelPattern: a["dreamer-model"] ? String(a["dreamer-model"]) : undefined });
66
- const ctx = { cwd: home, sessionManager: { getSessionId: () => "dream" } };
67
- const actions = applyManifest(ctx, home, stamp, manifest);
66
+ const result = await runDreamer(playbook, home, { modelPattern: a.dreamer ? String(a.dreamer) : undefined });
67
+ const writes = result.writes.length ? result.writes.map((w) => `- ${w.tool}: ${w.path}`).join("\n") : "- no changes";
68
68
  mkdirSync(join(home, "dreams"), { recursive: true });
69
- writeFileSync(reportPath, `# Dream ${stamp}\n\n${manifest.report}\n\n${actions.map((x) => `- ${x}`).join("\n")}\n`);
69
+ writeFileSync(reportPath, `# Dream ${stamp}\n\n${result.report}\n\n${writes}\n`);
70
+ gitCommit(home, `dream ${stamp}`);
70
71
  console.log(reportPath);
71
72
  return 0;
72
73
  }
@@ -1,5 +1,6 @@
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
  export function timeGate(lockPath, minHours, now = Date.now()) {
4
5
  if (!existsSync(lockPath))
5
6
  return { ok: true, reason: "time gate: no prior lock" };
@@ -7,7 +8,7 @@ export function timeGate(lockPath, minHours, now = Date.now()) {
7
8
  return age >= minHours * 3600000 ? { ok: true, reason: "time gate: stale" } : { ok: false, reason: "time gate: lock is too fresh" };
8
9
  }
9
10
  export function materialGate(home, lockMtime, minSessions) {
10
- const root = join(home, "pi", "session");
11
+ const root = sessionHomesRoot(home);
11
12
  let changed = 0;
12
13
  if (existsSync(root))
13
14
  for (const dir of readdirSync(root, { withFileTypes: true })) {
@@ -0,0 +1,28 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ /**
5
+ * Git audit layer for a dream run: one commit before (baseline) and one after
6
+ * (dream), so the human gate reviews `git show` instead of trusting a report,
7
+ * and rollback is `git revert`. This layer is a garnish, never load-bearing:
8
+ * every failure is logged and swallowed — a notes home without git, or a
9
+ * broken repo, still dreams. Nothing is committed when the tree is clean.
10
+ */
11
+ export function gitCommit(home, message) {
12
+ try {
13
+ if (!existsSync(join(home, ".git"))) {
14
+ execFileSync("git", ["init", "-q"], { cwd: home, stdio: "ignore" });
15
+ }
16
+ execFileSync("git", ["add", "-A"], { cwd: home, stdio: "ignore" });
17
+ try {
18
+ execFileSync("git", ["diff", "--cached", "--quiet"], { cwd: home, stdio: "ignore" });
19
+ return; // clean tree — no empty commit
20
+ }
21
+ catch { /* staged changes exist — fall through to commit */ }
22
+ execFileSync("git", ["commit", "-q", "-m", message], { cwd: home, stdio: "ignore" });
23
+ console.log(`git: committed "${message}"`);
24
+ }
25
+ catch (error) {
26
+ console.log(`git audit layer skipped: ${error instanceof Error ? error.message : error}`);
27
+ }
28
+ }
@@ -1,8 +1,76 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { readFileSync } from "node:fs";
3
- import { createAgentSession, ModelRuntime, resolveModelScopeWithDiagnostics, SessionManager } from "@earendil-works/pi-coding-agent";
4
- import { parseManifest } from "./manifest.js";
5
- export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "notes_read", "notes_list", "notes_search"];
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 } from "@earendil-works/pi-coding-agent";
5
+ import { contentText } from "../history.js";
6
+ export const DREAMER_TOOLS = ["read", "grep", "find", "ls", "write", "edit"];
7
+ function isOutside(notesHome, target) {
8
+ const fromHome = relative(notesHome, target);
9
+ return fromHome === ".." || fromHome.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(fromHome);
10
+ }
11
+ async function jailWritePath(notesHome, path) {
12
+ let realNotesHome;
13
+ try {
14
+ realNotesHome = await realpath(notesHome);
15
+ }
16
+ catch {
17
+ throw new Error(`write jail: cannot resolve notes home ${notesHome}`);
18
+ }
19
+ const target = resolve(realNotesHome, path);
20
+ const targetParent = dirname(target);
21
+ if (isOutside(realNotesHome, targetParent))
22
+ throw new Error(`write jail: ${path} is outside notes home ${notesHome}`);
23
+ // write creates parent directories itself. Create only after the lexical check, then
24
+ // canonicalize the parent so a symlink cannot lead the underlying tool out of home.
25
+ await mkdir(targetParent, { recursive: true });
26
+ let realTargetParent;
27
+ try {
28
+ realTargetParent = await realpath(targetParent);
29
+ }
30
+ catch {
31
+ throw new Error(`write jail: cannot resolve target parent in notes home ${notesHome}`);
32
+ }
33
+ if (isOutside(realNotesHome, realTargetParent))
34
+ throw new Error(`write jail: ${path} is outside notes home ${notesHome}`);
35
+ let targetStats;
36
+ try {
37
+ targetStats = await lstat(target);
38
+ }
39
+ catch (error) {
40
+ if (error.code !== "ENOENT")
41
+ throw new Error(`write jail: cannot inspect target in notes home ${notesHome}`);
42
+ }
43
+ if (targetStats?.isSymbolicLink()) {
44
+ let realTarget;
45
+ try {
46
+ realTarget = await realpath(target);
47
+ }
48
+ catch {
49
+ throw new Error(`write jail: cannot resolve target in notes home ${notesHome}`);
50
+ }
51
+ if (isOutside(realNotesHome, realTarget))
52
+ throw new Error(`write jail: ${path} is outside notes home ${notesHome}`);
53
+ }
54
+ if (targetStats && targetStats.nlink > 1)
55
+ throw new Error(`write jail: ${path} has hard links and is not allowed in notes home ${notesHome}`);
56
+ }
57
+ function jailToolDefinition(definition, notesHome) {
58
+ const execute = definition.execute;
59
+ return {
60
+ ...definition,
61
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
62
+ await jailWritePath(notesHome, params.path);
63
+ return execute(toolCallId, params, signal, onUpdate, ctx);
64
+ },
65
+ };
66
+ }
67
+ /** The only custom definitions in the dream session replace the two built-ins with jailed versions. */
68
+ export function dreamerWriteToolDefinitions(notesHome) {
69
+ return [
70
+ jailToolDefinition(createWriteToolDefinition(notesHome), notesHome),
71
+ jailToolDefinition(createEditToolDefinition(notesHome), notesHome),
72
+ ];
73
+ }
6
74
  export const defaultDreamerSessionFactory = async ({ cwd, modelPattern, tools }) => {
7
75
  let model;
8
76
  if (modelPattern) {
@@ -12,41 +80,32 @@ export const defaultDreamerSessionFactory = async ({ cwd, modelPattern, tools })
12
80
  if (!model)
13
81
  throw new Error(`dreamer model pattern "${modelPattern}" did not resolve to an available model`);
14
82
  }
15
- const { session } = await createAgentSession({ cwd, sessionManager: SessionManager.inMemory(cwd), tools, noTools: "all", model });
83
+ const { session } = await createAgentSession({ cwd, sessionManager: SessionManager.inMemory(cwd), tools, customTools: dreamerWriteToolDefinitions(cwd), noTools: "all", model, thinkingLevel: "off" });
16
84
  return session;
17
85
  };
18
- export function runExternalDreamer(command, playbook, cwd) {
19
- const result = spawnSync(command, { shell: true, cwd, input: playbook, encoding: "utf8" });
20
- if (result.error || result.status !== 0)
21
- throw new Error(`dreamer failed: ${result.error?.message ?? result.stderr ?? `exit ${result.status}`}`);
22
- return parseManifest(result.stdout);
23
- }
24
86
  export async function runDreamer(playbook, cwd, options = {}) {
25
- if (options.command)
26
- return runExternalDreamer(options.command, playbook, cwd);
27
- const session = await (options.sessionFactory ?? defaultDreamerSessionFactory)({ cwd, modelPattern: options.modelPattern, tools: READ_ONLY_TOOLS });
87
+ const session = await (options.sessionFactory ?? defaultDreamerSessionFactory)({ cwd, modelPattern: options.modelPattern, tools: DREAMER_TOOLS });
28
88
  let answer = "";
29
89
  let providerError;
90
+ const writes = [];
30
91
  const unsubscribe = session.subscribe((event) => {
92
+ const tool = event.toolName ?? event.tool?.name;
93
+ const args = event.args ?? event.arguments ?? event.tool?.arguments;
94
+ if ((tool === "write" || tool === "edit") && args && typeof args === "object" && typeof args.path === "string")
95
+ writes.push({ tool, path: args.path });
31
96
  if (event.type !== "message_end" || event.message?.role !== "assistant")
32
97
  return;
33
98
  if (event.message.stopReason === "error") {
34
99
  providerError = event.message.errorMessage ?? "unknown provider error";
35
100
  return;
36
101
  }
37
- const content = event.message.content;
38
- answer = typeof content === "string" ? content : Array.isArray(content) ? content.filter((part) => part.type === "text").map((part) => part.text).join("") : "";
102
+ answer = contentText(event.message.content);
39
103
  });
40
104
  try {
41
- await session.prompt(`${playbook}\n\nReturn exactly one JSON manifest matching this schema: { merge?, promote?, trash?, pending?, skillCandidates?, report }.`);
42
- try {
43
- return parseManifest(answer);
44
- }
45
- catch (error) {
46
- if (providerError)
47
- throw new Error(`dreamer failed: ${providerError}`);
48
- throw error;
49
- }
105
+ await session.prompt(playbook);
106
+ if (providerError)
107
+ throw new Error(`dreamer failed: ${providerError}`);
108
+ return { report: answer, writes };
50
109
  }
51
110
  finally {
52
111
  unsubscribe?.();
@@ -1,6 +1,6 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool } 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
  /**
@@ -57,7 +57,7 @@ export function registerHistoryTools(pi) {
57
57
  const badWindow = unknownWindowId(ctx, params);
58
58
  if (badWindow)
59
59
  return output({ error: badWindow.message, window_id: params.window_id, known_windows: badWindow.known });
60
- const items = filteredItems(ctx, params).map((item) => visibleItem(item, params.max_chars_per_item ?? 1200));
60
+ const items = filteredItems(ctx, params).map((item) => visibleItem(item, params.max_chars_per_item ?? HISTORY_PREVIEW_CHARS));
61
61
  return output(page(items, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
62
62
  },
63
63
  }));
@@ -65,7 +65,7 @@ export function registerHistoryTools(pi) {
65
65
  name: "history_read",
66
66
  label: "History read item",
67
67
  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).",
68
- 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 }),
68
+ 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 }),
69
69
  async execute(_id, params, _signal, _update, ctx) {
70
70
  const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
71
71
  if (!item)
@@ -76,7 +76,7 @@ export function registerHistoryTools(pi) {
76
76
  if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
77
77
  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 });
78
78
  }
79
- const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
79
+ const limit_chars = Math.min(params.limit_chars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS);
80
80
  return readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => {
81
81
  const { content, ...cursor } = window;
82
82
  return outputRaw(characterWindowHeader(`${item.windowId} · item ${item.itemId}`, window), content, { window_id: item.windowId, item_id: item.itemId, ...cursor, limit_chars });
@@ -98,7 +98,7 @@ export function registerHistoryTools(pi) {
98
98
  const queries = searchQueries(params.query);
99
99
  const matching = filteredItems(ctx, params)
100
100
  .filter((item) => queries.some((query) => item.content.includes(query)))
101
- .map((item) => ({ ...visibleItem(item, params.max_chars_per_item ?? 1200), match_offset_chars: earliestMatchOffsetChars(item.content, queries) }));
101
+ .map((item) => ({ ...visibleItem(item, params.max_chars_per_item ?? HISTORY_PREVIEW_CHARS), match_offset_chars: earliestMatchOffsetChars(item.content, queries) }));
102
102
  return output(page(matching, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
103
103
  },
104
104
  }));
@@ -1,11 +1,12 @@
1
1
  import { RESET_V2 } from "./protocol.js";
2
+ import { HISTORY_PREVIEW_CHARS } from "./tool-output.js";
2
3
  function isTextContent(part) {
3
4
  return typeof part === "object" && part !== null && part.type === "text" && typeof part.text === "string";
4
5
  }
5
- function contentText(content) {
6
+ export function contentText(content) {
6
7
  if (typeof content === "string")
7
8
  return content;
8
- return content.filter(isTextContent).map((part) => part.text).join("\n");
9
+ return Array.isArray(content) ? content.filter(isTextContent).map((part) => part.text).join("\n") : "";
9
10
  }
10
11
  function mapRole(role) {
11
12
  if (role === "user" || role === "assistant")
@@ -78,13 +79,17 @@ export function resetV2WindowId(details) {
78
79
  return candidate.windowId;
79
80
  }
80
81
  /** A compaction entry's window id: the extension-minted id for reset-v2, else Pi's entry id. */
81
- function windowIdOf(sessionId, entry) {
82
+ export function windowIdOf(sessionId, entry) {
82
83
  return resetV2WindowId(entry.details) ?? `pcw:${sessionId.slice(0, 8)}:${entry.id}`;
83
84
  }
85
+ /** Mint the durable identity of a session's root history window. */
86
+ export function rootWindowId(sessionId) {
87
+ return `pcw:${sessionId.slice(0, 8)}:root`;
88
+ }
84
89
  /** Build durable, on-demand history directly from every entry on the current session branch. */
85
90
  export function historyFromSession(ctx) {
86
91
  const sessionId = ctx.sessionManager.getSessionId();
87
- let window = { windowId: `pcw:${sessionId.slice(0, 8)}:root`, items: [] };
92
+ let window = { windowId: rootWindowId(sessionId), items: [] };
88
93
  const windows = [window];
89
94
  for (const entry of ctx.sessionManager.getBranch()) {
90
95
  if (entry.type === "compaction") {
@@ -128,7 +133,7 @@ export function historyFromSession(ctx) {
128
133
  }
129
134
  return windows;
130
135
  }
131
- export function visibleItem(item, maxChars = 1200) {
136
+ export function visibleItem(item, maxChars = HISTORY_PREVIEW_CHARS) {
132
137
  const characters = Array.from(item.content);
133
138
  const truncated = characters.length > maxChars;
134
139
  return {
@@ -206,5 +211,5 @@ export function currentWindowId(ctx) {
206
211
  if (entry?.type === "compaction")
207
212
  return windowIdOf(sessionId, entry);
208
213
  }
209
- return `pcw:${sessionId.slice(0, 8)}:root`;
214
+ return rootWindowId(sessionId);
210
215
  }
package/dist/src/index.js 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";
@@ -24,8 +24,7 @@ export default function piContext(pi) {
24
24
  // The root window has no compaction entry to carry the boot block, so persist
25
25
  // it once as a hidden custom message. Reset windows already carry theirs at
26
26
  // position 0 in the compaction summary, so a resumed session adds nothing.
27
- const sessionId = ctx.sessionManager.getSessionId();
28
- const rootId = `pcw:${sessionId.slice(0, 8)}:root`;
27
+ const rootId = rootWindowId(ctx.sessionManager.getSessionId());
29
28
  if (currentWindowId(ctx) !== rootId || hasWindowMessage(ctx, BOOT_TYPE))
30
29
  return;
31
30
  pi.sendMessage({ customType: BOOT_TYPE, content: bootBlock(ctx, rootId, undefined, false), display: false }, { triggerTurn: false });
@@ -49,7 +48,7 @@ export default function piContext(pi) {
49
48
  },
50
49
  });
51
50
  registerHistoryTools(pi);
52
- registerMemoryTools(pi);
51
+ registerNotesTools(pi);
53
52
  pi.registerTool(defineTool({
54
53
  name: "new_context",
55
54
  label: "New context",
@@ -70,16 +69,16 @@ export default function piContext(pi) {
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}`))
80
- minted = randomUUID().slice(0, 8);
81
- const windowId = `pcw:${session8}:${minted}`;
82
- 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)))
79
+ minted = { id: randomUUID().slice(0, 8) };
80
+ const windowId = windowIdOf(sessionId, minted);
81
+ const previousId = windows[windows.length - 1]?.windowId ?? rootWindowId(sessionId);
83
82
  // The reset marker stays as firstKeptEntryId; it no longer names the window.
84
83
  pi.appendEntry(RESET_MARKER_TYPE, { version: 1, reason: event.reason, requested: explicit });
85
84
  const markerId = ctx.sessionManager.getLeafId();
@@ -0,0 +1,31 @@
1
+ import { assertVirtualPath } from "./model.js";
2
+ const ADDRESS_FORMS = "legal prefixes are @project/ and @global/; bare names are the session home";
3
+ /**
4
+ * Decode the one public note address into its physical home and virtual path. This is a
5
+ * tool-boundary rule: replay paths keep using assertVirtualPath directly and are untouched.
6
+ */
7
+ export function assertAddress(value) {
8
+ if (typeof value !== "string")
9
+ throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
10
+ let scope = "session";
11
+ let path = value;
12
+ if (value.startsWith("@project/")) {
13
+ scope = "project";
14
+ path = value.slice("@project/".length);
15
+ }
16
+ else if (value.startsWith("@global/")) {
17
+ scope = "global";
18
+ path = value.slice("@global/".length);
19
+ }
20
+ else if (value.startsWith("@")) {
21
+ throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
22
+ }
23
+ if (path.includes("@"))
24
+ throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
25
+ assertVirtualPath(path);
26
+ return { scope, path };
27
+ }
28
+ /** Render a virtual path in its one unambiguous public address form. */
29
+ export function addressFor(scope, path) {
30
+ return scope === "session" ? path : `@${scope}/${path}`;
31
+ }
@@ -1,10 +1,10 @@
1
- import { localIso } from "../notes.js";
1
+ import { localIso } from "./model.js";
2
2
  const SCOPES = ["session", "project", "global"];
3
3
  const ORIGINS = ["user", "self", "external"];
4
4
  const STATUSES = ["active", "superseded", "pending", "archived"];
5
5
  const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"];
6
6
  /** Emission order, exactly the Design's key list. */
7
- const KNOWN_KEYS = ["scope", "origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"];
7
+ const KNOWN_KEYS = ["origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"];
8
8
  export function isScope(value) {
9
9
  return typeof value === "string" && SCOPES.includes(value);
10
10
  }
@@ -120,7 +120,9 @@ export function serializeNote(meta, body) {
120
120
  lines.push(`${key}: ${yamlScalar(value)}`);
121
121
  }
122
122
  for (const key of Object.keys(meta)) {
123
- if (KNOWN_KEYS.includes(key))
123
+ // scope is a legacy on-disk field. Store callers derive it from the home's location,
124
+ // but serialization intentionally drops it on the next write.
125
+ if (key === "scope" || KNOWN_KEYS.includes(key))
124
126
  continue;
125
127
  if (meta[key] === undefined)
126
128
  continue;
@@ -1,4 +1,4 @@
1
- import { MAX_NOTE_BYTES, NOTE_TYPE } from "./protocol.js";
1
+ import { MAX_NOTE_BYTES, NOTE_TYPE } from "../protocol.js";
2
2
  export function assertVirtualPath(value) {
3
3
  if (typeof value !== "string" || value.length === 0)
4
4
  throw new Error("path must be a non-empty virtual relative path");
@@ -7,6 +7,10 @@ export function notesRoot() {
7
7
  const override = process.env.PI_NOTES_HOME;
8
8
  return override && override.length > 0 ? resolve(override) : join(homedir(), ".agents", "notes");
9
9
  }
10
+ /** Absolute directory holding the per-session note homes. */
11
+ export function sessionHomesRoot(home = notesRoot()) {
12
+ return join(home, "pi", "session");
13
+ }
10
14
  /**
11
15
  * Absolute git root for `cwd`, walking upward until a directory holds a `.git` entry.
12
16
  * No git root yields undefined, which projectKey then replaces with the cwd itself.
@@ -39,7 +43,7 @@ export function scopeDir(scope, ctx) {
39
43
  return join(notesRoot(), "global");
40
44
  if (scope === "project")
41
45
  return join(notesRoot(), "project", projectKey(ctx.cwd));
42
- return join(notesRoot(), "pi", "session", sessionId(ctx));
46
+ return join(sessionHomesRoot(), sessionId(ctx));
43
47
  }
44
48
  /**
45
49
  * Notes are markdown files: a virtual path without an `.md` suffix gains one, an explicit