@astrosheep/pi-context 0.24.0 → 0.25.1
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 +52 -5
- package/dist/build-info.json +4 -0
- package/dist/extension.js +1951 -0
- package/dist/src/context/boot.js +46 -0
- package/dist/src/context/budget.js +150 -0
- package/dist/src/context/context-window.js +112 -0
- package/dist/src/context/prompts.js +91 -0
- package/dist/src/context/reset-artifacts.js +86 -0
- package/dist/src/context/reset-lifecycle.js +182 -0
- package/dist/src/context/runtime.js +151 -0
- package/dist/src/context/thresholds.js +62 -0
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/dream/doctor.js +34 -6
- package/dist/src/dream/runner.js +1 -1
- package/dist/src/dream/settings.js +30 -0
- package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
- package/dist/src/{history.js → history/history.js} +8 -46
- package/dist/src/index.js +27 -94
- package/dist/src/notes/address.js +97 -16
- package/dist/src/notes/frontmatter.js +18 -3
- package/dist/src/notes/notes-snapshot.js +30 -0
- package/dist/src/notes/paths.js +64 -7
- package/dist/src/notes/session-replay.js +41 -0
- package/dist/src/notes/store.js +76 -22
- package/dist/src/notes/tools.js +7 -7
- package/dist/src/protocol.js +9 -9
- package/dist/src/settings.js +16 -0
- package/dist/src/tool-schema.js +1 -1
- package/dist/test/agent-loop.test.js +815 -221
- package/dist/test/boot.integration.test.js +219 -0
- package/dist/test/budget-settings.integration.test.js +126 -0
- package/dist/test/doctor.test.js +14 -36
- package/dist/test/dream.test.js +37 -380
- package/dist/test/helpers/extension.js +392 -0
- package/dist/test/history.integration.test.js +316 -0
- package/dist/test/notes.integration.test.js +270 -0
- package/dist/test/notes.test.js +40 -359
- package/dist/test/reset-lifecycle.test.js +443 -178
- package/docs/architecture.md +35 -18
- package/docs/reset-lifecycle.md +73 -14
- package/package.json +11 -10
- package/src/context/boot.ts +68 -0
- package/src/context/budget.ts +148 -0
- package/src/context/context-window.ts +118 -0
- package/src/context/prompts.ts +108 -0
- package/src/context/reset-artifacts.ts +101 -0
- package/src/context/reset-lifecycle.ts +272 -0
- package/src/context/runtime.ts +151 -0
- package/src/context/thresholds.ts +78 -0
- package/src/dream/cli.ts +1 -1
- package/src/dream/doctor.ts +27 -6
- package/src/dream/runner.ts +1 -1
- package/src/dream/settings.ts +32 -0
- package/src/{history-tools.ts → history/history-tools.ts} +3 -3
- package/src/{history.ts → history/history.ts} +9 -48
- package/src/index.ts +27 -89
- package/src/notes/address.ts +82 -16
- package/src/notes/frontmatter.ts +20 -3
- package/src/notes/notes-snapshot.ts +40 -0
- package/src/notes/paths.ts +64 -7
- package/src/notes/session-replay.ts +53 -0
- package/src/notes/store.ts +78 -25
- package/src/notes/tools.ts +7 -7
- package/src/protocol.ts +9 -10
- package/src/settings.ts +20 -0
- package/src/tool-schema.ts +1 -2
- package/dist/src/budget.js +0 -65
- package/dist/src/notes/model.js +0 -101
- package/dist/src/prompts.js +0 -88
- package/dist/src/reset-lifecycle.js +0 -155
- package/dist/src/thresholds.js +0 -102
- package/dist/src/warning.js +0 -44
- package/dist/test/coherence.test.js +0 -371
- package/dist/test/history.test.js +0 -26
- package/dist/test/integration.test.js +0 -1759
- package/dist/test/pagination.property.test.js +0 -471
- package/src/budget.ts +0 -67
- package/src/notes/model.ts +0 -109
- package/src/prompts.ts +0 -91
- package/src/reset-lifecycle.ts +0 -173
- package/src/thresholds.ts +0 -110
- package/src/warning.ts +0 -46
package/dist/src/index.js
CHANGED
|
@@ -1,98 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { VERSION } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { registerHistoryTools } from "./history/history-tools.js";
|
|
2
3
|
import { registerNotesTools } from "./notes/tools.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
});
|
|
4
|
+
import { deriveThresholds } from "./context/thresholds.js";
|
|
5
|
+
import { registerContext } from "./context/runtime.js";
|
|
6
|
+
import { mergePiContextSettings } from "./settings.js";
|
|
7
|
+
import { NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, 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, CONTINUATION, WARNING_PROMPT } from "./protocol.js";
|
|
8
|
+
import { assertVirtualPath } from "./notes/address.js";
|
|
9
|
+
export { historyFromSession } from "./history/history.js";
|
|
10
|
+
export { notesFromSession } from "./notes/session-replay.js";
|
|
11
|
+
function registerPiContext(pi, settingsManager) {
|
|
12
|
+
const [major, minor] = VERSION.split(".").map(Number);
|
|
13
|
+
if (!(major > 0 || (major === 0 && minor >= 87))) {
|
|
14
|
+
throw new Error(`pi-context requires Pi >= 0.87.0; running ${VERSION}. Upgrade Pi and restart the process; /reload only reloads extensions.`);
|
|
15
|
+
}
|
|
16
|
+
registerContext(pi, settingsManager);
|
|
50
17
|
registerHistoryTools(pi);
|
|
51
18
|
registerNotesTools(pi);
|
|
52
|
-
pi.registerTool(defineTool({
|
|
53
|
-
name: "wipe_memory",
|
|
54
|
-
label: "Wipe memory",
|
|
55
|
-
description: "Wipe your in-context memory and start a fresh 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
19
|
}
|
|
98
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Create an extension factory bound to an SDK settings authority. The host must pass
|
|
22
|
+
* the same manager to createAgentSession and to this factory's resource loader.
|
|
23
|
+
*/
|
|
24
|
+
export function createPiContext(options = {}) {
|
|
25
|
+
return (pi) => registerPiContext(pi, options.settingsManager);
|
|
26
|
+
}
|
|
27
|
+
/** The Pi-discovered extension keeps the standard file-backed settings behavior. */
|
|
28
|
+
export default function piContext(pi) {
|
|
29
|
+
registerPiContext(pi);
|
|
30
|
+
}
|
|
31
|
+
export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, CONTINUATION_TYPE, WARNING_PROMPT, WARNING_RUNWAY_TOKENS, RESET_MARKER_TYPE, 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 };
|
|
@@ -1,31 +1,112 @@
|
|
|
1
|
-
import {
|
|
2
|
-
const ADDRESS_FORMS = "legal prefixes are @project
|
|
1
|
+
import { agentSlug, modelSlug, SLUG_PATTERN } from "./paths.js";
|
|
2
|
+
export const ADDRESS_FORMS = "legal prefixes are @project/, @human/, @self/, and @model/; bare names are this session";
|
|
3
|
+
export function assertVirtualPath(value) {
|
|
4
|
+
if (typeof value !== "string" || value.length === 0)
|
|
5
|
+
throw new Error("path must be a non-empty virtual relative path");
|
|
6
|
+
if (value.includes("\0") || value.includes("\\") || value.startsWith("/"))
|
|
7
|
+
throw new Error("path must be a safe virtual relative path");
|
|
8
|
+
const parts = value.split("/");
|
|
9
|
+
if (parts.some((part) => part.length === 0 || part === "." || part === ".."))
|
|
10
|
+
throw new Error("path contains an unsupported component");
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Minimal glob over virtual note paths: `*` matches any run within a segment (never
|
|
15
|
+
* `/`), `**` matches any run across segments (a leading double-star followed by a
|
|
16
|
+
* slash also matches zero segments, so it covers the root too), `?` matches exactly
|
|
17
|
+
* one non-`/` character. Everything else is literal and the match is anchored to the
|
|
18
|
+
* whole path.
|
|
19
|
+
*/
|
|
20
|
+
export function globToRegExp(pattern) {
|
|
21
|
+
let source = "^";
|
|
22
|
+
for (let index = 0; index < pattern.length; index++) {
|
|
23
|
+
const char = pattern[index];
|
|
24
|
+
if (char === "*") {
|
|
25
|
+
if (pattern[index + 1] === "*") {
|
|
26
|
+
const followedBySlash = pattern[index + 2] === "/";
|
|
27
|
+
source += followedBySlash ? "(?:[^]*\\/)?" : "[^]*";
|
|
28
|
+
index += followedBySlash ? 2 : 1;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
source += "[^/]*";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return new RegExp(`${source}$`);
|
|
39
|
+
}
|
|
40
|
+
/** Glob patterns are not virtual paths (`*` is legal), so they get their own guard: no NUL, no backslashes. */
|
|
41
|
+
export function assertGlobPattern(value) {
|
|
42
|
+
if (value === undefined || value === null || value === "")
|
|
43
|
+
return undefined;
|
|
44
|
+
if (typeof value !== "string")
|
|
45
|
+
throw new Error("glob pattern must be a string");
|
|
46
|
+
if (value.includes("\0") || value.includes("\\"))
|
|
47
|
+
throw new Error("glob pattern must not contain NUL or backslashes");
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
3
50
|
/**
|
|
4
51
|
* Decode the one public note address into its physical home and virtual path. This is a
|
|
5
52
|
* tool-boundary rule: replay paths keep using assertVirtualPath directly and are untouched.
|
|
53
|
+
* The word after `@` is always a reserved home name; agent and model names live at the
|
|
54
|
+
* second level (@agents/faye/, never @faye/), so user-chosen names can never collide with
|
|
55
|
+
* the reserved set. `@self` and `@model` are relative — `who` stays undefined and the
|
|
56
|
+
* store resolves the current agent/model at call time.
|
|
6
57
|
*/
|
|
7
58
|
export function assertAddress(value) {
|
|
8
59
|
if (typeof value !== "string")
|
|
9
60
|
throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
|
|
10
61
|
let scope = "session";
|
|
11
62
|
let path = value;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
path =
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
63
|
+
let who;
|
|
64
|
+
if (value.startsWith("@")) {
|
|
65
|
+
const rest = value.slice(1);
|
|
66
|
+
const headEnd = rest.indexOf("/");
|
|
67
|
+
const head = headEnd === -1 ? rest : rest.slice(0, headEnd);
|
|
68
|
+
const tail = headEnd === -1 ? "" : rest.slice(headEnd + 1);
|
|
69
|
+
path = tail;
|
|
70
|
+
if (head === "project")
|
|
71
|
+
scope = "project";
|
|
72
|
+
else if (head === "human")
|
|
73
|
+
scope = "human";
|
|
74
|
+
else if (head === "self")
|
|
75
|
+
scope = "agent";
|
|
76
|
+
else if (head === "model")
|
|
77
|
+
scope = "model";
|
|
78
|
+
else if (head === "agents" || head === "models") {
|
|
79
|
+
const nameEnd = tail.indexOf("/");
|
|
80
|
+
who = nameEnd === -1 ? tail : tail.slice(0, nameEnd);
|
|
81
|
+
if (!SLUG_PATTERN.test(who))
|
|
82
|
+
throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
|
|
83
|
+
scope = head === "agents" ? "agent" : "model";
|
|
84
|
+
path = nameEnd === -1 ? "" : tail.slice(nameEnd + 1);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
|
|
88
|
+
}
|
|
89
|
+
if (path === "" && scope !== "agent" && scope !== "model")
|
|
90
|
+
throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
|
|
91
|
+
if (path === "" && who === undefined)
|
|
92
|
+
throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
|
|
22
93
|
}
|
|
23
94
|
if (path.includes("@"))
|
|
24
95
|
throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
|
|
25
96
|
assertVirtualPath(path);
|
|
26
|
-
return { scope, path };
|
|
97
|
+
return { scope, path, who };
|
|
27
98
|
}
|
|
28
|
-
/** Render a virtual path in its one unambiguous public address form.
|
|
29
|
-
|
|
30
|
-
|
|
99
|
+
/** Render a virtual path in its one unambiguous public address form. Relative forms
|
|
100
|
+
* (@self/, @model/) never render: the canonical address always carries the resolved
|
|
101
|
+
* name, so listings alone tell every home apart. */
|
|
102
|
+
export function addressFor(ctx, scope, path, who) {
|
|
103
|
+
if (scope === "session")
|
|
104
|
+
return path;
|
|
105
|
+
if (scope === "project")
|
|
106
|
+
return `@project/${path}`;
|
|
107
|
+
if (scope === "human")
|
|
108
|
+
return `@human/${path}`;
|
|
109
|
+
if (scope === "agent")
|
|
110
|
+
return `@agents/${who ?? agentSlug(ctx)}/${path}`;
|
|
111
|
+
return `@models/${who ?? modelSlug(ctx)}/${path}`;
|
|
31
112
|
}
|
|
@@ -1,10 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
const SCOPES = ["session", "project", "personal"];
|
|
1
|
+
const SCOPES = ["session", "project", "human", "agent", "model"];
|
|
3
2
|
const ORIGINS = ["user", "self", "external"];
|
|
4
3
|
const STATUSES = ["active", "superseded", "pending", "archived"];
|
|
5
4
|
const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"];
|
|
6
5
|
/** Emission order, exactly the Design's key list. */
|
|
7
6
|
const KNOWN_KEYS = ["origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"];
|
|
7
|
+
const pad2 = (value) => String(value).padStart(2, "0");
|
|
8
|
+
/**
|
|
9
|
+
* Format epoch milliseconds as an ISO 8601 string in the host's local time zone with an
|
|
10
|
+
* explicit numeric offset (e.g. 2026-09-15T17:31:45.392+08:00). A UTC host renders
|
|
11
|
+
* "+00:00"; the "Z" designator is never used, and Date.parse round-trips the value.
|
|
12
|
+
*/
|
|
13
|
+
export function localIso(epochMs) {
|
|
14
|
+
const date = new Date(epochMs);
|
|
15
|
+
const offsetMinutes = -date.getTimezoneOffset();
|
|
16
|
+
const absOffset = Math.abs(offsetMinutes);
|
|
17
|
+
const offset = `${offsetMinutes < 0 ? "-" : "+"}${pad2(Math.floor(absOffset / 60))}:${pad2(absOffset % 60)}`;
|
|
18
|
+
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")}`;
|
|
19
|
+
return `${wallClock}${offset}`;
|
|
20
|
+
}
|
|
8
21
|
export function isScope(value) {
|
|
9
22
|
return typeof value === "string" && SCOPES.includes(value);
|
|
10
23
|
}
|
|
@@ -89,7 +102,9 @@ function parseFrontmatter(raw) {
|
|
|
89
102
|
export function parseNote(raw, now = Date.now()) {
|
|
90
103
|
const { fields, body } = parseFrontmatter(raw);
|
|
91
104
|
const meta = { ...fields };
|
|
92
|
-
|
|
105
|
+
// scope is a legacy on-disk field: store callers derive it from the file's home and
|
|
106
|
+
// overwrite it after parsing, so an absent or outdated value just falls back.
|
|
107
|
+
meta.scope = isScope(meta.scope) ? meta.scope : "session";
|
|
93
108
|
meta.origin = isOrigin(meta.origin) ? meta.origin : "self";
|
|
94
109
|
meta.status = isStatus(meta.status) ? meta.status : "active";
|
|
95
110
|
meta.stale = meta.stale === true;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { listNotes } from "./store.js";
|
|
2
|
+
const NOTES_HOMES = [
|
|
3
|
+
{ scope: "session", label: "this session" },
|
|
4
|
+
{ scope: "project", label: "@project" },
|
|
5
|
+
{ scope: "human", label: "@human" },
|
|
6
|
+
{ scope: "agent", label: "@self" },
|
|
7
|
+
{ scope: "model", label: "@model" },
|
|
8
|
+
];
|
|
9
|
+
/**
|
|
10
|
+
* Acquire the five homes once for one boot. Only filesystem-style errno failures are isolated;
|
|
11
|
+
* malformed note data and unrelated construction errors remain visible to the caller.
|
|
12
|
+
*/
|
|
13
|
+
export function loadNotesSnapshot(ctx, loadHome = (context, scope) => listNotes(context, { scope })) {
|
|
14
|
+
const openedAt = Date.now();
|
|
15
|
+
const homes = new Map();
|
|
16
|
+
const unavailable = [];
|
|
17
|
+
for (const home of NOTES_HOMES) {
|
|
18
|
+
try {
|
|
19
|
+
homes.set(home.scope, loadHome(ctx, home.scope));
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
const code = typeof error === "object" && error !== null ? error.code : undefined;
|
|
23
|
+
if (typeof code !== "string" || !/^E[A-Z0-9_]+$/.test(code) || code.startsWith("ERR_"))
|
|
24
|
+
throw error;
|
|
25
|
+
homes.set(home.scope, []);
|
|
26
|
+
unavailable.push(home);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { openedAt, homes, unavailable };
|
|
30
|
+
}
|
package/dist/src/notes/paths.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { existsSync, readdirSync, renameSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
/** Physical home of the on-disk note store: $PI_NOTES_HOME or ~/.agents/notes. */
|
|
@@ -37,14 +37,71 @@ export function projectKey(cwd) {
|
|
|
37
37
|
function sessionId(ctx) {
|
|
38
38
|
return ctx.sessionManager.getSessionId();
|
|
39
39
|
}
|
|
40
|
-
/**
|
|
41
|
-
export
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
/** The one legal home-name shape: lowercase [a-z0-9-] runs separated by single dashes. */
|
|
41
|
+
export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
42
|
+
/**
|
|
43
|
+
* Identity slugs: one declared name per home, never detected from prompt content.
|
|
44
|
+
* `PI_NOTES_AGENT` declares who is running (default "root"); the model slug derives
|
|
45
|
+
* from the live model id, provider prefix stripped. Both slugified to [a-z0-9-].
|
|
46
|
+
*/
|
|
47
|
+
export function slugify(value) {
|
|
48
|
+
const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
49
|
+
return slug.length > 0 ? slug : "root";
|
|
50
|
+
}
|
|
51
|
+
/** The current agent's home name: the launch-declared identity, defaulting to "root". */
|
|
52
|
+
export function agentSlug(_ctx) {
|
|
53
|
+
return slugify(process.env.PI_NOTES_AGENT ?? "root");
|
|
54
|
+
}
|
|
55
|
+
/** The current model's home name, live-resolved from ctx.model; "default" when unknown. */
|
|
56
|
+
export function modelSlug(ctx) {
|
|
57
|
+
const id = ctx.model?.id;
|
|
58
|
+
if (!id)
|
|
59
|
+
return "default";
|
|
60
|
+
return slugify(id.split("/").pop() ?? id);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Absolute directory holding every note of one scope. `who` names an agent or model
|
|
64
|
+
* home absolutely; omitted, the current one resolves (agent from PI_NOTES_AGENT,
|
|
65
|
+
* model live from ctx.model).
|
|
66
|
+
*/
|
|
67
|
+
export function scopeDir(scope, ctx, who) {
|
|
68
|
+
if (scope === "human")
|
|
69
|
+
return join(notesRoot(), "human");
|
|
44
70
|
if (scope === "project")
|
|
45
71
|
return join(notesRoot(), "project", projectKey(ctx.cwd));
|
|
72
|
+
if (scope === "agent")
|
|
73
|
+
return join(notesRoot(), "agents", who ?? agentSlug(ctx));
|
|
74
|
+
if (scope === "model")
|
|
75
|
+
return join(notesRoot(), "models", who ?? modelSlug(ctx));
|
|
46
76
|
return join(sessionHomesRoot(), sessionId(ctx));
|
|
47
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* One-time migration of the pre-v0.25 `personal/` home to `human/`. Runs at extension
|
|
80
|
+
* activation; returns a warning string when both directories exist (no auto-merge),
|
|
81
|
+
* undefined otherwise. Old note bodies are history, not addresses, and stay untouched.
|
|
82
|
+
*/
|
|
83
|
+
export function migrateLegacyHomes(home = notesRoot()) {
|
|
84
|
+
const legacy = join(home, "personal");
|
|
85
|
+
const modern = join(home, "human");
|
|
86
|
+
if (!existsSync(legacy))
|
|
87
|
+
return undefined;
|
|
88
|
+
if (existsSync(modern))
|
|
89
|
+
return "both personal/ and human/ exist under the notes home; migrate by hand, no automatic merge";
|
|
90
|
+
renameSync(legacy, modern);
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
/** Every existing home directory of the agents/ or models/ namespace, as slugs. */
|
|
94
|
+
export function namespaceSlugs(namespace, home = notesRoot()) {
|
|
95
|
+
try {
|
|
96
|
+
return readdirSync(join(home, namespace), { withFileTypes: true })
|
|
97
|
+
.filter((entry) => entry.isDirectory())
|
|
98
|
+
.map((entry) => entry.name)
|
|
99
|
+
.sort();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
48
105
|
/**
|
|
49
106
|
* Notes are markdown files: a virtual path without an `.md` suffix gains one, an explicit
|
|
50
107
|
* `.md` is kept as-is, so `a/b` and `a/b.md` name the same physical file.
|
|
@@ -53,6 +110,6 @@ export function noteFileName(vpath) {
|
|
|
53
110
|
return vpath.endsWith(".md") ? vpath : `${vpath}.md`;
|
|
54
111
|
}
|
|
55
112
|
/** 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("/"));
|
|
113
|
+
export function physicalPath(scope, vpath, ctx, who) {
|
|
114
|
+
return join(scopeDir(scope, ctx, who), ...noteFileName(vpath).split("/"));
|
|
58
115
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { MAX_NOTE_BYTES, NOTE_TYPE } from "../protocol.js";
|
|
2
|
+
import { assertVirtualPath } from "./address.js";
|
|
3
|
+
/** Replays only pi-context note operations from session custom entries. */
|
|
4
|
+
function isNoteOperation(data) {
|
|
5
|
+
if (typeof data !== "object" || data === null)
|
|
6
|
+
return false;
|
|
7
|
+
const op = data;
|
|
8
|
+
return ((op.op === "write" || op.op === "append") &&
|
|
9
|
+
typeof op.path === "string" &&
|
|
10
|
+
(op.text === undefined || typeof op.text === "string") &&
|
|
11
|
+
(op.stale === undefined || typeof op.stale === "boolean") &&
|
|
12
|
+
(op.text !== undefined || op.stale !== undefined) &&
|
|
13
|
+
typeof op.createdAt === "number" && Number.isFinite(new Date(op.createdAt).getTime()) &&
|
|
14
|
+
typeof op.updatedAt === "number" && Number.isFinite(new Date(op.updatedAt).getTime()));
|
|
15
|
+
}
|
|
16
|
+
export function notesFromSession(ctx) {
|
|
17
|
+
const files = new Map();
|
|
18
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
19
|
+
if (entry.type !== "custom" || entry.customType !== NOTE_TYPE || !isNoteOperation(entry.data))
|
|
20
|
+
continue;
|
|
21
|
+
const op = entry.data;
|
|
22
|
+
try {
|
|
23
|
+
assertVirtualPath(op.path);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const previous = files.get(op.path);
|
|
29
|
+
const hasText = op.text !== undefined;
|
|
30
|
+
// A mark-only operation needs an existing note to change; without one it is a no-op.
|
|
31
|
+
if (!hasText && !previous)
|
|
32
|
+
continue;
|
|
33
|
+
const text = hasText ? (op.op === "append" ? `${previous?.text ?? ""}${op.text}` : op.text) : previous.text;
|
|
34
|
+
if (Buffer.byteLength(text, "utf8") > MAX_NOTE_BYTES)
|
|
35
|
+
continue;
|
|
36
|
+
// Carrying text revives unless the call also marks stale; a mark-only op keeps its flag.
|
|
37
|
+
const stale = hasText ? op.stale ?? false : op.stale ?? previous.stale;
|
|
38
|
+
files.set(op.path, { text, stale, createdAt: previous?.createdAt ?? op.createdAt, updatedAt: op.updatedAt });
|
|
39
|
+
}
|
|
40
|
+
return files;
|
|
41
|
+
}
|
package/dist/src/notes/store.js
CHANGED
|
@@ -2,11 +2,10 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import { generateDiffString } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { assertGlobPattern, assertVirtualPath, globToRegExp } from "./model.js";
|
|
6
5
|
import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "../protocol.js";
|
|
7
6
|
import { isOrigin, isScope, parseNote, serializeNote, stripLeadingFrontmatter } from "./frontmatter.js";
|
|
8
|
-
import { addressFor } from "./address.js";
|
|
9
|
-
import { physicalPath, scopeDir } from "./paths.js";
|
|
7
|
+
import { addressFor, assertGlobPattern, assertVirtualPath, globToRegExp } from "./address.js";
|
|
8
|
+
import { agentSlug, modelSlug, namespaceSlugs, physicalPath, scopeDir } from "./paths.js";
|
|
10
9
|
import { earliestMatchOffsetChars } from "../tool-output.js";
|
|
11
10
|
/** Typed store refusal. `line_numbers` and `edit_index` are the edit error's addressing fields. */
|
|
12
11
|
export class NoteError extends Error {
|
|
@@ -21,10 +20,10 @@ export class NoteError extends Error {
|
|
|
21
20
|
this.edit_index = extra.edit_index;
|
|
22
21
|
}
|
|
23
22
|
}
|
|
24
|
-
const SCOPE_ORDER = ["session", "project", "
|
|
23
|
+
const SCOPE_ORDER = ["session", "project", "human", "agent", "model"];
|
|
25
24
|
function assertScope(value) {
|
|
26
25
|
if (!isScope(value))
|
|
27
|
-
throw new NoteError("invalid_scope", `scope must be one of session, project,
|
|
26
|
+
throw new NoteError("invalid_scope", `scope must be one of session, project, human, agent, model (got ${JSON.stringify(value)})`);
|
|
28
27
|
return value;
|
|
29
28
|
}
|
|
30
29
|
function assertOrigin(value) {
|
|
@@ -38,8 +37,13 @@ function walkMarkdown(dir, base = dir) {
|
|
|
38
37
|
try {
|
|
39
38
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
40
39
|
}
|
|
41
|
-
catch {
|
|
42
|
-
|
|
40
|
+
catch (error) {
|
|
41
|
+
// A home that has never been created is normal. Every other directory
|
|
42
|
+
// failure must reach the boot snapshot boundary instead of masquerading as
|
|
43
|
+
// an empty home.
|
|
44
|
+
if (typeof error === "object" && error !== null && error.code === "ENOENT")
|
|
45
|
+
return [];
|
|
46
|
+
throw error;
|
|
43
47
|
}
|
|
44
48
|
const paths = [];
|
|
45
49
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
@@ -87,6 +91,52 @@ function assertSerializedSize(content) {
|
|
|
87
91
|
function frontmatterOf(meta) {
|
|
88
92
|
return serializeNote(meta, "").slice(0, -2);
|
|
89
93
|
}
|
|
94
|
+
/** Named agent/model homes are read-only to whoever is not running there. */
|
|
95
|
+
function assertWritableHome(scope, who, ctx) {
|
|
96
|
+
if (who === undefined)
|
|
97
|
+
return;
|
|
98
|
+
const current = scope === "agent" ? agentSlug(ctx) : modelSlug(ctx);
|
|
99
|
+
if (who === current)
|
|
100
|
+
return;
|
|
101
|
+
const home = scope === "agent" ? `@agents/${who}/` : `@models/${who}/`;
|
|
102
|
+
throw new NoteError("invalid_scope", `${home} is not your home: writable homes are this session, @project/, @human/, @self/, and the current @model/ home`);
|
|
103
|
+
}
|
|
104
|
+
function homesForPattern(pattern) {
|
|
105
|
+
if (!pattern || !pattern.startsWith("@"))
|
|
106
|
+
return undefined;
|
|
107
|
+
const head = /^@([^/]+)\//.exec(pattern)?.[1];
|
|
108
|
+
if (head === "project")
|
|
109
|
+
return [{ scope: "project" }];
|
|
110
|
+
if (head === "human")
|
|
111
|
+
return [{ scope: "human" }];
|
|
112
|
+
if (head === "self")
|
|
113
|
+
return [{ scope: "agent" }];
|
|
114
|
+
if (head === "model")
|
|
115
|
+
return [{ scope: "model" }];
|
|
116
|
+
if (head === "agents" || head === "models") {
|
|
117
|
+
const scope = head === "agents" ? "agent" : "model";
|
|
118
|
+
const name = pattern.slice(head.length + 2).split("/")[0] ?? "";
|
|
119
|
+
if (name.length > 0 && !/[*?]/.test(name))
|
|
120
|
+
return [{ scope, who: name }];
|
|
121
|
+
return namespaceSlugs(head).map((who) => ({ scope, who }));
|
|
122
|
+
}
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
/** Relative pattern heads resolve to canonical names, so they match rendered addresses. */
|
|
126
|
+
function normalizePattern(pattern, ctx) {
|
|
127
|
+
if (!pattern)
|
|
128
|
+
return pattern;
|
|
129
|
+
if (pattern.startsWith("@self/"))
|
|
130
|
+
return `@agents/${agentSlug(ctx)}/${pattern.slice("@self/".length)}`;
|
|
131
|
+
if (pattern.startsWith("@model/"))
|
|
132
|
+
return `@models/${modelSlug(ctx)}/${pattern.slice("@model/".length)}`;
|
|
133
|
+
return pattern;
|
|
134
|
+
}
|
|
135
|
+
function homesFor(ctx, opts) {
|
|
136
|
+
if (opts.scope !== undefined)
|
|
137
|
+
return [{ scope: opts.scope, who: opts.who }];
|
|
138
|
+
return homesForPattern(opts.pattern) ?? SCOPE_ORDER.map((scope) => ({ scope }));
|
|
139
|
+
}
|
|
90
140
|
/** Line numbers (1-based) of every occurrence of `needle` in `body`. */
|
|
91
141
|
function matchLineNumbers(body, needle) {
|
|
92
142
|
const lines = [];
|
|
@@ -105,8 +155,9 @@ export function writeNote(ctx, vpath, body, opts) {
|
|
|
105
155
|
assertVirtualPath(vpath);
|
|
106
156
|
assertWritablePath(vpath);
|
|
107
157
|
const scope = assertScope(opts.scope);
|
|
158
|
+
assertWritableHome(scope, opts.who, ctx);
|
|
108
159
|
const origin = assertOrigin(opts.origin);
|
|
109
|
-
const path = physicalPath(scope, vpath, ctx);
|
|
160
|
+
const path = physicalPath(scope, vpath, ctx, opts.who);
|
|
110
161
|
const now = Date.now();
|
|
111
162
|
const cleanBody = stripLeadingFrontmatter(body);
|
|
112
163
|
const existing = existsSync(path) ? parseNote(readFileSync(path, "utf8"), now).meta : undefined;
|
|
@@ -131,9 +182,9 @@ export function writeNote(ctx, vpath, body, opts) {
|
|
|
131
182
|
return { meta };
|
|
132
183
|
}
|
|
133
184
|
/** Dream harness mutation: metadata changes still use the store's atomic writer. */
|
|
134
|
-
export function updateNoteMeta(ctx, vpath, scope, mutate) {
|
|
185
|
+
export function updateNoteMeta(ctx, vpath, scope, mutate, who) {
|
|
135
186
|
assertVirtualPath(vpath);
|
|
136
|
-
const path = physicalPath(scope, vpath, ctx);
|
|
187
|
+
const path = physicalPath(scope, vpath, ctx, who);
|
|
137
188
|
if (!existsSync(path))
|
|
138
189
|
throw new NoteError("not_found", `note not found: ${vpath}`);
|
|
139
190
|
const parsed = parseNote(readFileSync(path, "utf8"));
|
|
@@ -146,14 +197,15 @@ export function updateNoteMeta(ctx, vpath, scope, mutate) {
|
|
|
146
197
|
return { meta, body: parsed.body };
|
|
147
198
|
}
|
|
148
199
|
/** Apply body-only edits against one explicit home; origin and stale are its metadata setters. */
|
|
149
|
-
export function editNote(ctx, vpath, scope, edits, opts = {}) {
|
|
200
|
+
export function editNote(ctx, vpath, scope, edits, opts = {}, who) {
|
|
150
201
|
assertVirtualPath(vpath);
|
|
151
202
|
assertWritablePath(vpath);
|
|
203
|
+
assertWritableHome(scope, who, ctx);
|
|
152
204
|
const operations = edits ?? [];
|
|
153
205
|
if (operations.length === 0 && opts.origin === undefined && opts.stale === undefined) {
|
|
154
206
|
throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of origin, stale");
|
|
155
207
|
}
|
|
156
|
-
const path = physicalPath(scope, vpath, ctx);
|
|
208
|
+
const path = physicalPath(scope, vpath, ctx, who);
|
|
157
209
|
if (!existsSync(path))
|
|
158
210
|
throw new NoteError("not_found", "note not found");
|
|
159
211
|
const raw = readFileSync(path, "utf8");
|
|
@@ -216,9 +268,9 @@ function accessedMeta(meta, scope, now) {
|
|
|
216
268
|
return next;
|
|
217
269
|
}
|
|
218
270
|
/** Read a note and, as a side effect, bump last_accessed/access_count in the file. */
|
|
219
|
-
export function readNote(ctx, vpath, scope) {
|
|
271
|
+
export function readNote(ctx, vpath, scope, who) {
|
|
220
272
|
assertVirtualPath(vpath);
|
|
221
|
-
const path = physicalPath(scope, vpath, ctx);
|
|
273
|
+
const path = physicalPath(scope, vpath, ctx, who);
|
|
222
274
|
if (!existsSync(path))
|
|
223
275
|
return undefined;
|
|
224
276
|
const now = Date.now();
|
|
@@ -230,12 +282,13 @@ export function readNote(ctx, vpath, scope) {
|
|
|
230
282
|
}
|
|
231
283
|
/** Merged rows across homes, most recently updated first (address breaks ties). */
|
|
232
284
|
export function listNotes(ctx, opts = {}) {
|
|
233
|
-
const matcher = matcherFor(opts.pattern);
|
|
285
|
+
const matcher = matcherFor(normalizePattern(opts.pattern, ctx));
|
|
234
286
|
const rows = [];
|
|
235
|
-
for (const
|
|
236
|
-
const
|
|
287
|
+
for (const home of homesFor(ctx, opts)) {
|
|
288
|
+
const scope = home.scope;
|
|
289
|
+
const root = scopeDir(scope, ctx, home.who);
|
|
237
290
|
for (const path of walkMarkdown(root)) {
|
|
238
|
-
const address = addressFor(scope, path);
|
|
291
|
+
const address = addressFor(ctx, scope, path, home.who);
|
|
239
292
|
if (matcher && !matcher.test(address))
|
|
240
293
|
continue;
|
|
241
294
|
const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
|
|
@@ -248,12 +301,13 @@ export function listNotes(ctx, opts = {}) {
|
|
|
248
301
|
}
|
|
249
302
|
/** Case-sensitive literal substring search over note bodies, with a match address per line. */
|
|
250
303
|
export function searchNotes(ctx, queries, opts = {}) {
|
|
251
|
-
const matcher = matcherFor(opts.pattern);
|
|
304
|
+
const matcher = matcherFor(normalizePattern(opts.pattern, ctx));
|
|
252
305
|
const rows = [];
|
|
253
|
-
for (const
|
|
254
|
-
const
|
|
306
|
+
for (const home of homesFor(ctx, opts)) {
|
|
307
|
+
const scope = home.scope;
|
|
308
|
+
const root = scopeDir(scope, ctx, home.who);
|
|
255
309
|
for (const path of walkMarkdown(root)) {
|
|
256
|
-
const address = addressFor(scope, path);
|
|
310
|
+
const address = addressFor(ctx, scope, path, home.who);
|
|
257
311
|
if (matcher && !matcher.test(address))
|
|
258
312
|
continue;
|
|
259
313
|
const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
|