@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.
- package/README.md +88 -7
- package/dist/build-info.json +2 -2
- package/dist/extension.js +613 -367
- package/dist/src/context/boot.d.ts +24 -0
- package/dist/src/context/boot.js +33 -24
- package/dist/src/context/budget.d.ts +9 -0
- package/dist/src/context/budget.js +16 -12
- package/dist/src/context/context-window.d.ts +41 -0
- package/dist/src/context/context-window.js +16 -1
- package/dist/src/context/prompts.d.ts +20 -0
- package/dist/src/context/prompts.js +1 -1
- package/dist/src/context/reset-artifacts.d.ts +26 -0
- package/dist/src/context/reset-artifacts.js +18 -17
- package/dist/src/context/reset-lifecycle.d.ts +89 -0
- package/dist/src/context/reset-lifecycle.js +103 -75
- package/dist/src/context/runtime.d.ts +3 -0
- package/dist/src/context/runtime.js +53 -21
- package/dist/src/context/thresholds.d.ts +33 -0
- package/dist/src/context/thresholds.js +1 -1
- package/dist/src/dream/cli.d.ts +10 -0
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/dream/doctor.d.ts +2 -0
- package/dist/src/dream/doctor.js +6 -2
- package/dist/src/dream/gates.d.ts +10 -0
- package/dist/src/dream/git.d.ts +21 -0
- package/dist/src/dream/lock.d.ts +31 -0
- package/dist/src/dream/runner.d.ts +30 -0
- package/dist/src/dream/settings.d.ts +16 -0
- package/dist/src/history/history-tools.d.ts +2 -0
- package/dist/src/history/history.d.ts +57 -0
- package/dist/src/index.d.ts +39 -0
- package/dist/src/index.js +4 -4
- package/dist/src/notes/address.d.ts +26 -0
- package/dist/src/notes/address.js +8 -14
- package/dist/src/notes/constants.d.ts +3 -0
- package/dist/src/notes/constants.js +3 -0
- package/dist/src/notes/context.d.ts +10 -0
- package/dist/src/notes/context.js +33 -0
- package/dist/src/notes/frontmatter.d.ts +46 -0
- package/dist/src/notes/frontmatter.js +10 -5
- package/dist/src/notes/index.d.ts +4 -0
- package/dist/src/notes/index.js +2 -0
- package/dist/src/notes/paths.d.ts +21 -0
- package/dist/src/notes/paths.js +72 -76
- package/dist/src/notes/store.d.ts +94 -0
- package/dist/src/notes/store.js +298 -242
- package/dist/src/pi/notes/adapter.d.ts +12 -0
- package/dist/src/pi/notes/adapter.js +39 -0
- package/dist/src/pi/notes/session-replay.d.ts +16 -0
- package/dist/src/{notes → pi/notes}/session-replay.js +2 -2
- package/dist/src/pi/notes/snapshot.d.ts +33 -0
- package/dist/src/{notes/notes-snapshot.js → pi/notes/snapshot.js} +11 -3
- package/dist/src/pi/notes/tools.d.ts +2 -0
- package/dist/src/{notes → pi/notes}/tools.js +24 -21
- package/dist/src/protocol.d.ts +41 -0
- package/dist/src/protocol.js +4 -6
- package/dist/src/session-reader.d.ts +5 -0
- package/dist/src/settings.d.ts +6 -0
- package/dist/src/tool-output.d.ts +101 -0
- package/dist/src/tool-schema.d.ts +17 -0
- package/dist/test/agent-loop.test.d.ts +1 -0
- package/dist/test/agent-loop.test.js +309 -10
- package/dist/test/boot.integration.test.d.ts +1 -0
- package/dist/test/boot.integration.test.js +55 -29
- package/dist/test/budget-settings.integration.test.d.ts +1 -0
- package/dist/test/budget-settings.integration.test.js +8 -7
- package/dist/test/doctor.test.d.ts +1 -0
- package/dist/test/doctor.test.js +10 -2
- package/dist/test/dream-skill.test.d.ts +1 -0
- package/dist/test/dream-skill.test.js +69 -0
- package/dist/test/dream.test.d.ts +1 -0
- package/dist/test/helpers/extension.d.ts +115 -0
- package/dist/test/helpers/extension.js +6 -6
- package/dist/test/helpers/notes.d.ts +6 -0
- package/dist/test/helpers/notes.js +13 -0
- package/dist/test/history.integration.test.d.ts +1 -0
- package/dist/test/notes-library.test.d.ts +1 -0
- package/dist/test/notes-library.test.js +111 -0
- package/dist/test/notes.integration.test.d.ts +1 -0
- package/dist/test/notes.integration.test.js +22 -24
- package/dist/test/notes.test.d.ts +1 -0
- package/dist/test/notes.test.js +137 -7
- package/dist/test/reset-lifecycle.test.d.ts +1 -0
- package/dist/test/reset-lifecycle.test.js +142 -85
- package/docs/architecture.md +8 -8
- package/docs/reset-lifecycle.md +63 -79
- package/package.json +35 -2
- package/playbook.md +33 -32
- package/skills/dream/SKILL.md +12 -0
- package/src/context/boot.ts +44 -25
- package/src/context/budget.ts +19 -11
- package/src/context/context-window.ts +16 -1
- package/src/context/prompts.ts +2 -2
- package/src/context/reset-artifacts.ts +26 -24
- package/src/context/reset-lifecycle.ts +117 -111
- package/src/context/runtime.ts +50 -22
- package/src/context/thresholds.ts +1 -1
- package/src/dream/cli.ts +1 -1
- package/src/dream/doctor.ts +5 -2
- package/src/index.ts +4 -4
- package/src/notes/address.ts +9 -15
- package/src/notes/constants.ts +3 -0
- package/src/notes/context.ts +40 -0
- package/src/notes/frontmatter.ts +18 -12
- package/src/notes/index.ts +22 -0
- package/src/notes/paths.ts +64 -78
- package/src/notes/store.ts +308 -244
- package/src/pi/notes/adapter.ts +44 -0
- package/src/{notes → pi/notes}/session-replay.ts +3 -3
- package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
- package/src/{notes → pi/notes}/tools.ts +25 -23
- 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.
|
|
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 ?? "", /
|
|
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 {};
|
package/dist/test/doctor.test.js
CHANGED
|
@@ -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\
|
|
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/
|
|
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/
|
|
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
|
|
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 "
|
|
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\
|
|
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.
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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.
|
|
172
|
-
assert.
|
|
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 {};
|