@astrosheep/pi-context 0.25.2 → 0.26.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 (112) hide show
  1. package/README.md +88 -7
  2. package/dist/build-info.json +2 -2
  3. package/dist/extension.js +613 -367
  4. package/dist/src/context/boot.d.ts +24 -0
  5. package/dist/src/context/boot.js +33 -24
  6. package/dist/src/context/budget.d.ts +9 -0
  7. package/dist/src/context/budget.js +16 -12
  8. package/dist/src/context/context-window.d.ts +41 -0
  9. package/dist/src/context/context-window.js +16 -1
  10. package/dist/src/context/prompts.d.ts +20 -0
  11. package/dist/src/context/prompts.js +1 -1
  12. package/dist/src/context/reset-artifacts.d.ts +26 -0
  13. package/dist/src/context/reset-artifacts.js +18 -17
  14. package/dist/src/context/reset-lifecycle.d.ts +89 -0
  15. package/dist/src/context/reset-lifecycle.js +103 -75
  16. package/dist/src/context/runtime.d.ts +3 -0
  17. package/dist/src/context/runtime.js +53 -21
  18. package/dist/src/context/thresholds.d.ts +33 -0
  19. package/dist/src/context/thresholds.js +1 -1
  20. package/dist/src/dream/cli.d.ts +10 -0
  21. package/dist/src/dream/cli.js +1 -1
  22. package/dist/src/dream/doctor.d.ts +2 -0
  23. package/dist/src/dream/doctor.js +6 -2
  24. package/dist/src/dream/gates.d.ts +10 -0
  25. package/dist/src/dream/git.d.ts +21 -0
  26. package/dist/src/dream/lock.d.ts +31 -0
  27. package/dist/src/dream/runner.d.ts +30 -0
  28. package/dist/src/dream/settings.d.ts +16 -0
  29. package/dist/src/history/history-tools.d.ts +2 -0
  30. package/dist/src/history/history.d.ts +57 -0
  31. package/dist/src/index.d.ts +39 -0
  32. package/dist/src/index.js +4 -4
  33. package/dist/src/notes/address.d.ts +26 -0
  34. package/dist/src/notes/address.js +8 -14
  35. package/dist/src/notes/constants.d.ts +3 -0
  36. package/dist/src/notes/constants.js +3 -0
  37. package/dist/src/notes/context.d.ts +10 -0
  38. package/dist/src/notes/context.js +33 -0
  39. package/dist/src/notes/frontmatter.d.ts +46 -0
  40. package/dist/src/notes/frontmatter.js +10 -5
  41. package/dist/src/notes/index.d.ts +4 -0
  42. package/dist/src/notes/index.js +2 -0
  43. package/dist/src/notes/paths.d.ts +21 -0
  44. package/dist/src/notes/paths.js +72 -76
  45. package/dist/src/notes/store.d.ts +94 -0
  46. package/dist/src/notes/store.js +298 -242
  47. package/dist/src/pi/notes/adapter.d.ts +12 -0
  48. package/dist/src/pi/notes/adapter.js +39 -0
  49. package/dist/src/pi/notes/session-replay.d.ts +16 -0
  50. package/dist/src/{notes → pi/notes}/session-replay.js +2 -2
  51. package/dist/src/pi/notes/snapshot.d.ts +33 -0
  52. package/dist/src/{notes/notes-snapshot.js → pi/notes/snapshot.js} +11 -3
  53. package/dist/src/pi/notes/tools.d.ts +2 -0
  54. package/dist/src/{notes → pi/notes}/tools.js +24 -21
  55. package/dist/src/protocol.d.ts +41 -0
  56. package/dist/src/protocol.js +4 -6
  57. package/dist/src/session-reader.d.ts +5 -0
  58. package/dist/src/settings.d.ts +6 -0
  59. package/dist/src/tool-output.d.ts +101 -0
  60. package/dist/src/tool-schema.d.ts +17 -0
  61. package/dist/test/agent-loop.test.d.ts +1 -0
  62. package/dist/test/agent-loop.test.js +309 -10
  63. package/dist/test/boot.integration.test.d.ts +1 -0
  64. package/dist/test/boot.integration.test.js +55 -29
  65. package/dist/test/budget-settings.integration.test.d.ts +1 -0
  66. package/dist/test/budget-settings.integration.test.js +8 -7
  67. package/dist/test/doctor.test.d.ts +1 -0
  68. package/dist/test/doctor.test.js +10 -2
  69. package/dist/test/dream-skill.test.d.ts +1 -0
  70. package/dist/test/dream-skill.test.js +69 -0
  71. package/dist/test/dream.test.d.ts +1 -0
  72. package/dist/test/helpers/extension.d.ts +115 -0
  73. package/dist/test/helpers/extension.js +6 -6
  74. package/dist/test/helpers/notes.d.ts +6 -0
  75. package/dist/test/helpers/notes.js +13 -0
  76. package/dist/test/history.integration.test.d.ts +1 -0
  77. package/dist/test/notes-library.test.d.ts +1 -0
  78. package/dist/test/notes-library.test.js +111 -0
  79. package/dist/test/notes.integration.test.d.ts +1 -0
  80. package/dist/test/notes.integration.test.js +22 -24
  81. package/dist/test/notes.test.d.ts +1 -0
  82. package/dist/test/notes.test.js +137 -7
  83. package/dist/test/reset-lifecycle.test.d.ts +1 -0
  84. package/dist/test/reset-lifecycle.test.js +142 -85
  85. package/docs/architecture.md +8 -8
  86. package/docs/reset-lifecycle.md +63 -79
  87. package/package.json +35 -2
  88. package/playbook.md +33 -32
  89. package/skills/dream/SKILL.md +12 -0
  90. package/src/context/boot.ts +44 -25
  91. package/src/context/budget.ts +19 -11
  92. package/src/context/context-window.ts +16 -1
  93. package/src/context/prompts.ts +2 -2
  94. package/src/context/reset-artifacts.ts +26 -24
  95. package/src/context/reset-lifecycle.ts +117 -111
  96. package/src/context/runtime.ts +50 -22
  97. package/src/context/thresholds.ts +1 -1
  98. package/src/dream/cli.ts +1 -1
  99. package/src/dream/doctor.ts +5 -2
  100. package/src/index.ts +4 -4
  101. package/src/notes/address.ts +9 -15
  102. package/src/notes/constants.ts +3 -0
  103. package/src/notes/context.ts +40 -0
  104. package/src/notes/frontmatter.ts +18 -12
  105. package/src/notes/index.ts +22 -0
  106. package/src/notes/paths.ts +64 -78
  107. package/src/notes/store.ts +308 -244
  108. package/src/pi/notes/adapter.ts +44 -0
  109. package/src/{notes → pi/notes}/session-replay.ts +3 -3
  110. package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
  111. package/src/{notes → pi/notes}/tools.ts +25 -23
  112. package/src/protocol.ts +5 -6
@@ -31,7 +31,7 @@ test("the visible countdown ends at the warning line, clamps at zero, and preser
31
31
  });
32
32
  const sm = manager();
33
33
  const captured = makeExtension(sm);
34
- runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd, true));
34
+ await runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd, true));
35
35
  const readBudget = async (tokens, trusted = true) => {
36
36
  const ctx = context(sm, undefined, { tokens, contextWindow: 200_000, percent: tokens === null ? null : tokens / 2000 }, true, fixture.cwd, trusted);
37
37
  return resultJson(await call(captured, "get_context_remaining", {}, ctx)).remaining_tokens;
@@ -44,7 +44,7 @@ test("the visible countdown ends at the warning line, clamps at zero, and preser
44
44
  const absent = context(sm, undefined, undefined, true, fixture.cwd);
45
45
  assert.equal(resultJson(await call(captured, "get_context_remaining", {}, absent)).remaining_tokens, null);
46
46
  const untrusted = context(sm, undefined, { tokens: 72_563, contextWindow: 200_000, percent: 36.2815 }, true, fixture.cwd, false);
47
- runHandlers(captured, "session_start", {}, untrusted);
47
+ await runHandlers(captured, "session_start", {}, untrusted);
48
48
  assert.equal(await readBudget(72_563, false), 98_765, "session start reloads the global reserve when the project is untrusted");
49
49
  });
50
50
  test("absent pi-context key or margins reproduce the default reminder threshold at Pi's default reserve", async () => {
@@ -59,7 +59,7 @@ test("absent pi-context key or margins reproduce the default reminder threshold
59
59
  const captured = makeExtension(sm);
60
60
  // Thresholds are resolved once per session and cached; branch navigation clears the
61
61
  // cache without emitting a boot block, so the next read uses this fixture.
62
- runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd));
62
+ await runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd));
63
63
  const window = 200_000;
64
64
  const at = (remaining) => context(sm, undefined, { tokens: window - remaining, percent: 0, contextWindow: window }, true, fixture.cwd);
65
65
  const first = at(40_961);
@@ -81,7 +81,7 @@ test("project pi-context reminder margin and reserve override global per key", a
81
81
  // Project reserve wins: reminder = 50000 + 40000 (project margin).
82
82
  const sm = manager();
83
83
  const captured = makeExtension(sm);
84
- runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd));
84
+ await runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd));
85
85
  const window = 300_000;
86
86
  const at = (remaining) => context(sm, undefined, { tokens: window - remaining, percent: 0, contextWindow: window }, true, fixture.cwd);
87
87
  assert.equal(await runContextHook(captured, at(90_001)), undefined, "nothing injected above the project-derived reminder");
@@ -96,12 +96,13 @@ test("an invalid reminder margin degrades to its default with one warning and ne
96
96
  const sm = manager();
97
97
  const captured = makeExtension(sm);
98
98
  const ctx = context(sm, undefined, undefined, true, fixture.cwd);
99
- assert.doesNotThrow(() => runHandlers(captured, "session_start", { reason: "startup" }, ctx));
99
+ await assert.doesNotReject(() => runHandlers(captured, "session_start", { reason: "startup" }, ctx));
100
100
  const notices = noticesOf(ctx).filter((notice) => notice.type === "warning");
101
101
  assert.equal(notices.length, 1, "one warning for the offending key");
102
102
  assert.equal(notices[0]?.type, "warning");
103
103
  assert.match(notices[0]?.message ?? "", /reminderMarginTokens/);
104
- assert.match(notices[0]?.message ?? "", /24576/);
104
+ assert.match(notices[0]?.message ?? "", /default reminder margin/);
105
+ assert.equal(/\d+\s*k?\s*(remaining|tokens)/i.test(notices[0]?.message ?? ""), false, "UI notices do not expose token counts");
105
106
  const window = 200_000;
106
107
  const at = (remaining) => context(sm, undefined, { tokens: window - remaining, percent: 0, contextWindow: window }, true, fixture.cwd);
107
108
  // The degraded reminder is Pi's default reserve + default margin = 40960.
@@ -121,6 +122,6 @@ test("the warning supersedes the early reminder when usage jumps across both thr
121
122
  assert.deepEqual(warningResult?.messages.map((message) => message.customType), [internal.WARNING_TYPE]);
122
123
  await commitTurnEndBoundary(captured, sm, ctx);
123
124
  const reloaded = makeExtension(sm);
124
- runHandlers(reloaded, "context", {}, ctx);
125
+ await runHandlers(reloaded, "context", {}, ctx);
125
126
  assert.equal(reloaded.sent.length, 0, "persisted warning also suppresses a late reminder after reload");
126
127
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -1,11 +1,19 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { main } from "../src/dream/cli.js";
3
3
  import { doctor } from "../src/dream/doctor.js";
4
- import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import test from "node:test";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
- const note = "---\norigin: self\nstatus: active\nstale: false\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\nlast_accessed: 2026-01-01T00:00:00Z\naccess_count: 0\n---\n\n";
8
+ const note = "---\norigin: self\nstatus: active\nstale: false\ncreatedAt: 2026-01-01T00:00:00Z\nupdatedAt: 2026-01-01T00:00:00Z\nlastAccessed: 2026-01-01T00:00:00Z\naccessCount: 0\n---\n\n";
9
+ test("doctor identifies legacy metadata as requiring manual migration", (t) => {
10
+ const root = mkdtempSync(join(tmpdir(), "dream-doctor-legacy-"));
11
+ t.after(() => rmSync(root, { recursive: true, force: true }));
12
+ mkdirSync(join(root, "human"));
13
+ writeFileSync(join(root, "human/legacy.md"), note.replace("createdAt", "created_at"));
14
+ const issues = doctor(root);
15
+ assert.ok(issues.some((issue) => issue.includes("legacy metadata key created_at; manually migrate to createdAt")));
16
+ });
9
17
  test("doctor validates without repairing files or running the dreamer", async () => {
10
18
  const root = mkdtempSync(join(tmpdir(), "dream-doctor-test-"));
11
19
  mkdirSync(join(root, "human"));
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,69 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import test from "node:test";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { loadSkillsFromDir } from "@earendil-works/pi-coding-agent";
9
+ import { loadPlaybook } from "../src/dream/runner.js";
10
+ function packageRoot() {
11
+ let dir = dirname(fileURLToPath(import.meta.url));
12
+ while (!existsSync(join(dir, "package.json"))) {
13
+ const parent = dirname(dir);
14
+ if (parent === dir)
15
+ throw new Error("could not locate package root");
16
+ dir = parent;
17
+ }
18
+ return dir;
19
+ }
20
+ function discoverDreamSkill(skillsDir) {
21
+ const result = loadSkillsFromDir({ dir: skillsDir, source: "path" });
22
+ assert.deepEqual(result.diagnostics, []);
23
+ const skill = result.skills.find((candidate) => candidate.name === "dream");
24
+ assert.ok(skill, "Pi's public skill loader discovers dream");
25
+ assert.ok(skill.description.length > 0);
26
+ return skill;
27
+ }
28
+ test("dream skill is publicly discoverable and resolves the CLI's shared playbook", () => {
29
+ const root = packageRoot();
30
+ const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
31
+ assert.deepEqual(manifest.pi.skills, ["./skills"]);
32
+ assert.deepEqual(manifest.pi.extensions, ["./dist/extension.js"]);
33
+ assert.equal(manifest.bin.dream, "dist/src/dream/cli.js");
34
+ assert.ok(manifest.files.includes("skills"));
35
+ const skill = discoverDreamSkill(join(root, "skills"));
36
+ const skillPlaybook = resolve(skill.baseDir, "../../playbook.md");
37
+ const packagePlaybook = resolve(root, "playbook.md");
38
+ assert.equal(skillPlaybook, packagePlaybook);
39
+ const playbook = readFileSync(packagePlaybook, "utf8");
40
+ assert.equal(loadPlaybook(skillPlaybook), playbook);
41
+ });
42
+ test("npm tarball keeps the skill's relative playbook path portable after extraction", () => {
43
+ const root = packageRoot();
44
+ const fixture = mkdtempSync(join(tmpdir(), "dream-skill-pack-"));
45
+ try {
46
+ const packed = JSON.parse(execFileSync("npm", ["pack", "--offline", "--ignore-scripts", "--json", "--pack-destination", fixture], {
47
+ cwd: root,
48
+ encoding: "utf8",
49
+ stdio: ["ignore", "pipe", "pipe"],
50
+ }))[0];
51
+ const packedPaths = packed.files.map((file) => file.path);
52
+ assert.ok(packedPaths.includes("skills/dream/SKILL.md"));
53
+ assert.ok(packedPaths.includes("playbook.md"));
54
+ assert.ok(packedPaths.includes("dist/extension.js"));
55
+ assert.ok(packedPaths.includes("dist/src/dream/cli.js"));
56
+ const extracted = join(fixture, "extracted");
57
+ execFileSync("mkdir", ["-p", extracted]);
58
+ execFileSync("tar", ["-xzf", join(fixture, packed.filename), "-C", extracted]);
59
+ const installedRoot = join(extracted, "package");
60
+ const installedSkill = discoverDreamSkill(join(installedRoot, "skills"));
61
+ const referencedPlaybook = resolve(installedSkill.baseDir, "../../playbook.md");
62
+ assert.equal(referencedPlaybook, resolve(installedRoot, "playbook.md"));
63
+ assert.equal(loadPlaybook(referencedPlaybook), readFileSync(referencedPlaybook, "utf8"));
64
+ assert.equal(loadPlaybook(referencedPlaybook), readFileSync(join(root, "playbook.md"), "utf8"));
65
+ }
66
+ finally {
67
+ rmSync(fixture, { recursive: true, force: true });
68
+ }
69
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,115 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
+ import { type ContextUsage, type ExtensionAPI, type ExtensionContext, type RegisteredCommand, SessionManager, SettingsManager, type SessionBoundaryDraft, type ToolDefinition } from "@earendil-works/pi-coding-agent";
3
+ export type ExtensionTestEnvironment = {
4
+ readonly cwd: string;
5
+ readonly agentDir: string;
6
+ beforeEach(): void;
7
+ afterEach(): void;
8
+ newNotesRoot(): string;
9
+ dispose(): void;
10
+ };
11
+ /**
12
+ * Install the process-level settings and notes roots needed by one test file.
13
+ * The caller owns the lifecycle hooks; this helper never registers tests or hooks.
14
+ */
15
+ export declare function installExtensionTestEnvironment(prefix?: string): ExtensionTestEnvironment;
16
+ export type Notice = {
17
+ message: string;
18
+ type?: "info" | "warning" | "error";
19
+ };
20
+ export declare function writeJson(path: string, value: unknown): void;
21
+ export type SettingsFixture = {
22
+ cwd: string;
23
+ agentDir: string;
24
+ };
25
+ /**
26
+ * Materialize global (agentDir/settings.json) and project (cwd/.pi/settings.json)
27
+ * settings in temp directories, then read them back through the same public
28
+ * SettingsManager.create the extension uses. Never touches the real ~/.pi.
29
+ */
30
+ export declare function settingsFixture(options?: {
31
+ global?: Record<string, unknown>;
32
+ project?: Record<string, unknown>;
33
+ reserveTokens?: number;
34
+ }): SettingsFixture;
35
+ export type EventHandler = (event: never, ctx: ExtensionContext) => unknown;
36
+ type SendMessageArg = Parameters<ExtensionAPI["sendMessage"]>[0];
37
+ export type SentMessage = {
38
+ message: SendMessageArg;
39
+ options: {
40
+ triggerTurn?: boolean;
41
+ } | undefined;
42
+ };
43
+ export type Captured = {
44
+ tools: Map<string, ToolDefinition>;
45
+ handlers: Map<string, EventHandler[]>;
46
+ commands: Map<string, CommandOptions>;
47
+ sent: SentMessage[];
48
+ contextMessages: unknown[];
49
+ flags: string[];
50
+ };
51
+ export type CommandOptions = Omit<RegisteredCommand, "name" | "sourceInfo">;
52
+ export type CompactionHookResult = {
53
+ cancel: true;
54
+ } | {
55
+ compaction: {
56
+ summary: string;
57
+ firstKeptEntryId: string | null;
58
+ tokensBefore: number;
59
+ details?: unknown;
60
+ };
61
+ } | undefined;
62
+ /** TypeBox's TSchema does not expose `type`/`required` statically; read them structurally. */
63
+ export declare function objectSchema(tool: ToolDefinition | undefined): {
64
+ type?: string;
65
+ required?: string[];
66
+ } | undefined;
67
+ export declare function manager(persisted?: boolean): SessionManager;
68
+ export declare function makeExtension(sessionManager: SessionManager, settingsManager?: SettingsManager): Captured;
69
+ export declare function explicitBoot(ctx: ExtensionContext, currentWindowId: string, previousWindowId: string | undefined): Promise<string>;
70
+ export declare function context(sessionManager: SessionManager, compact?: ExtensionContext["compact"], usage?: ContextUsage, idle?: boolean, cwd?: string, projectTrusted?: boolean, model?: string | {
71
+ provider: string;
72
+ id: string;
73
+ }): ExtensionContext;
74
+ export declare function noticesOf(ctx: ExtensionContext): Notice[];
75
+ export declare function sentOf(captured: Captured, customType: string): SentMessage[];
76
+ export declare function call(captured: Captured, name: string, params: Record<string, unknown>, ctx: ExtensionContext): Promise<AgentToolResult<unknown>>;
77
+ export declare function resultJson<T>(result: AgentToolResult<unknown>): T;
78
+ /** Assert the delivered wire text fits the tool-output budget, header included for raw reads. */
79
+ export declare function assertWithinBudget(result: AgentToolResult<unknown>, message: string): void;
80
+ /** Decoded raw read response using the shared READ WINDOW grammar. */
81
+ export type ReadWindow = {
82
+ header: string;
83
+ content: string;
84
+ offset_chars: number;
85
+ total_chars: number;
86
+ next_offset_chars: number | null;
87
+ details: Record<string, unknown>;
88
+ };
89
+ /** Decode either raw read without including its shared metadata block in the payload. */
90
+ export declare function resultRead(result: AgentToolResult<unknown>): ReadWindow;
91
+ /**
92
+ * Assert a value is a local-time ISO 8601 string with an explicit numeric offset (never "Z")
93
+ * and that Date.parse restores the stored epoch milliseconds. No time zone is assumed.
94
+ */
95
+ export declare function assertLocalIso(value: unknown, epochMs: number, message: string): void;
96
+ /** Assert the text contains a well-formed local ISO timestamp and return it, without pinning surrounding wording. */
97
+ export declare function assertIsoTimestamp(text: string, message: string): string;
98
+ /** Assert `actual` is a middle-truncation of `original`: same head, same tail, strictly fewer characters. */
99
+ export declare function assertTruncationOf(original: string, actual: string): void;
100
+ export declare function runManualCompact(captured: Captured, ctx: ExtensionContext): Promise<CompactionHookResult>;
101
+ export declare function runHandlers(captured: Captured, name: string, event: unknown, ctx: ExtensionContext): Promise<void>;
102
+ export declare function runHandlersAsync(captured: Captured, name: string, event: unknown, ctx: ExtensionContext): Promise<unknown[]>;
103
+ export declare function completeRequestedCompaction(ctx: ExtensionContext): void;
104
+ export declare function runCommand(captured: Captured, name: string, args: string, ctx: ExtensionContext): Promise<Notice[]>;
105
+ export type ContextHookResult = {
106
+ messages: unknown[];
107
+ } | undefined;
108
+ export declare function runContextHook(captured: Captured, ctx: ExtensionContext, eventOverride?: Record<string, unknown>): Promise<ContextHookResult>;
109
+ export declare function runContextWithSystemHook(captured: Captured, ctx: ExtensionContext, messages: unknown[]): Promise<ContextHookResult>;
110
+ export declare function commitTurnEndBoundary(captured: Captured, sessionManager: SessionManager, ctx: ExtensionContext): Promise<{
111
+ entries: SessionBoundaryDraft[];
112
+ continue: boolean;
113
+ }>;
114
+ export declare function appendText(sessionManager: SessionManager, role: "user" | "assistant" | "toolResult", text: string, toolName?: string): string;
115
+ export {};
@@ -4,9 +4,9 @@ import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
6
6
  import piContext, { createPiContext } from "../../src/index.js";
7
- import { loadNotesSnapshot } from "../../src/notes/notes-snapshot.js";
7
+ import { loadNotesSnapshot } from "../../src/pi/notes/snapshot.js";
8
8
  import { renderBootBlock } from "../../src/context/prompts.js";
9
- import { agentSlug, modelSlug } from "../../src/notes/paths.js";
9
+ import { agentSlug, modelSlug } from "../../src/pi/notes/adapter.js";
10
10
  import { rootWindowId } from "../../src/context/context-window.js";
11
11
  import { TOOL_OUTPUT_MAX_BYTES } from "../../src/tool-output.js";
12
12
  let defaultCwd = "/private/tmp/pi-context-test-cwd";
@@ -130,14 +130,14 @@ export function makeExtension(sessionManager, settingsManager) {
130
130
  (settingsManager ? createPiContext({ settingsManager }) : piContext)(api);
131
131
  return captured;
132
132
  }
133
- export function explicitBoot(ctx, currentWindowId, previousWindowId) {
133
+ export async function explicitBoot(ctx, currentWindowId, previousWindowId) {
134
134
  return renderBootBlock({
135
135
  agentName: agentSlug(ctx),
136
136
  modelName: modelSlug(ctx),
137
137
  firstWindowId: rootWindowId(ctx.sessionManager.getSessionId()),
138
138
  currentWindowId,
139
139
  previousWindowId,
140
- notes: loadNotesSnapshot(ctx),
140
+ notes: await loadNotesSnapshot(ctx),
141
141
  });
142
142
  }
143
143
  export function context(sessionManager, compact, usage, idle = true, cwd = defaultCwd, projectTrusted = true, model) {
@@ -270,13 +270,13 @@ export async function runManualCompact(captured, ctx) {
270
270
  };
271
271
  return (await handler(event, ctx));
272
272
  }
273
- export function runHandlers(captured, name, event, ctx) {
273
+ export async function runHandlers(captured, name, event, ctx) {
274
274
  const isIdle = ctx.isIdle;
275
275
  if (name === "agent_settled")
276
276
  ctx.isIdle = () => true;
277
277
  try {
278
278
  for (const handler of captured.handlers.get(name) ?? [])
279
- handler(event, ctx);
279
+ await handler(event, ctx);
280
280
  }
281
281
  finally {
282
282
  ctx.isIdle = isIdle;
@@ -0,0 +1,6 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { type NoteRow, type NotesQuery, type Scope } from "../../src/notes/index.js";
3
+ /** Test-only conveniences for fixtures that still model notes through a live Pi context. */
4
+ export declare function physicalPath(scope: Scope, path: string, ctx: ExtensionContext, who?: string): string;
5
+ export declare function scopeDir(scope: Scope, ctx: ExtensionContext, who?: string): string;
6
+ export declare function listNotes(ctx: ExtensionContext, query?: NotesQuery): Promise<NoteRow[]>;
@@ -0,0 +1,13 @@
1
+ import { createNotesStore } from "../../src/notes/index.js";
2
+ import { notesContextFromPi } from "../../src/pi/notes/adapter.js";
3
+ import { physicalPath as corePhysicalPath, scopeDir as coreScopeDir } from "../../src/notes/paths.js";
4
+ /** Test-only conveniences for fixtures that still model notes through a live Pi context. */
5
+ export function physicalPath(scope, path, ctx, who) {
6
+ return corePhysicalPath(scope, path, notesContextFromPi(ctx), who);
7
+ }
8
+ export function scopeDir(scope, ctx, who) {
9
+ return coreScopeDir(scope, notesContextFromPi(ctx), who);
10
+ }
11
+ export function listNotes(ctx, query = {}) {
12
+ return createNotesStore(notesContextFromPi(ctx)).list(query);
13
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,111 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { createNotesStore, NoteError } from "../src/notes/index.js";
7
+ function fixture(t) {
8
+ const home = mkdtempSync(join(tmpdir(), "notes-library-"));
9
+ t.after(() => rmSync(home, { recursive: true, force: true }));
10
+ const context = { home, sessionId: "session-a", projectKey: "project-12345678", agent: "root", model: "test-model" };
11
+ return { home, context, notes: createNotesStore(context) };
12
+ }
13
+ test("standalone notes API persists camelCase metadata, edits, lists and searches full results", async (t) => {
14
+ const { home, notes } = fixture(t);
15
+ const first = await notes.write("checkpoint", "alpha\nneedle 😀", { origin: "user" });
16
+ assert.equal(first.meta.project, "project-12345678");
17
+ const read = (await notes.read("checkpoint.md"));
18
+ assert.equal(read.body, "alpha\nneedle 😀");
19
+ assert.equal(read.meta.origin, "user");
20
+ assert.equal(read.meta.accessCount, 1);
21
+ assert.equal(await notes.read("missing.md"), undefined);
22
+ const edited = await notes.edit("checkpoint", [{ oldText: "alpha", newText: "beta" }]);
23
+ assert.deepEqual(edited.change, { kind: "body", before: "alpha\nneedle 😀", after: "beta\nneedle 😀" });
24
+ assert.equal(edited.resolvedScope, read.resolvedScope);
25
+ assert.equal(edited.applied, 1);
26
+ const metadataEdit = await notes.edit("checkpoint", undefined, { stale: true });
27
+ assert.equal(metadataEdit.change.kind, "metadata");
28
+ assert.match(metadataEdit.change.before, /\nstale: false\n/);
29
+ assert.match(metadataEdit.change.after, /\nstale: true\n/);
30
+ assert.equal(metadataEdit.change.after.includes("needle"), false, "metadata-only diff excludes body");
31
+ assert.equal((await notes.list())[0]?.meta.stale, true);
32
+ const matches = await notes.search(["needle"]);
33
+ assert.equal(matches[0]?.matches[0]?.line, 2);
34
+ const text = (await notes.read("checkpoint")).text;
35
+ assert.equal(Array.from(text).slice(matches[0].matches[0].offsetChars).join(""), "needle 😀");
36
+ // An existing hand-written extra field and original ownership survive a new caller.
37
+ const path = join(home, "pi/session/session-a/checkpoint.md");
38
+ writeFileSync(path, readFileSync(path, "utf8").replace("\n---\n\n", "\nsourceWindow: pcw:test\nrecurrenceCount: 2\nrecurrenceWindows:\n - pcw:one\n - pcw:two\n__proto__: {\"sentinel\":true}\ncustom: preserved\n---\n\n"));
39
+ const moved = createNotesStore({ home, sessionId: "session-a", projectKey: "other-12345678", agent: "root", model: "test-model" });
40
+ const rewritten = await moved.write("checkpoint", "new body");
41
+ assert.equal(rewritten.meta.createdAt, first.meta.createdAt);
42
+ assert.equal(rewritten.meta.project, "project-12345678");
43
+ assert.equal(rewritten.meta.custom, "preserved");
44
+ assert.deepEqual(rewritten.meta["__proto__"], { sentinel: true });
45
+ assert.deepEqual((await moved.read("checkpoint"))?.meta["__proto__"], { sentinel: true }, "unknown __proto__ metadata survives persistence and reread");
46
+ assert.equal(rewritten.meta.sourceWindow, "pcw:test");
47
+ assert.equal(rewritten.meta.recurrenceCount, 2);
48
+ assert.deepEqual(rewritten.meta.recurrenceWindows, ["pcw:one", "pcw:two"]);
49
+ assert.equal(rewritten.meta.stale, false);
50
+ const combined = await moved.edit("checkpoint", [{ oldText: "new", newText: "final" }], { stale: true });
51
+ assert.equal(combined.change.kind, "file");
52
+ assert.match(combined.change.after, /\nstale: true\n/);
53
+ assert.match(combined.change.after, /final body$/);
54
+ assert.deepEqual((await moved.edit("checkpoint", undefined, { stale: true })).change, { kind: "none", before: "", after: "" });
55
+ });
56
+ test("same-file read/modify/write operations serialize across stores and markdown aliases", async (t) => {
57
+ const { home, context, notes } = fixture(t);
58
+ const other = createNotesStore(context);
59
+ await notes.write("shared", "alpha\nbeta");
60
+ const firstEdit = notes.edit("shared", [{ oldText: "alpha", newText: "A" }]);
61
+ const secondEdit = other.edit("shared.md", [{ oldText: "beta", newText: "B" }]);
62
+ await Promise.all([firstEdit, secondEdit]);
63
+ assert.equal((await notes.read("shared"))?.body, "A\nB", "edits through address aliases retain both changes");
64
+ const before = (await notes.read("shared")).meta.accessCount;
65
+ const [readA, readB] = await Promise.all([notes.read("shared.md"), other.read("shared")]);
66
+ assert.ok(readA && readB);
67
+ assert.equal((await notes.list({ scope: "session" }))[0]?.meta.accessCount, before + 2, "parallel reads do not lose access-counter updates");
68
+ const file = join(home, "pi/session/session-a/shared.md");
69
+ const stableEdits = [{ oldText: "A", newText: "first" }];
70
+ const operation = notes.edit("shared", stableEdits);
71
+ stableEdits[0].newText = "mutated after call";
72
+ await operation;
73
+ assert.equal((await notes.read("shared"))?.body, "first\nB", "edit arguments are snapshotted at method entry");
74
+ assert.equal(readFileSync(file, "utf8").includes("mutated after call"), false);
75
+ });
76
+ test("stores snapshot explicit identity and do not leak homes across instances", async (t) => {
77
+ const { home, context } = fixture(t);
78
+ const mutable = { ...context };
79
+ const notes = createNotesStore(mutable);
80
+ const other = createNotesStore({ ...context, home: join(home, "other"), agent: "other-agent", model: "other-model" });
81
+ assert.equal(existsSync(join(home, "other")), false, "construction does not create the supplied home");
82
+ mutable.agent = "changed-after-construction";
83
+ await notes.write("@self/private", "first");
84
+ await other.write("@self/private", "second");
85
+ assert.equal((await notes.read("@self/private"))?.body, "first");
86
+ assert.equal((await other.read("@self/private"))?.body, "second");
87
+ assert.equal((await notes.list())[0]?.address, "@agents/root/private.md");
88
+ assert.equal((await other.list())[0]?.address, "@agents/other-agent/private.md");
89
+ mkdirSync(join(home, "agents/visitor"), { recursive: true });
90
+ writeFileSync(join(home, "agents/visitor/hello.md"), "visiting");
91
+ assert.equal((await notes.read("@agents/visitor/hello"))?.body, "visiting");
92
+ await assert.rejects(() => notes.write("@agents/visitor/hello", "overwrite"), (error) => error instanceof NoteError && error.code === "invalid_scope");
93
+ });
94
+ test("invalid addressing and failed edits leave stored bytes untouched without poisoning the queue", async (t) => {
95
+ const { home, context, notes } = fixture(t);
96
+ assert.throws(() => createNotesStore({ ...context, sessionId: "../escape" }));
97
+ await assert.rejects(() => notes.write("@project/../escape", "bad"));
98
+ await assert.rejects(() => notes.list({ scope: "agent", who: "../escape" }));
99
+ // The typed API disallows this; JavaScript callers must still receive a refusal.
100
+ // @ts-expect-error who cannot accompany project scope
101
+ await assert.rejects(() => notes.list({ scope: "project", who: "root" }), (error) => error instanceof NoteError && error.code === "invalid_scope");
102
+ await notes.write("edit", "alpha\nbeta\nbeta");
103
+ const path = join(home, "pi/session/session-a/edit.md");
104
+ const before = readFileSync(path, "utf8");
105
+ await assert.rejects(() => notes.edit("edit", [{ oldText: "beta", newText: "B" }]), (error) => error instanceof NoteError && error.code === "ambiguous_edit" && error.lineNumbers?.join(",") === "2,3");
106
+ await assert.rejects(() => notes.edit("edit", [{ oldText: "alpha", newText: "A" }, { oldText: "absent", newText: "X" }]), (error) => error instanceof NoteError && error.code === "no_match" && error.editIndex === 1);
107
+ assert.equal(readFileSync(path, "utf8"), before);
108
+ await assert.rejects(() => notes.edit("absent", undefined, { stale: true }), (error) => error instanceof NoteError && error.code === "not_found");
109
+ await notes.edit("edit", [{ oldText: "alpha", newText: "A" }]);
110
+ assert.equal((await notes.read("edit"))?.body, "A\nbeta\nbeta", "failed operations do not poison later queue work");
111
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -2,11 +2,10 @@ import assert from "node:assert/strict";
2
2
  import { mkdirSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import test from "node:test";
5
- import { loadNotesSnapshot as loadNotesSnapshot } from "../src/notes/notes-snapshot.js";
5
+ import { loadNotesSnapshot } from "../src/pi/notes/snapshot.js";
6
6
  import { renderBootBlock } from "../src/context/prompts.js";
7
7
  import { localIso } from "../src/notes/frontmatter.js";
8
- import { physicalPath, scopeDir } from "../src/notes/paths.js";
9
- import { listNotes } from "../src/notes/store.js";
8
+ import { listNotes, physicalPath, scopeDir } from "./helpers/notes.js";
10
9
  import { TOOL_OUTPUT_MAX_BYTES } from "../src/tool-output.js";
11
10
  import { assertWithinBudget, call, context, explicitBoot, installExtensionTestEnvironment, makeExtension, manager, resultJson, resultRead, } from "./helpers/extension.js";
12
11
  const testEnvironment = installExtensionTestEnvironment("pi-context-integration");
@@ -20,7 +19,7 @@ test("notes_list is most-recently-updated first across merged scopes", async ()
20
19
  const put = (scope, path, updated) => {
21
20
  const file = physicalPath(scope, path, ctx);
22
21
  mkdirSync(dirname(file), { recursive: true });
23
- writeFileSync(file, `---\nscope: ${scope}\norigin: self\nstatus: active\nstale: false\ncreated_at: ${localIso(updated - 1000)}\nupdated_at: ${localIso(updated)}\nlast_accessed: ${localIso(updated)}\naccess_count: 0\n---\n\nbody`);
22
+ writeFileSync(file, `---\nscope: ${scope}\norigin: self\nstatus: active\nstale: false\ncreatedAt: ${localIso(updated - 1000)}\nupdatedAt: ${localIso(updated)}\nlastAccessed: ${localIso(updated)}\naccessCount: 0\n---\n\nbody`);
24
23
  };
25
24
  const base = 1_700_000_000_000;
26
25
  put("session", "b.md", base + 10);
@@ -56,7 +55,6 @@ test("notes are real files that persist across sessions and round-trip Unicode",
56
55
  // A single-segment * never crosses `/`, so a nested-only store matches nothing at the root.
57
56
  const rootOnly = resultJson(await call(restoredCaptured, "notes_list", { pattern: "*", scope: "human" }, restoredCtx));
58
57
  assert.equal(rootOnly.files.length, 0, "glob * stays within one segment");
59
- assert.equal(searched.files[0]?.created_at, listedFiles.files[0]?.created_at, "note tools agree on the timestamp format");
60
58
  assert.equal(searched.files[0]?.updated_at, listedFiles.files[0]?.updated_at);
61
59
  await assert.rejects(() => call(captured, "notes_write", { path: "../escape", content: "x" }, ctx), /unsupported component/);
62
60
  });
@@ -68,29 +66,29 @@ test("stale lifecycle: writes and metadata-only edits close and revive a note",
68
66
  // metadata-only: content unchanged, flag set, applied 0
69
67
  const markOnly = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: true }, ctx));
70
68
  assert.equal(markOnly.applied, 0);
71
- assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, true);
69
+ assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, true);
72
70
  assert.equal(resultRead(await call(captured, "notes_read", { path: "journal.md" }, ctx)).content.endsWith("log line"), true, "mark-only leaves content unchanged");
73
71
  // explicit revive
74
72
  const revived = resultJson(await call(captured, "notes_edit", { path: "journal.md", stale: false }, ctx));
75
- assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, false, "stale:false revives");
73
+ assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, false, "stale:false revives");
76
74
  // write+stale closure then plain write revival
77
75
  await call(captured, "notes_write", { path: "journal.md", content: "final", stale: true }, ctx);
78
- assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, true);
76
+ assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, true);
79
77
  await call(captured, "notes_write", { path: "journal.md", content: "reopened" }, ctx);
80
- assert.equal(listNotes(ctx, { scope: "session" })[0]?.meta.stale, false, "writing without stale revives");
78
+ assert.equal((await listNotes(ctx, { scope: "session" }))[0]?.meta.stale, false, "writing without stale revives");
81
79
  // metadata-only on a missing path is the typed not-found arm
82
80
  const missing = resultJson(await call(captured, "notes_edit", { path: "missing.md", stale: true }, ctx));
83
81
  assert.equal(missing.error, "note not found");
84
82
  });
85
- test("the filesystem notes loader treats an absent home as empty but surfaces a real directory read failure", () => {
83
+ test("the filesystem notes loader treats an absent home as empty but surfaces a real directory read failure", async () => {
86
84
  const sm = manager();
87
85
  const ctx = context(sm);
88
- assert.deepEqual(listNotes(ctx, { scope: "human" }), [], "a home that has not been created is empty");
86
+ assert.deepEqual(await listNotes(ctx, { scope: "human" }), [], "a home that has not been created is empty");
89
87
  const blockedHome = scopeDir("human", ctx);
90
88
  writeFileSync(blockedHome, "not a directory");
91
- assert.throws(() => listNotes(ctx, { scope: "human" }), (error) => error.code === "ENOTDIR", "a non-ENOENT directory failure is not swallowed as an empty home");
89
+ await assert.rejects(() => listNotes(ctx, { scope: "human" }), (error) => error.code === "ENOTDIR", "a non-ENOENT directory failure is not swallowed as an empty home");
92
90
  });
93
- test("boot note acquisition is one closed snapshot and isolates one or all failed homes", () => {
91
+ test("boot note acquisition is one closed snapshot and isolates one or all failed homes", async () => {
94
92
  const sm = manager();
95
93
  const ctx = context(sm);
96
94
  const updated = Date.now();
@@ -105,10 +103,10 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
105
103
  origin: "self",
106
104
  status: "active",
107
105
  stale: false,
108
- created_at: updated,
109
- updated_at: updated,
110
- last_accessed: updated,
111
- access_count: 0,
106
+ createdAt: updated,
107
+ updatedAt: updated,
108
+ lastAccessed: updated,
109
+ accessCount: 0,
112
110
  },
113
111
  });
114
112
  const rows = new Map([
@@ -119,7 +117,7 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
119
117
  ["model", [note("model", "model.md", "@models/default/model.md", "MODEL_POCKET_BODY")]],
120
118
  ]);
121
119
  const calls = new Map();
122
- const snapshot = loadNotesSnapshot(ctx, (_ctx, scope) => {
120
+ const snapshot = await loadNotesSnapshot(ctx, (_ctx, scope) => {
123
121
  calls.set(scope, (calls.get(scope) ?? 0) + 1);
124
122
  return rows.get(scope) ?? [];
125
123
  });
@@ -138,7 +136,7 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
138
136
  assert.ok(rendered.includes("session.md") && rendered.includes("@human/human.md"), "pocket rows come from the same snapshot");
139
137
  assert.equal(rendered.includes("SESSION_POCKET_BODY"), false, "pocket bodies stay excluded");
140
138
  const readFailure = (code) => Object.assign(new Error("scripted read failure"), { code });
141
- const oneFailed = loadNotesSnapshot(ctx, (_ctx, scope) => {
139
+ const oneFailed = await loadNotesSnapshot(ctx, (_ctx, scope) => {
142
140
  if (scope === "human")
143
141
  throw readFailure("EIO");
144
142
  return rows.get(scope) ?? [];
@@ -153,7 +151,7 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
153
151
  });
154
152
  assert.ok(oneFailedText.includes("PROJECT_MAP_BODY") && oneFailedText.includes("notes_list can retry after recovery"), "healthy homes and the recovery notice survive one failure");
155
153
  assert.equal(oneFailedText.includes("HUMAN_POCKET_BODY"), false, "the failed home's index is omitted");
156
- const allFailed = loadNotesSnapshot(ctx, (_ctx, scope) => {
154
+ const allFailed = await loadNotesSnapshot(ctx, (_ctx, scope) => {
157
155
  throw readFailure(scope === "session" ? "EACCES" : "EIO");
158
156
  });
159
157
  assert.equal(allFailed.unavailable.length, 5);
@@ -168,12 +166,12 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
168
166
  assert.ok(allFailedText.includes("pcw:test:root") && allFailedText.includes("pcw:test:next"), "identity survives an all-home failure");
169
167
  assert.ok(allFailedText.includes("Your memory resets whenever the context window fills"), "protocol survives an all-home failure");
170
168
  assert.equal(allFailedText.includes("scripted read failure"), false, "the model-facing notice does not expose OS/error details");
171
- assert.throws(() => loadNotesSnapshot(ctx, () => { throw new TypeError("programmer failure"); }), (error) => error instanceof TypeError, "unrelated TypeError construction failures remain visible");
172
- assert.throws(() => loadNotesSnapshot(ctx, () => { throw Object.assign(new Error("invalid argument"), { code: "ERR_INVALID_ARG_TYPE" }); }), (error) => error.code === "ERR_INVALID_ARG_TYPE", "Node ERR_* failures are not treated as filesystem errno failures");
169
+ await assert.rejects(() => loadNotesSnapshot(ctx, () => { throw new TypeError("programmer failure"); }), (error) => error instanceof TypeError, "unrelated TypeError construction failures remain visible");
170
+ await assert.rejects(() => loadNotesSnapshot(ctx, () => { throw Object.assign(new Error("invalid argument"), { code: "ERR_INVALID_ARG_TYPE" }); }), (error) => error.code === "ERR_INVALID_ARG_TYPE", "Node ERR_* failures are not treated as filesystem errno failures");
173
171
  });
174
- test("the boot block gives awake agents the notes-home file layout", () => {
172
+ test("the boot block gives awake agents the notes-home file layout", async () => {
175
173
  const session = manager();
176
- const rendered = explicitBoot(context(session), "pcw:test:root", undefined);
174
+ const rendered = await explicitBoot(context(session), "pcw:test:root", undefined);
177
175
  assert.equal(rendered.includes(process.env.PI_NOTES_HOME ?? ""), false, "the absolute notes home is never exposed");
178
176
  assert.match(rendered, /bare <vpath>.*@project\/<vpath>.*@human\/<vpath>/);
179
177
  });
@@ -0,0 +1 @@
1
+ export {};