@astrosheep/pi-context 0.19.0 → 0.20.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/dist/src/budget.js +63 -0
- package/dist/src/dream/apply.js +87 -0
- package/dist/src/dream/cli.js +82 -0
- package/dist/src/dream/gates.js +21 -0
- package/dist/src/dream/lock.js +58 -0
- package/dist/src/dream/manifest.js +16 -0
- package/dist/src/dream/runner.js +56 -0
- package/dist/src/history-tools.js +105 -0
- package/dist/src/history.js +210 -0
- package/dist/src/index.js +99 -0
- package/dist/src/memory/frontmatter.js +134 -0
- package/dist/src/memory/paths.js +54 -0
- package/dist/src/memory/store.js +297 -0
- package/dist/src/memory/tools.js +175 -0
- package/dist/src/notes.js +101 -0
- package/dist/src/prompts.js +79 -0
- package/dist/src/protocol.js +52 -0
- package/dist/src/reset-lifecycle.js +101 -0
- package/dist/src/session-reader.js +1 -0
- package/dist/src/thresholds.js +72 -0
- package/dist/src/tool-output.js +172 -0
- package/dist/src/tool-schema.js +26 -0
- package/dist/src/warning.js +44 -0
- package/dist/test/agent-loop.test.js +212 -0
- package/dist/test/coherence.test.js +371 -0
- package/dist/test/dream.test.js +43 -0
- package/dist/test/history.test.js +21 -0
- package/dist/test/integration.test.js +1716 -0
- package/dist/test/memory.test.js +370 -0
- package/dist/test/pagination.property.test.js +476 -0
- package/dist/test/reset-lifecycle.test.js +199 -0
- package/package.json +9 -3
- package/playbook.md +5 -0
- package/src/dream/apply.ts +47 -0
- package/src/dream/cli.ts +33 -0
- package/src/dream/gates.ts +19 -0
- package/src/dream/lock.ts +39 -0
- package/src/dream/manifest.ts +21 -0
- package/src/dream/runner.ts +53 -0
- package/src/memory/store.ts +15 -0
- package/src/memory/tools.ts +12 -3
- package/src/protocol.ts +2 -2
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { RESET_V2 } from "./protocol.js";
|
|
2
|
+
function isTextContent(part) {
|
|
3
|
+
return typeof part === "object" && part !== null && part.type === "text" && typeof part.text === "string";
|
|
4
|
+
}
|
|
5
|
+
function contentText(content) {
|
|
6
|
+
if (typeof content === "string")
|
|
7
|
+
return content;
|
|
8
|
+
return content.filter(isTextContent).map((part) => part.text).join("\n");
|
|
9
|
+
}
|
|
10
|
+
function mapRole(role) {
|
|
11
|
+
if (role === "user" || role === "assistant")
|
|
12
|
+
return role;
|
|
13
|
+
if (role === "toolResult" || role === "bashExecution")
|
|
14
|
+
return "tool";
|
|
15
|
+
if (role === "custom")
|
|
16
|
+
return "user";
|
|
17
|
+
if (role === "compactionSummary" || role === "branchSummary")
|
|
18
|
+
return "system";
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
/** This extension's own custom-entry namespace; entries under it are authored by pi-context. */
|
|
22
|
+
const PI_CONTEXT_ENTRY_PREFIX = "pi-context/";
|
|
23
|
+
function messageContent(message) {
|
|
24
|
+
switch (message.role) {
|
|
25
|
+
case "bashExecution":
|
|
26
|
+
// The command is as much the record as its output: without it the typed line is
|
|
27
|
+
// unsearchable. Mirrors the tool-call projection below.
|
|
28
|
+
return message.output ? `${message.command}\n${message.output}` : message.command;
|
|
29
|
+
case "branchSummary":
|
|
30
|
+
case "compactionSummary":
|
|
31
|
+
return message.summary;
|
|
32
|
+
default:
|
|
33
|
+
return contentText(message.content);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function toolInfo(message) {
|
|
37
|
+
if (message.role === "bashExecution") {
|
|
38
|
+
// A truncated bash run is only half the record without the on-disk path: surface both.
|
|
39
|
+
return { toolName: "bash", outputTruncated: message.truncated || undefined, fullOutputPath: message.truncated ? message.fullOutputPath : undefined };
|
|
40
|
+
}
|
|
41
|
+
if (message.role !== "toolResult")
|
|
42
|
+
return {};
|
|
43
|
+
return { toolName: message.toolName, toolError: message.isError === true ? true : undefined };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* An assistant turn's tool calls, projected as their own items: calls wear their own role so the
|
|
47
|
+
* authoring turn's visible text (role "assistant") stays pure; what was invoked stays as
|
|
48
|
+
* searchable as what came back (role "tool"). Ids derive from the turn's entry id and stay
|
|
49
|
+
* opaque; history_read resolves them like any other item.
|
|
50
|
+
*/
|
|
51
|
+
function toolCallItems(windowId, entry, message) {
|
|
52
|
+
if (message.role !== "assistant" || !Array.isArray(message.content))
|
|
53
|
+
return [];
|
|
54
|
+
const items = [];
|
|
55
|
+
let callIndex = 0;
|
|
56
|
+
for (const part of message.content) {
|
|
57
|
+
if (typeof part !== "object" || part === null || part.type !== "toolCall")
|
|
58
|
+
continue;
|
|
59
|
+
const call = part;
|
|
60
|
+
items.push({
|
|
61
|
+
windowId,
|
|
62
|
+
itemId: `${entry.id}#${callIndex++}`,
|
|
63
|
+
role: "tool_call",
|
|
64
|
+
content: JSON.stringify(call.arguments),
|
|
65
|
+
createdAt: entry.timestamp,
|
|
66
|
+
toolName: call.name,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return items;
|
|
70
|
+
}
|
|
71
|
+
/** The extension-owned window id baked onto a reset-v2 compaction entry, if present. */
|
|
72
|
+
export function resetV2WindowId(details) {
|
|
73
|
+
if (typeof details !== "object" || details === null)
|
|
74
|
+
return undefined;
|
|
75
|
+
const candidate = details;
|
|
76
|
+
if (candidate.piContext !== RESET_V2 || typeof candidate.windowId !== "string")
|
|
77
|
+
return undefined;
|
|
78
|
+
return candidate.windowId;
|
|
79
|
+
}
|
|
80
|
+
/** A compaction entry's window id: the extension-minted id for reset-v2, else Pi's entry id. */
|
|
81
|
+
function windowIdOf(sessionId, entry) {
|
|
82
|
+
return resetV2WindowId(entry.details) ?? `pcw:${sessionId.slice(0, 8)}:${entry.id}`;
|
|
83
|
+
}
|
|
84
|
+
/** Build durable, on-demand history directly from every entry on the current session branch. */
|
|
85
|
+
export function historyFromSession(ctx) {
|
|
86
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
87
|
+
let window = { windowId: `pcw:${sessionId.slice(0, 8)}:root`, items: [] };
|
|
88
|
+
const windows = [window];
|
|
89
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
90
|
+
if (entry.type === "compaction") {
|
|
91
|
+
window = { windowId: windowIdOf(sessionId, entry), createdAt: entry.timestamp, items: [] };
|
|
92
|
+
windows.push(window);
|
|
93
|
+
window.items.push({
|
|
94
|
+
windowId: window.windowId,
|
|
95
|
+
itemId: entry.id,
|
|
96
|
+
// A reset-v2 compaction is authored by this extension; a native Pi compaction is not.
|
|
97
|
+
role: resetV2WindowId(entry.details) === undefined ? "system" : "developer",
|
|
98
|
+
content: entry.summary,
|
|
99
|
+
createdAt: entry.timestamp,
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (entry.type === "message") {
|
|
104
|
+
const role = mapRole(entry.message.role);
|
|
105
|
+
if (!role)
|
|
106
|
+
continue;
|
|
107
|
+
window.items.push({
|
|
108
|
+
windowId: window.windowId,
|
|
109
|
+
itemId: entry.id,
|
|
110
|
+
role,
|
|
111
|
+
content: messageContent(entry.message),
|
|
112
|
+
createdAt: entry.timestamp,
|
|
113
|
+
...toolInfo(entry.message),
|
|
114
|
+
});
|
|
115
|
+
window.items.push(...toolCallItems(window.windowId, entry, entry.message));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (entry.type === "custom_message") {
|
|
119
|
+
window.items.push({
|
|
120
|
+
windowId: window.windowId,
|
|
121
|
+
itemId: entry.id,
|
|
122
|
+
// Only entries this extension wrote are its own; every foreign custom message stays a user turn.
|
|
123
|
+
role: entry.customType.startsWith(PI_CONTEXT_ENTRY_PREFIX) ? "developer" : "user",
|
|
124
|
+
content: contentText(entry.content),
|
|
125
|
+
createdAt: entry.timestamp,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return windows;
|
|
130
|
+
}
|
|
131
|
+
export function visibleItem(item, maxChars = 1200) {
|
|
132
|
+
const characters = Array.from(item.content);
|
|
133
|
+
const truncated = characters.length > maxChars;
|
|
134
|
+
return {
|
|
135
|
+
window_id: item.windowId,
|
|
136
|
+
item_id: item.itemId,
|
|
137
|
+
role: item.role,
|
|
138
|
+
tool_name: item.toolName ?? null,
|
|
139
|
+
// Surfaced only when set: a truncated bash run names its full-output path, and an
|
|
140
|
+
// errored tool run says so. Absent keys mean nothing special happened.
|
|
141
|
+
...(item.outputTruncated ? { output_truncated: true, full_output_path: item.fullOutputPath ?? null } : {}),
|
|
142
|
+
...(item.toolError ? { tool_error: true } : {}),
|
|
143
|
+
truncated,
|
|
144
|
+
total_chars: characters.length,
|
|
145
|
+
// A truncated payload is a plain prefix: no synthetic marker is appended, and
|
|
146
|
+
// `total_chars` names exactly how many code points were left out.
|
|
147
|
+
truncated_content: truncated ? characters.slice(0, maxChars).join("") : item.content,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export function allItems(ctx) {
|
|
151
|
+
return historyFromSession(ctx).flatMap((window) => window.items);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* window_id must name a real window; anything else is a named error, not a silent empty page
|
|
155
|
+
* (a window that exists but has no matching items after the other filters stays a legal empty
|
|
156
|
+
* page). Returns the teaching message plus the known window ids so the error is self-healing.
|
|
157
|
+
*/
|
|
158
|
+
export function unknownWindowId(ctx, params) {
|
|
159
|
+
if (typeof params.window_id !== "string")
|
|
160
|
+
return undefined;
|
|
161
|
+
const known = historyFromSession(ctx).map((window) => window.windowId);
|
|
162
|
+
return known.includes(params.window_id) ? undefined : { message: `unknown window_id "${params.window_id}"`, known };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* A role×tool_name combination is vacuous — provably empty from the taxonomy alone, before
|
|
166
|
+
* any data is read — when tool_name is given alongside a role that never carries one. Only
|
|
167
|
+
* "tool_call" and "tool" items have a tool name. Returns the teaching error message, or
|
|
168
|
+
* undefined when the combination can match.
|
|
169
|
+
*/
|
|
170
|
+
export function vacuousRoleToolCombo(params) {
|
|
171
|
+
if (typeof params.tool_name === "string" && typeof params.role === "string" && params.role !== "tool_call" && params.role !== "tool") {
|
|
172
|
+
return `tool_name is only set on "tool_call" and "tool" items; role "${params.role}" never carries one`;
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
export function filteredItems(ctx, params) {
|
|
177
|
+
let items = allItems(ctx);
|
|
178
|
+
if (typeof params.window_id === "string")
|
|
179
|
+
items = items.filter((item) => item.windowId === params.window_id);
|
|
180
|
+
if (typeof params.role === "string")
|
|
181
|
+
items = items.filter((item) => item.role === params.role);
|
|
182
|
+
if (typeof params.tool_name === "string")
|
|
183
|
+
items = items.filter((item) => item.toolName === params.tool_name);
|
|
184
|
+
if (params.recent_first !== false)
|
|
185
|
+
items.reverse();
|
|
186
|
+
return items;
|
|
187
|
+
}
|
|
188
|
+
/** Persisted messages in the active window, excluding earlier windows on this branch. */
|
|
189
|
+
export function hasWindowMessage(ctx, customType) {
|
|
190
|
+
const branch = ctx.sessionManager.getBranch();
|
|
191
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
192
|
+
const entry = branch[i];
|
|
193
|
+
if (entry.type === "compaction")
|
|
194
|
+
break;
|
|
195
|
+
if (entry.type === "custom_message" && entry.customType === customType)
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
/** Cheap current-window lookup: scan the branch tail for the latest compaction entry. */
|
|
201
|
+
export function currentWindowId(ctx) {
|
|
202
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
203
|
+
const branch = ctx.sessionManager.getBranch();
|
|
204
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
205
|
+
const entry = branch[i];
|
|
206
|
+
if (entry?.type === "compaction")
|
|
207
|
+
return windowIdOf(sessionId, entry);
|
|
208
|
+
}
|
|
209
|
+
return `pcw:${sessionId.slice(0, 8)}:root`;
|
|
210
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { registerHistoryTools } from "./history-tools.js";
|
|
2
|
+
import { registerMemoryTools } from "./memory/tools.js";
|
|
3
|
+
import { registerBudget, deriveThresholds, mergePiContextSettings } from "./budget.js";
|
|
4
|
+
import { output } from "./tool-output.js";
|
|
5
|
+
export { deriveThresholds, mergePiContextSettings };
|
|
6
|
+
import { STATE_TYPE, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, RESET_V2, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS, RESET_SUMMARY, CONTINUATION, WARNING_PROMPT } from "./protocol.js";
|
|
7
|
+
import { historyFromSession, hasWindowMessage, currentWindowId, resetV2WindowId } from "./history.js";
|
|
8
|
+
import { assertVirtualPath } from "./notes.js";
|
|
9
|
+
import { bootBlock } from "./prompts.js";
|
|
10
|
+
export { historyFromSession } from "./history.js";
|
|
11
|
+
export { notesFromSession } from "./notes.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 sessionId = ctx.sessionManager.getSessionId();
|
|
28
|
+
const rootId = `pcw:${sessionId.slice(0, 8)}:root`;
|
|
29
|
+
if (currentWindowId(ctx) !== rootId || hasWindowMessage(ctx, BOOT_TYPE))
|
|
30
|
+
return;
|
|
31
|
+
pi.sendMessage({ customType: BOOT_TYPE, content: bootBlock(ctx, rootId, undefined, false), display: false }, { triggerTurn: false });
|
|
32
|
+
});
|
|
33
|
+
pi.registerCommand("pi-context", {
|
|
34
|
+
description: "Toggle pi-context: context_window boot block, low-budget guidance, and reset-style compaction",
|
|
35
|
+
getArgumentCompletions: (prefix) => ["on", "off"].filter((a) => a.startsWith(prefix)).map((a) => ({ value: a, label: a })),
|
|
36
|
+
handler: async (args, cmdCtx) => {
|
|
37
|
+
const arg = args.trim().toLowerCase();
|
|
38
|
+
if (arg === "on")
|
|
39
|
+
enabled = true;
|
|
40
|
+
else if (arg === "off") {
|
|
41
|
+
enabled = false;
|
|
42
|
+
resets.clear();
|
|
43
|
+
}
|
|
44
|
+
else if (arg !== "") {
|
|
45
|
+
cmdCtx.ui.notify("Usage: /pi-context [on|off]", "error");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
cmdCtx.ui.notify(`pi-context: ${enabled ? "on" : "off"}`, "info");
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
registerHistoryTools(pi);
|
|
52
|
+
registerMemoryTools(pi);
|
|
53
|
+
pi.registerTool(defineTool({
|
|
54
|
+
name: "new_context",
|
|
55
|
+
label: "New context",
|
|
56
|
+
description: "Clear your mind and start a new context window. Your session, notes, and history survive.",
|
|
57
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
58
|
+
async execute() {
|
|
59
|
+
if (!enabled)
|
|
60
|
+
return output({ error: "pi-context is off (/pi-context on to enable)" });
|
|
61
|
+
return output({ status: resets.request() }, undefined, true);
|
|
62
|
+
},
|
|
63
|
+
}));
|
|
64
|
+
const resets = registerResetLifecycle(pi, {
|
|
65
|
+
isEnabled: () => enabled,
|
|
66
|
+
continuation: { customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
|
|
67
|
+
isCurrentReset: (entryId, ctx) => {
|
|
68
|
+
const entry = ctx.sessionManager.getEntry(entryId);
|
|
69
|
+
return entry?.type === "compaction" && resetV2WindowId(entry.details) === currentWindowId(ctx);
|
|
70
|
+
},
|
|
71
|
+
onReset: (entryId) => pi.appendEntry(STATE_TYPE, { version: 1, lastResetEntryId: entryId }),
|
|
72
|
+
buildReset: (event, ctx, explicit) => {
|
|
73
|
+
const session8 = ctx.sessionManager.getSessionId().slice(0, 8);
|
|
74
|
+
// Window IDs are independent of Pi entry IDs. Avoid reusing a window
|
|
75
|
+
// identity already present on this branch.
|
|
76
|
+
const windows = historyFromSession(ctx);
|
|
77
|
+
const usedIds = new Set(windows.map((window) => window.windowId));
|
|
78
|
+
let minted = randomUUID().slice(0, 8);
|
|
79
|
+
while (usedIds.has(`pcw:${session8}:${minted}`))
|
|
80
|
+
minted = randomUUID().slice(0, 8);
|
|
81
|
+
const windowId = `pcw:${session8}:${minted}`;
|
|
82
|
+
const previousId = windows[windows.length - 1]?.windowId ?? `pcw:${session8}:root`;
|
|
83
|
+
// The reset marker stays as firstKeptEntryId; it no longer names the window.
|
|
84
|
+
pi.appendEntry(RESET_MARKER_TYPE, { version: 1, reason: event.reason, requested: explicit });
|
|
85
|
+
const markerId = ctx.sessionManager.getLeafId();
|
|
86
|
+
if (!markerId)
|
|
87
|
+
return { cancel: true };
|
|
88
|
+
return {
|
|
89
|
+
compaction: {
|
|
90
|
+
summary: bootBlock(ctx, windowId, previousId, true),
|
|
91
|
+
firstKeptEntryId: markerId,
|
|
92
|
+
tokensBefore: event.preparation.tokensBefore,
|
|
93
|
+
details: { piContext: RESET_V2, windowId },
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
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,134 @@
|
|
|
1
|
+
import { localIso } from "../notes.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 = ["scope", "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
|
+
if (KNOWN_KEYS.includes(key))
|
|
124
|
+
continue;
|
|
125
|
+
if (meta[key] === undefined)
|
|
126
|
+
continue;
|
|
127
|
+
lines.push(`${key}: ${yamlScalar(meta[key])}`);
|
|
128
|
+
}
|
|
129
|
+
return `---\n${lines.join("\n")}\n---\n\n${body}`;
|
|
130
|
+
}
|
|
131
|
+
/** Strip a leading frontmatter block from user content, so a note body is pure content. */
|
|
132
|
+
export function stripLeadingFrontmatter(content) {
|
|
133
|
+
return parseFrontmatter(content).body;
|
|
134
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
/**
|
|
11
|
+
* Absolute git root for `cwd`, walking upward until a directory holds a `.git` entry.
|
|
12
|
+
* No git root yields undefined, which projectKey then replaces with the cwd itself.
|
|
13
|
+
*/
|
|
14
|
+
function gitRoot(cwd) {
|
|
15
|
+
let dir = resolve(cwd);
|
|
16
|
+
for (;;) {
|
|
17
|
+
if (existsSync(join(dir, ".git")))
|
|
18
|
+
return dir;
|
|
19
|
+
const parent = dirname(dir);
|
|
20
|
+
if (parent === dir)
|
|
21
|
+
return undefined;
|
|
22
|
+
dir = parent;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** `<basename(absGitRoot)-sha1(absGitRoot)[:8]>`, or the same formula over cwd with no git root. */
|
|
26
|
+
export function projectKey(cwd) {
|
|
27
|
+
const absolute = resolve(cwd);
|
|
28
|
+
const root = gitRoot(absolute) ?? absolute;
|
|
29
|
+
const digest = createHash("sha1").update(root).digest("hex").slice(0, 8);
|
|
30
|
+
return `${basename(root)}-${digest}`;
|
|
31
|
+
}
|
|
32
|
+
/** Session identity comes from the pi session manager; ids are filesystem-safe by construction. */
|
|
33
|
+
function sessionId(ctx) {
|
|
34
|
+
return ctx.sessionManager.getSessionId();
|
|
35
|
+
}
|
|
36
|
+
/** Absolute directory holding every note of one scope. */
|
|
37
|
+
export function scopeDir(scope, ctx) {
|
|
38
|
+
if (scope === "global")
|
|
39
|
+
return join(notesRoot(), "global");
|
|
40
|
+
if (scope === "project")
|
|
41
|
+
return join(notesRoot(), "project", projectKey(ctx.cwd));
|
|
42
|
+
return join(notesRoot(), "pi", "session", sessionId(ctx));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Notes are markdown files: a virtual path without an `.md` suffix gains one, an explicit
|
|
46
|
+
* `.md` is kept as-is, so `a/b` and `a/b.md` name the same physical file.
|
|
47
|
+
*/
|
|
48
|
+
export function noteFileName(vpath) {
|
|
49
|
+
return vpath.endsWith(".md") ? vpath : `${vpath}.md`;
|
|
50
|
+
}
|
|
51
|
+
/** Absolute file path for a virtual path in a scope. Callers validate the vpath first. */
|
|
52
|
+
export function physicalPath(scope, vpath, ctx) {
|
|
53
|
+
return join(scopeDir(scope, ctx), ...noteFileName(vpath).split("/"));
|
|
54
|
+
}
|