@astrosheep/pi-context 0.19.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 (54) hide show
  1. package/dist/src/budget.js +65 -0
  2. package/dist/src/dream/cli.js +83 -0
  3. package/dist/src/dream/gates.js +22 -0
  4. package/dist/src/dream/git.js +28 -0
  5. package/dist/src/dream/lock.js +58 -0
  6. package/dist/src/dream/runner.js +115 -0
  7. package/dist/src/history-tools.js +105 -0
  8. package/dist/src/history.js +215 -0
  9. package/dist/src/index.js +98 -0
  10. package/dist/src/notes/address.js +31 -0
  11. package/dist/src/notes/frontmatter.js +136 -0
  12. package/dist/src/notes/model.js +101 -0
  13. package/dist/src/notes/paths.js +58 -0
  14. package/dist/src/notes/store.js +270 -0
  15. package/dist/src/notes/tools.js +153 -0
  16. package/dist/src/prompts.js +81 -0
  17. package/dist/src/protocol.js +56 -0
  18. package/dist/src/reset-lifecycle.js +101 -0
  19. package/dist/src/session-reader.js +1 -0
  20. package/dist/src/thresholds.js +75 -0
  21. package/dist/src/tool-output.js +175 -0
  22. package/dist/src/tool-schema.js +26 -0
  23. package/dist/src/warning.js +44 -0
  24. package/dist/test/agent-loop.test.js +214 -0
  25. package/dist/test/coherence.test.js +375 -0
  26. package/dist/test/dream.test.js +142 -0
  27. package/dist/test/history.test.js +26 -0
  28. package/dist/test/integration.test.js +1766 -0
  29. package/dist/test/notes.test.js +474 -0
  30. package/dist/test/pagination.property.test.js +476 -0
  31. package/dist/test/reset-lifecycle.test.js +199 -0
  32. package/package.json +13 -7
  33. package/playbook.md +32 -0
  34. package/src/budget.ts +11 -9
  35. package/src/dream/cli.ts +33 -0
  36. package/src/dream/gates.ts +20 -0
  37. package/src/dream/git.ts +27 -0
  38. package/src/dream/lock.ts +39 -0
  39. package/src/dream/runner.ts +111 -0
  40. package/src/history-tools.ts +5 -5
  41. package/src/history.ts +12 -7
  42. package/src/index.ts +13 -14
  43. package/src/notes/address.ts +33 -0
  44. package/src/{memory → notes}/frontmatter.ts +5 -3
  45. package/src/{notes.ts → notes/model.ts} +2 -2
  46. package/src/{memory → notes}/paths.ts +6 -1
  47. package/src/{memory → notes}/store.ts +62 -77
  48. package/src/notes/tools.ts +132 -0
  49. package/src/prompts.ts +31 -29
  50. package/src/protocol.ts +9 -5
  51. package/src/thresholds.ts +4 -1
  52. package/src/tool-output.ts +4 -1
  53. package/src/warning.ts +3 -3
  54. package/src/memory/tools.ts +0 -166
@@ -0,0 +1,215 @@
1
+ import { RESET_V2 } from "./protocol.js";
2
+ import { HISTORY_PREVIEW_CHARS } from "./tool-output.js";
3
+ function isTextContent(part) {
4
+ return typeof part === "object" && part !== null && part.type === "text" && typeof part.text === "string";
5
+ }
6
+ export function contentText(content) {
7
+ if (typeof content === "string")
8
+ return content;
9
+ return Array.isArray(content) ? content.filter(isTextContent).map((part) => part.text).join("\n") : "";
10
+ }
11
+ function mapRole(role) {
12
+ if (role === "user" || role === "assistant")
13
+ return role;
14
+ if (role === "toolResult" || role === "bashExecution")
15
+ return "tool";
16
+ if (role === "custom")
17
+ return "user";
18
+ if (role === "compactionSummary" || role === "branchSummary")
19
+ return "system";
20
+ return undefined;
21
+ }
22
+ /** This extension's own custom-entry namespace; entries under it are authored by pi-context. */
23
+ const PI_CONTEXT_ENTRY_PREFIX = "pi-context/";
24
+ function messageContent(message) {
25
+ switch (message.role) {
26
+ case "bashExecution":
27
+ // The command is as much the record as its output: without it the typed line is
28
+ // unsearchable. Mirrors the tool-call projection below.
29
+ return message.output ? `${message.command}\n${message.output}` : message.command;
30
+ case "branchSummary":
31
+ case "compactionSummary":
32
+ return message.summary;
33
+ default:
34
+ return contentText(message.content);
35
+ }
36
+ }
37
+ function toolInfo(message) {
38
+ if (message.role === "bashExecution") {
39
+ // A truncated bash run is only half the record without the on-disk path: surface both.
40
+ return { toolName: "bash", outputTruncated: message.truncated || undefined, fullOutputPath: message.truncated ? message.fullOutputPath : undefined };
41
+ }
42
+ if (message.role !== "toolResult")
43
+ return {};
44
+ return { toolName: message.toolName, toolError: message.isError === true ? true : undefined };
45
+ }
46
+ /**
47
+ * An assistant turn's tool calls, projected as their own items: calls wear their own role so the
48
+ * authoring turn's visible text (role "assistant") stays pure; what was invoked stays as
49
+ * searchable as what came back (role "tool"). Ids derive from the turn's entry id and stay
50
+ * opaque; history_read resolves them like any other item.
51
+ */
52
+ function toolCallItems(windowId, entry, message) {
53
+ if (message.role !== "assistant" || !Array.isArray(message.content))
54
+ return [];
55
+ const items = [];
56
+ let callIndex = 0;
57
+ for (const part of message.content) {
58
+ if (typeof part !== "object" || part === null || part.type !== "toolCall")
59
+ continue;
60
+ const call = part;
61
+ items.push({
62
+ windowId,
63
+ itemId: `${entry.id}#${callIndex++}`,
64
+ role: "tool_call",
65
+ content: JSON.stringify(call.arguments),
66
+ createdAt: entry.timestamp,
67
+ toolName: call.name,
68
+ });
69
+ }
70
+ return items;
71
+ }
72
+ /** The extension-owned window id baked onto a reset-v2 compaction entry, if present. */
73
+ export function resetV2WindowId(details) {
74
+ if (typeof details !== "object" || details === null)
75
+ return undefined;
76
+ const candidate = details;
77
+ if (candidate.piContext !== RESET_V2 || typeof candidate.windowId !== "string")
78
+ return undefined;
79
+ return candidate.windowId;
80
+ }
81
+ /** A compaction entry's window id: the extension-minted id for reset-v2, else Pi's entry id. */
82
+ export function windowIdOf(sessionId, entry) {
83
+ return resetV2WindowId(entry.details) ?? `pcw:${sessionId.slice(0, 8)}:${entry.id}`;
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
+ }
89
+ /** Build durable, on-demand history directly from every entry on the current session branch. */
90
+ export function historyFromSession(ctx) {
91
+ const sessionId = ctx.sessionManager.getSessionId();
92
+ let window = { windowId: rootWindowId(sessionId), items: [] };
93
+ const windows = [window];
94
+ for (const entry of ctx.sessionManager.getBranch()) {
95
+ if (entry.type === "compaction") {
96
+ window = { windowId: windowIdOf(sessionId, entry), createdAt: entry.timestamp, items: [] };
97
+ windows.push(window);
98
+ window.items.push({
99
+ windowId: window.windowId,
100
+ itemId: entry.id,
101
+ // A reset-v2 compaction is authored by this extension; a native Pi compaction is not.
102
+ role: resetV2WindowId(entry.details) === undefined ? "system" : "developer",
103
+ content: entry.summary,
104
+ createdAt: entry.timestamp,
105
+ });
106
+ continue;
107
+ }
108
+ if (entry.type === "message") {
109
+ const role = mapRole(entry.message.role);
110
+ if (!role)
111
+ continue;
112
+ window.items.push({
113
+ windowId: window.windowId,
114
+ itemId: entry.id,
115
+ role,
116
+ content: messageContent(entry.message),
117
+ createdAt: entry.timestamp,
118
+ ...toolInfo(entry.message),
119
+ });
120
+ window.items.push(...toolCallItems(window.windowId, entry, entry.message));
121
+ continue;
122
+ }
123
+ if (entry.type === "custom_message") {
124
+ window.items.push({
125
+ windowId: window.windowId,
126
+ itemId: entry.id,
127
+ // Only entries this extension wrote are its own; every foreign custom message stays a user turn.
128
+ role: entry.customType.startsWith(PI_CONTEXT_ENTRY_PREFIX) ? "developer" : "user",
129
+ content: contentText(entry.content),
130
+ createdAt: entry.timestamp,
131
+ });
132
+ }
133
+ }
134
+ return windows;
135
+ }
136
+ export function visibleItem(item, maxChars = HISTORY_PREVIEW_CHARS) {
137
+ const characters = Array.from(item.content);
138
+ const truncated = characters.length > maxChars;
139
+ return {
140
+ window_id: item.windowId,
141
+ item_id: item.itemId,
142
+ role: item.role,
143
+ tool_name: item.toolName ?? null,
144
+ // Surfaced only when set: a truncated bash run names its full-output path, and an
145
+ // errored tool run says so. Absent keys mean nothing special happened.
146
+ ...(item.outputTruncated ? { output_truncated: true, full_output_path: item.fullOutputPath ?? null } : {}),
147
+ ...(item.toolError ? { tool_error: true } : {}),
148
+ truncated,
149
+ total_chars: characters.length,
150
+ // A truncated payload is a plain prefix: no synthetic marker is appended, and
151
+ // `total_chars` names exactly how many code points were left out.
152
+ truncated_content: truncated ? characters.slice(0, maxChars).join("") : item.content,
153
+ };
154
+ }
155
+ export function allItems(ctx) {
156
+ return historyFromSession(ctx).flatMap((window) => window.items);
157
+ }
158
+ /**
159
+ * window_id must name a real window; anything else is a named error, not a silent empty page
160
+ * (a window that exists but has no matching items after the other filters stays a legal empty
161
+ * page). Returns the teaching message plus the known window ids so the error is self-healing.
162
+ */
163
+ export function unknownWindowId(ctx, params) {
164
+ if (typeof params.window_id !== "string")
165
+ return undefined;
166
+ const known = historyFromSession(ctx).map((window) => window.windowId);
167
+ return known.includes(params.window_id) ? undefined : { message: `unknown window_id "${params.window_id}"`, known };
168
+ }
169
+ /**
170
+ * A role×tool_name combination is vacuous — provably empty from the taxonomy alone, before
171
+ * any data is read — when tool_name is given alongside a role that never carries one. Only
172
+ * "tool_call" and "tool" items have a tool name. Returns the teaching error message, or
173
+ * undefined when the combination can match.
174
+ */
175
+ export function vacuousRoleToolCombo(params) {
176
+ if (typeof params.tool_name === "string" && typeof params.role === "string" && params.role !== "tool_call" && params.role !== "tool") {
177
+ return `tool_name is only set on "tool_call" and "tool" items; role "${params.role}" never carries one`;
178
+ }
179
+ return undefined;
180
+ }
181
+ export function filteredItems(ctx, params) {
182
+ let items = allItems(ctx);
183
+ if (typeof params.window_id === "string")
184
+ items = items.filter((item) => item.windowId === params.window_id);
185
+ if (typeof params.role === "string")
186
+ items = items.filter((item) => item.role === params.role);
187
+ if (typeof params.tool_name === "string")
188
+ items = items.filter((item) => item.toolName === params.tool_name);
189
+ if (params.recent_first !== false)
190
+ items.reverse();
191
+ return items;
192
+ }
193
+ /** Persisted messages in the active window, excluding earlier windows on this branch. */
194
+ export function hasWindowMessage(ctx, customType) {
195
+ const branch = ctx.sessionManager.getBranch();
196
+ for (let i = branch.length - 1; i >= 0; i--) {
197
+ const entry = branch[i];
198
+ if (entry.type === "compaction")
199
+ break;
200
+ if (entry.type === "custom_message" && entry.customType === customType)
201
+ return true;
202
+ }
203
+ return false;
204
+ }
205
+ /** Cheap current-window lookup: scan the branch tail for the latest compaction entry. */
206
+ export function currentWindowId(ctx) {
207
+ const sessionId = ctx.sessionManager.getSessionId();
208
+ const branch = ctx.sessionManager.getBranch();
209
+ for (let i = branch.length - 1; i >= 0; i--) {
210
+ const entry = branch[i];
211
+ if (entry?.type === "compaction")
212
+ return windowIdOf(sessionId, entry);
213
+ }
214
+ return rootWindowId(sessionId);
215
+ }
@@ -0,0 +1,98 @@
1
+ import { registerHistoryTools } from "./history-tools.js";
2
+ import { registerNotesTools } from "./notes/tools.js";
3
+ import { registerBudget } from "./budget.js";
4
+ import { output } from "./tool-output.js";
5
+ import { deriveThresholds, mergePiContextSettings } from "./thresholds.js";
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, rootWindowId, windowIdOf } from "./history.js";
8
+ import { assertVirtualPath } from "./notes/model.js";
9
+ import { bootBlock } from "./prompts.js";
10
+ export { historyFromSession } from "./history.js";
11
+ export { notesFromSession } from "./notes/model.js";
12
+ import { registerResetLifecycle } from "./reset-lifecycle.js";
13
+ import { registerWarning } from "./warning.js";
14
+ import { randomUUID } from "node:crypto";
15
+ import { Type } from "@earendil-works/pi-ai";
16
+ import { defineTool } from "@earendil-works/pi-coding-agent";
17
+ export default function piContext(pi) {
18
+ let enabled = true;
19
+ registerBudget(pi, () => enabled);
20
+ registerWarning(pi, () => enabled);
21
+ pi.on("session_start", (_event, ctx) => {
22
+ if (!enabled)
23
+ return;
24
+ // The root window has no compaction entry to carry the boot block, so persist
25
+ // it once as a hidden custom message. Reset windows already carry theirs at
26
+ // position 0 in the compaction summary, so a resumed session adds nothing.
27
+ const rootId = rootWindowId(ctx.sessionManager.getSessionId());
28
+ if (currentWindowId(ctx) !== rootId || hasWindowMessage(ctx, BOOT_TYPE))
29
+ return;
30
+ pi.sendMessage({ customType: BOOT_TYPE, content: bootBlock(ctx, rootId, undefined, false), display: false }, { triggerTurn: false });
31
+ });
32
+ pi.registerCommand("pi-context", {
33
+ description: "Toggle pi-context: context_window boot block, low-budget guidance, and reset-style compaction",
34
+ getArgumentCompletions: (prefix) => ["on", "off"].filter((a) => a.startsWith(prefix)).map((a) => ({ value: a, label: a })),
35
+ handler: async (args, cmdCtx) => {
36
+ const arg = args.trim().toLowerCase();
37
+ if (arg === "on")
38
+ enabled = true;
39
+ else if (arg === "off") {
40
+ enabled = false;
41
+ resets.clear();
42
+ }
43
+ else if (arg !== "") {
44
+ cmdCtx.ui.notify("Usage: /pi-context [on|off]", "error");
45
+ return;
46
+ }
47
+ cmdCtx.ui.notify(`pi-context: ${enabled ? "on" : "off"}`, "info");
48
+ },
49
+ });
50
+ registerHistoryTools(pi);
51
+ registerNotesTools(pi);
52
+ pi.registerTool(defineTool({
53
+ name: "new_context",
54
+ label: "New context",
55
+ description: "Clear your mind and start a new context window. Your session, notes, and history survive.",
56
+ parameters: Type.Object({}, { additionalProperties: false }),
57
+ async execute() {
58
+ if (!enabled)
59
+ return output({ error: "pi-context is off (/pi-context on to enable)" });
60
+ return output({ status: resets.request() }, undefined, true);
61
+ },
62
+ }));
63
+ const resets = registerResetLifecycle(pi, {
64
+ isEnabled: () => enabled,
65
+ continuation: { customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
66
+ isCurrentReset: (entryId, ctx) => {
67
+ const entry = ctx.sessionManager.getEntry(entryId);
68
+ return entry?.type === "compaction" && resetV2WindowId(entry.details) === currentWindowId(ctx);
69
+ },
70
+ onReset: (entryId) => pi.appendEntry(STATE_TYPE, { version: 1, lastResetEntryId: entryId }),
71
+ buildReset: (event, ctx, explicit) => {
72
+ const sessionId = ctx.sessionManager.getSessionId();
73
+ // Window IDs are independent of Pi entry IDs. Avoid reusing a window
74
+ // identity already present on this branch.
75
+ const windows = historyFromSession(ctx);
76
+ const usedIds = new Set(windows.map((window) => window.windowId));
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);
82
+ // The reset marker stays as firstKeptEntryId; it no longer names the window.
83
+ pi.appendEntry(RESET_MARKER_TYPE, { version: 1, reason: event.reason, requested: explicit });
84
+ const markerId = ctx.sessionManager.getLeafId();
85
+ if (!markerId)
86
+ return { cancel: true };
87
+ return {
88
+ compaction: {
89
+ summary: bootBlock(ctx, windowId, previousId, true),
90
+ firstKeptEntryId: markerId,
91
+ tokensBefore: event.preparation.tokensBefore,
92
+ details: { piContext: RESET_V2, windowId },
93
+ },
94
+ };
95
+ },
96
+ });
97
+ }
98
+ export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, WARNING_PROMPT, WARNING_RUNWAY_TOKENS, RESET_MARKER_TYPE, RESET_SUMMARY, CONTINUATION, 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, deriveThresholds, mergePiContextSettings, assertVirtualPath };
@@ -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
+ }
@@ -0,0 +1,136 @@
1
+ import { localIso } from "./model.js";
2
+ const SCOPES = ["session", "project", "global"];
3
+ const ORIGINS = ["user", "self", "external"];
4
+ const STATUSES = ["active", "superseded", "pending", "archived"];
5
+ const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"];
6
+ /** Emission order, exactly the Design's key list. */
7
+ const KNOWN_KEYS = ["origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"];
8
+ export function isScope(value) {
9
+ return typeof value === "string" && SCOPES.includes(value);
10
+ }
11
+ export function isOrigin(value) {
12
+ return typeof value === "string" && ORIGINS.includes(value);
13
+ }
14
+ function isStatus(value) {
15
+ return typeof value === "string" && STATUSES.includes(value);
16
+ }
17
+ function toEpoch(value, fallback) {
18
+ if (typeof value === "number" && Number.isFinite(value))
19
+ return value;
20
+ if (typeof value === "string") {
21
+ const parsed = Date.parse(value);
22
+ if (Number.isFinite(parsed))
23
+ return parsed;
24
+ }
25
+ return fallback;
26
+ }
27
+ /** A frontmatter scalar encoded as JSON, falling back to a plain string for hand-written YAML. */
28
+ function parseScalar(text) {
29
+ const trimmed = text.trim();
30
+ if (trimmed === "")
31
+ return "";
32
+ try {
33
+ return JSON.parse(trimmed);
34
+ }
35
+ catch {
36
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")))
37
+ return trimmed.slice(1, -1);
38
+ return trimmed;
39
+ }
40
+ }
41
+ /**
42
+ * Split raw file text into its frontmatter fields and body. When the file does not open with
43
+ * a closed `---` block, the whole text is body and the field map is empty.
44
+ */
45
+ function parseFrontmatter(raw) {
46
+ const stripped = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
47
+ const lines = stripped.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
48
+ if (lines[0]?.trim() !== "---")
49
+ return { fields: {}, body: raw };
50
+ let close = -1;
51
+ for (let index = 1; index < lines.length; index++) {
52
+ if (lines[index]?.trim() === "---") {
53
+ close = index;
54
+ break;
55
+ }
56
+ }
57
+ if (close === -1)
58
+ return { fields: {}, body: raw };
59
+ const fields = {};
60
+ for (let index = 1; index < close; index++) {
61
+ const line = lines[index];
62
+ const match = /^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/.exec(line);
63
+ if (!match)
64
+ continue;
65
+ const key = match[1];
66
+ const rest = match[2];
67
+ if (rest.trim() === "") {
68
+ // A bare key opens a block sequence of `- item` lines, the only multi-line shape we parse.
69
+ const items = [];
70
+ while (index + 1 < close && /^\s*-\s+/.test(lines[index + 1])) {
71
+ index++;
72
+ items.push(parseScalar(lines[index].replace(/^\s*-\s+/, "")));
73
+ }
74
+ fields[key] = items;
75
+ }
76
+ else {
77
+ fields[key] = parseScalar(rest);
78
+ }
79
+ }
80
+ const rest = lines.slice(close + 1);
81
+ if (rest[0] === "")
82
+ rest.shift();
83
+ return { fields, body: rest.join("\n") };
84
+ }
85
+ /**
86
+ * Parse a note file. Missing known keys take the Design defaults (status active, stale false,
87
+ * access_count 0, timestamps now); unknown keys are carried through untouched.
88
+ */
89
+ export function parseNote(raw, now = Date.now()) {
90
+ const { fields, body } = parseFrontmatter(raw);
91
+ const meta = { ...fields };
92
+ meta.scope = isScope(meta.scope) ? meta.scope : "global";
93
+ meta.origin = isOrigin(meta.origin) ? meta.origin : "self";
94
+ meta.status = isStatus(meta.status) ? meta.status : "active";
95
+ meta.stale = meta.stale === true;
96
+ for (const key of TIMESTAMP_KEYS)
97
+ meta[key] = toEpoch(meta[key], now);
98
+ meta.access_count = typeof meta.access_count === "number" && Number.isFinite(meta.access_count) ? meta.access_count : 0;
99
+ return { meta: meta, body };
100
+ }
101
+ /** Emit a YAML scalar: bare for safe strings and JSON literals, JSON-quoted otherwise. */
102
+ function yamlScalar(value) {
103
+ if (typeof value === "string") {
104
+ const reserved = new Set(["true", "false", "null", "yes", "no", "on", "off", "~"]);
105
+ if (/^[A-Za-z0-9_.+\-:/]+$/.test(value) && !reserved.has(value.toLowerCase()))
106
+ return value;
107
+ }
108
+ return JSON.stringify(value);
109
+ }
110
+ /** Serialize frontmatter + blank line + body. Known keys emit in Design order, extras after. */
111
+ export function serializeNote(meta, body) {
112
+ const lines = [];
113
+ for (const key of KNOWN_KEYS) {
114
+ const value = meta[key];
115
+ if (value === undefined)
116
+ continue;
117
+ if (TIMESTAMP_KEYS.includes(key))
118
+ lines.push(`${key}: ${yamlScalar(localIso(value))}`);
119
+ else
120
+ lines.push(`${key}: ${yamlScalar(value)}`);
121
+ }
122
+ for (const key of Object.keys(meta)) {
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))
126
+ continue;
127
+ if (meta[key] === undefined)
128
+ continue;
129
+ lines.push(`${key}: ${yamlScalar(meta[key])}`);
130
+ }
131
+ return `---\n${lines.join("\n")}\n---\n\n${body}`;
132
+ }
133
+ /** Strip a leading frontmatter block from user content, so a note body is pure content. */
134
+ export function stripLeadingFrontmatter(content) {
135
+ return parseFrontmatter(content).body;
136
+ }
@@ -0,0 +1,101 @@
1
+ import { MAX_NOTE_BYTES, NOTE_TYPE } from "../protocol.js";
2
+ export function assertVirtualPath(value) {
3
+ if (typeof value !== "string" || value.length === 0)
4
+ throw new Error("path must be a non-empty virtual relative path");
5
+ if (value.includes("\0") || value.includes("\\") || value.startsWith("/"))
6
+ throw new Error("path must be a safe virtual relative path");
7
+ const parts = value.split("/");
8
+ if (parts.some((part) => part.length === 0 || part === "." || part === ".."))
9
+ throw new Error("path contains an unsupported component");
10
+ return value;
11
+ }
12
+ /**
13
+ * Minimal glob over virtual note paths: `*` matches any run within a segment (never
14
+ * `/`), `**` matches any run across segments (a leading double-star followed by a
15
+ * slash also matches zero segments, so it covers the root too), `?` matches exactly
16
+ * one non-`/` character. Everything else is literal and the match is anchored to the
17
+ * whole path.
18
+ */
19
+ export function globToRegExp(pattern) {
20
+ let source = "^";
21
+ for (let index = 0; index < pattern.length; index++) {
22
+ const char = pattern[index];
23
+ if (char === "*") {
24
+ if (pattern[index + 1] === "*") {
25
+ const followedBySlash = pattern[index + 2] === "/";
26
+ source += followedBySlash ? "(?:[^]*\\/)?" : "[^]*";
27
+ index += followedBySlash ? 2 : 1;
28
+ }
29
+ else {
30
+ source += "[^/]*";
31
+ }
32
+ }
33
+ else {
34
+ source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
35
+ }
36
+ }
37
+ return new RegExp(`${source}$`);
38
+ }
39
+ /** Glob patterns are not virtual paths (`*` is legal), so they get their own guard: no NUL, no backslashes. */
40
+ export function assertGlobPattern(value) {
41
+ if (value === undefined || value === null || value === "")
42
+ return undefined;
43
+ if (typeof value !== "string")
44
+ throw new Error("glob pattern must be a string");
45
+ if (value.includes("\0") || value.includes("\\"))
46
+ throw new Error("glob pattern must not contain NUL or backslashes");
47
+ return value;
48
+ }
49
+ /** Replays only pi-context note operations from session custom entries. */
50
+ function isNoteOperation(data) {
51
+ if (typeof data !== "object" || data === null)
52
+ return false;
53
+ const op = data;
54
+ return ((op.op === "write" || op.op === "append") &&
55
+ typeof op.path === "string" &&
56
+ (op.text === undefined || typeof op.text === "string") &&
57
+ (op.stale === undefined || typeof op.stale === "boolean") &&
58
+ (op.text !== undefined || op.stale !== undefined) &&
59
+ typeof op.createdAt === "number" && Number.isFinite(new Date(op.createdAt).getTime()) &&
60
+ typeof op.updatedAt === "number" && Number.isFinite(new Date(op.updatedAt).getTime()));
61
+ }
62
+ export function notesFromSession(ctx) {
63
+ const files = new Map();
64
+ for (const entry of ctx.sessionManager.getBranch()) {
65
+ if (entry.type !== "custom" || entry.customType !== NOTE_TYPE || !isNoteOperation(entry.data))
66
+ continue;
67
+ const op = entry.data;
68
+ try {
69
+ assertVirtualPath(op.path);
70
+ }
71
+ catch {
72
+ continue;
73
+ }
74
+ const previous = files.get(op.path);
75
+ const hasText = op.text !== undefined;
76
+ // A mark-only operation needs an existing note to change; without one it is a no-op.
77
+ if (!hasText && !previous)
78
+ continue;
79
+ const text = hasText ? (op.op === "append" ? `${previous?.text ?? ""}${op.text}` : op.text) : previous.text;
80
+ if (Buffer.byteLength(text, "utf8") > MAX_NOTE_BYTES)
81
+ continue;
82
+ // Carrying text revives unless the call also marks stale; a mark-only op keeps its flag.
83
+ const stale = hasText ? op.stale ?? false : op.stale ?? previous.stale;
84
+ files.set(op.path, { text, stale, createdAt: previous?.createdAt ?? op.createdAt, updatedAt: op.updatedAt });
85
+ }
86
+ return files;
87
+ }
88
+ const pad2 = (value) => String(value).padStart(2, "0");
89
+ /**
90
+ * Format epoch milliseconds as an ISO 8601 string in the host's local time zone with an
91
+ * explicit numeric offset (e.g. 2026-09-15T17:31:45.392+08:00). A UTC host renders
92
+ * "+00:00"; the "Z" designator is never used, and Date.parse round-trips the value.
93
+ */
94
+ export function localIso(epochMs) {
95
+ const date = new Date(epochMs);
96
+ const offsetMinutes = -date.getTimezoneOffset();
97
+ const absOffset = Math.abs(offsetMinutes);
98
+ const offset = `${offsetMinutes < 0 ? "-" : "+"}${pad2(Math.floor(absOffset / 60))}:${pad2(absOffset % 60)}`;
99
+ const wallClock = `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, "0")}`;
100
+ return `${wallClock}${offset}`;
101
+ }
@@ -0,0 +1,58 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ /** Physical home of the on-disk note store: $PI_NOTES_HOME or ~/.agents/notes. */
6
+ export function notesRoot() {
7
+ const override = process.env.PI_NOTES_HOME;
8
+ return override && override.length > 0 ? resolve(override) : join(homedir(), ".agents", "notes");
9
+ }
10
+ /** Absolute directory holding the per-session note homes. */
11
+ export function sessionHomesRoot(home = notesRoot()) {
12
+ return join(home, "pi", "session");
13
+ }
14
+ /**
15
+ * Absolute git root for `cwd`, walking upward until a directory holds a `.git` entry.
16
+ * No git root yields undefined, which projectKey then replaces with the cwd itself.
17
+ */
18
+ function gitRoot(cwd) {
19
+ let dir = resolve(cwd);
20
+ for (;;) {
21
+ if (existsSync(join(dir, ".git")))
22
+ return dir;
23
+ const parent = dirname(dir);
24
+ if (parent === dir)
25
+ return undefined;
26
+ dir = parent;
27
+ }
28
+ }
29
+ /** `<basename(absGitRoot)-sha1(absGitRoot)[:8]>`, or the same formula over cwd with no git root. */
30
+ export function projectKey(cwd) {
31
+ const absolute = resolve(cwd);
32
+ const root = gitRoot(absolute) ?? absolute;
33
+ const digest = createHash("sha1").update(root).digest("hex").slice(0, 8);
34
+ return `${basename(root)}-${digest}`;
35
+ }
36
+ /** Session identity comes from the pi session manager; ids are filesystem-safe by construction. */
37
+ function sessionId(ctx) {
38
+ return ctx.sessionManager.getSessionId();
39
+ }
40
+ /** Absolute directory holding every note of one scope. */
41
+ export function scopeDir(scope, ctx) {
42
+ if (scope === "global")
43
+ return join(notesRoot(), "global");
44
+ if (scope === "project")
45
+ return join(notesRoot(), "project", projectKey(ctx.cwd));
46
+ return join(sessionHomesRoot(), sessionId(ctx));
47
+ }
48
+ /**
49
+ * Notes are markdown files: a virtual path without an `.md` suffix gains one, an explicit
50
+ * `.md` is kept as-is, so `a/b` and `a/b.md` name the same physical file.
51
+ */
52
+ export function noteFileName(vpath) {
53
+ return vpath.endsWith(".md") ? vpath : `${vpath}.md`;
54
+ }
55
+ /** Absolute file path for a virtual path in a scope. Callers validate the vpath first. */
56
+ export function physicalPath(scope, vpath, ctx) {
57
+ return join(scopeDir(scope, ctx), ...noteFileName(vpath).split("/"));
58
+ }