@astrosheep/pi-context 0.25.2 → 0.26.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 +88 -7
- package/dist/build-info.json +2 -2
- package/dist/extension.js +635 -381
- package/dist/src/context/boot.d.ts +24 -0
- package/dist/src/context/boot.js +33 -24
- package/dist/src/context/budget.d.ts +9 -0
- package/dist/src/context/budget.js +51 -28
- package/dist/src/context/context-window.d.ts +41 -0
- package/dist/src/context/context-window.js +16 -1
- package/dist/src/context/prompts.d.ts +20 -0
- package/dist/src/context/prompts.js +1 -1
- package/dist/src/context/reset-artifacts.d.ts +26 -0
- package/dist/src/context/reset-artifacts.js +18 -17
- package/dist/src/context/reset-lifecycle.d.ts +89 -0
- package/dist/src/context/reset-lifecycle.js +103 -75
- package/dist/src/context/runtime.d.ts +3 -0
- package/dist/src/context/runtime.js +53 -21
- package/dist/src/context/thresholds.d.ts +33 -0
- package/dist/src/context/thresholds.js +1 -1
- package/dist/src/dream/cli.d.ts +10 -0
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/dream/doctor.d.ts +2 -0
- package/dist/src/dream/doctor.js +6 -2
- package/dist/src/dream/gates.d.ts +10 -0
- package/dist/src/dream/git.d.ts +21 -0
- package/dist/src/dream/lock.d.ts +31 -0
- package/dist/src/dream/runner.d.ts +30 -0
- package/dist/src/dream/settings.d.ts +16 -0
- package/dist/src/history/history-tools.d.ts +2 -0
- package/dist/src/history/history.d.ts +57 -0
- package/dist/src/index.d.ts +39 -0
- package/dist/src/index.js +4 -4
- package/dist/src/notes/address.d.ts +26 -0
- package/dist/src/notes/address.js +8 -14
- package/dist/src/notes/constants.d.ts +3 -0
- package/dist/src/notes/constants.js +3 -0
- package/dist/src/notes/context.d.ts +10 -0
- package/dist/src/notes/context.js +33 -0
- package/dist/src/notes/frontmatter.d.ts +46 -0
- package/dist/src/notes/frontmatter.js +10 -5
- package/dist/src/notes/index.d.ts +4 -0
- package/dist/src/notes/index.js +2 -0
- package/dist/src/notes/paths.d.ts +21 -0
- package/dist/src/notes/paths.js +72 -76
- package/dist/src/notes/store.d.ts +94 -0
- package/dist/src/notes/store.js +276 -236
- package/dist/src/pi/notes/adapter.d.ts +12 -0
- package/dist/src/pi/notes/adapter.js +39 -0
- package/dist/src/pi/notes/session-replay.d.ts +16 -0
- package/dist/src/{notes → pi/notes}/session-replay.js +2 -2
- package/dist/src/pi/notes/snapshot.d.ts +33 -0
- package/dist/src/{notes/notes-snapshot.js → pi/notes/snapshot.js} +11 -3
- package/dist/src/pi/notes/tools.d.ts +2 -0
- package/dist/src/{notes → pi/notes}/tools.js +24 -21
- package/dist/src/protocol.d.ts +41 -0
- package/dist/src/protocol.js +4 -6
- package/dist/src/session-reader.d.ts +5 -0
- package/dist/src/settings.d.ts +6 -0
- package/dist/src/text-match.d.ts +5 -0
- package/dist/src/text-match.js +15 -0
- package/dist/src/tool-output.d.ts +97 -0
- package/dist/src/tool-output.js +1 -15
- package/dist/src/tool-schema.d.ts +17 -0
- package/dist/test/agent-loop.test.d.ts +1 -0
- package/dist/test/agent-loop.test.js +387 -20
- package/dist/test/boot.integration.test.d.ts +1 -0
- package/dist/test/boot.integration.test.js +59 -35
- package/dist/test/budget-settings.integration.test.d.ts +1 -0
- package/dist/test/budget-settings.integration.test.js +28 -12
- package/dist/test/doctor.test.d.ts +1 -0
- package/dist/test/doctor.test.js +10 -2
- package/dist/test/dream-skill.test.d.ts +1 -0
- package/dist/test/dream-skill.test.js +69 -0
- package/dist/test/dream.test.d.ts +1 -0
- package/dist/test/helpers/extension-test-environment.d.ts +1 -0
- package/dist/test/helpers/extension-test-environment.js +9 -0
- package/dist/test/helpers/extension.d.ts +104 -0
- package/dist/test/helpers/extension.js +7 -81
- package/dist/test/helpers/notes.d.ts +6 -0
- package/dist/test/helpers/notes.js +13 -0
- package/dist/test/history.integration.test.d.ts +1 -0
- package/dist/test/history.integration.test.js +17 -19
- package/dist/test/notes-library.test.d.ts +1 -0
- package/dist/test/notes-library.test.js +128 -0
- package/dist/test/notes.integration.test.d.ts +1 -0
- package/dist/test/notes.integration.test.js +51 -55
- package/dist/test/notes.test.d.ts +1 -0
- package/dist/test/notes.test.js +162 -34
- package/dist/test/reset-lifecycle.test.d.ts +1 -0
- package/dist/test/reset-lifecycle.test.js +142 -85
- package/docs/architecture.md +8 -8
- package/docs/reset-lifecycle.md +63 -79
- package/package.json +35 -2
- package/playbook.md +33 -32
- package/skills/dream/SKILL.md +12 -0
- package/src/context/boot.ts +44 -25
- package/src/context/budget.ts +52 -27
- package/src/context/context-window.ts +16 -1
- package/src/context/prompts.ts +2 -2
- package/src/context/reset-artifacts.ts +26 -24
- package/src/context/reset-lifecycle.ts +117 -111
- package/src/context/runtime.ts +50 -22
- package/src/context/thresholds.ts +1 -1
- package/src/dream/cli.ts +1 -1
- package/src/dream/doctor.ts +5 -2
- package/src/index.ts +4 -4
- package/src/notes/address.ts +9 -15
- package/src/notes/constants.ts +3 -0
- package/src/notes/context.ts +40 -0
- package/src/notes/frontmatter.ts +18 -12
- package/src/notes/index.ts +22 -0
- package/src/notes/paths.ts +64 -78
- package/src/notes/store.ts +289 -238
- package/src/pi/notes/adapter.ts +44 -0
- package/src/{notes → pi/notes}/session-replay.ts +3 -3
- package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
- package/src/{notes → pi/notes}/tools.ts +25 -23
- package/src/protocol.ts +5 -6
- package/src/text-match.ts +13 -0
- package/src/tool-output.ts +2 -14
package/src/context/runtime.ts
CHANGED
|
@@ -2,10 +2,11 @@ import { getCurrentSystemMessage, Type } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import { VERSION, defineTool, type ExtensionAPI, type ExtensionContext, type SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { registerBudget } from "./budget.js";
|
|
4
4
|
import { output } from "../tool-output.js";
|
|
5
|
-
import { migrateLegacyHomes } from "../notes/
|
|
6
|
-
import { currentReset, isWindowMarker, projectRootWindow, projectWindow, rootWindowId } from "./context-window.js";
|
|
5
|
+
import { migrateLegacyHomes } from "../pi/notes/adapter.js";
|
|
6
|
+
import { currentReset, currentWindowId, isCheckpointBackedReset, isWindowBoot, isWindowMarker, projectRootWindow, projectWindow, rootWindowId, type WindowMarker } from "./context-window.js";
|
|
7
7
|
import { registerResetLifecycle } from "./reset-lifecycle.js";
|
|
8
|
-
import { buildResetDrafts,
|
|
8
|
+
import { buildResetDrafts, resetTailCommitted } from "./reset-artifacts.js";
|
|
9
|
+
import { BOOT_TYPE, WARNING_CONTENT, WARNING_TYPE } from "../protocol.js";
|
|
9
10
|
import { ensureBoot, type IncompleteNotesNotifier } from "./boot.js";
|
|
10
11
|
|
|
11
12
|
declare const __PI_CONTEXT_BUILD__: { version: string; sourceHash: string };
|
|
@@ -22,6 +23,7 @@ function branchHasWindowMarker(ctx: ExtensionContext, fromId?: string): boolean
|
|
|
22
23
|
/** Register the context-window runtime and its context-owned commands/tools. */
|
|
23
24
|
export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsManager): void {
|
|
24
25
|
let enabled = true;
|
|
26
|
+
let lifecycleGeneration = 0;
|
|
25
27
|
let missingBootNotice: string | undefined;
|
|
26
28
|
const incompleteNotesNotified = new Set<string>();
|
|
27
29
|
const pendingResetNotices = new Set<string>();
|
|
@@ -32,8 +34,8 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
|
|
|
32
34
|
if (pendingResetNotices.size === 0) return;
|
|
33
35
|
const branch = ctx.sessionManager.getBranch();
|
|
34
36
|
for (const windowId of pendingResetNotices) {
|
|
35
|
-
const marker = branch.find((entry) => isWindowMarker(entry) && entry.data.windowId === windowId);
|
|
36
|
-
if (!marker || !resetTailCommitted(ctx, marker.id, windowId)) continue;
|
|
37
|
+
const marker = branch.find((entry): entry is WindowMarker => isWindowMarker(entry) && entry.data.windowId === windowId);
|
|
38
|
+
if (!marker || !isCheckpointBackedReset(ctx, marker) || !resetTailCommitted(ctx, marker.id, windowId)) continue;
|
|
37
39
|
pendingResetNotices.delete(windowId);
|
|
38
40
|
ctx.ui.notify(`pi-context: memory cleared · ${windowId}`, "info");
|
|
39
41
|
}
|
|
@@ -52,18 +54,24 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
|
|
|
52
54
|
const migrationWarning = migrateLegacyHomes();
|
|
53
55
|
if (migrationWarning) console.warn(`pi-context: ${migrationWarning}`);
|
|
54
56
|
|
|
55
|
-
const budget = registerBudget(pi, () => enabled, settingsManager);
|
|
57
|
+
const budget = registerBudget(pi, () => enabled, settingsManager, (windowId) => resets.closeOut(windowId, "automatic"));
|
|
56
58
|
|
|
57
|
-
pi.on("session_start", (_event, ctx) => {
|
|
59
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
60
|
+
const generation = ++lifecycleGeneration;
|
|
58
61
|
if (!enabled) return;
|
|
59
62
|
missingBootNotice = undefined;
|
|
60
63
|
pendingResetNotices.clear();
|
|
61
|
-
ensureBoot(pi, ctx, notifyIncompleteNotes);
|
|
64
|
+
await ensureBoot(pi, ctx, notifyIncompleteNotes, () => generation === lifecycleGeneration && enabled);
|
|
62
65
|
});
|
|
63
|
-
pi.on("session_tree", (_event, ctx) => {
|
|
66
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
67
|
+
const generation = ++lifecycleGeneration;
|
|
64
68
|
missingBootNotice = undefined;
|
|
65
69
|
pendingResetNotices.clear();
|
|
66
|
-
if (enabled) ensureBoot(pi, ctx, notifyIncompleteNotes);
|
|
70
|
+
if (enabled) await ensureBoot(pi, ctx, notifyIncompleteNotes, () => generation === lifecycleGeneration && enabled);
|
|
71
|
+
});
|
|
72
|
+
pi.on("session_shutdown", () => {
|
|
73
|
+
lifecycleGeneration++;
|
|
74
|
+
pendingResetNotices.clear();
|
|
67
75
|
});
|
|
68
76
|
|
|
69
77
|
// Pi's branch summarizer receives raw entries and bypasses context_with_system. Do not
|
|
@@ -81,7 +89,11 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
|
|
|
81
89
|
const reset = currentReset(ctx);
|
|
82
90
|
const windowId = reset?.data.windowId ?? rootWindowId(ctx.sessionManager.getSessionId());
|
|
83
91
|
try {
|
|
84
|
-
|
|
92
|
+
if (reset) {
|
|
93
|
+
if (!event.messages.some((message) => isWindowBoot(message, windowId))) throw new Error(`Missing boot for context window ${windowId}`);
|
|
94
|
+
return { messages: isCheckpointBackedReset(ctx, reset) ? event.messages : projectWindow(event.messages, windowId) };
|
|
95
|
+
}
|
|
96
|
+
return { messages: projectRootWindow(event.messages, windowId) };
|
|
85
97
|
} catch (error) {
|
|
86
98
|
if (missingBootNotice !== windowId) {
|
|
87
99
|
missingBootNotice = windowId;
|
|
@@ -101,9 +113,12 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
|
|
|
101
113
|
const arg = args.trim().toLowerCase();
|
|
102
114
|
if (arg === "on") {
|
|
103
115
|
enabled = true;
|
|
104
|
-
|
|
116
|
+
const generation = ++lifecycleGeneration;
|
|
117
|
+
await ensureBoot(pi, cmdCtx, notifyIncompleteNotes, () => generation === lifecycleGeneration && enabled);
|
|
105
118
|
} else if (arg === "off") {
|
|
106
119
|
enabled = false;
|
|
120
|
+
lifecycleGeneration++;
|
|
121
|
+
pendingResetNotices.clear();
|
|
107
122
|
budget.clear();
|
|
108
123
|
resets.clear();
|
|
109
124
|
} else if (arg !== "") {
|
|
@@ -115,16 +130,25 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
|
|
|
115
130
|
});
|
|
116
131
|
|
|
117
132
|
pi.registerCommand("wipe-memory", {
|
|
118
|
-
description: "
|
|
133
|
+
description: "Ask the agent to close out its notes, then start a fresh context window",
|
|
119
134
|
handler: async (_args, cmdCtx) => {
|
|
120
135
|
if (!enabled) {
|
|
121
136
|
cmdCtx.ui.notify("pi-context: /wipe-memory requires /pi-context on.", "error");
|
|
122
137
|
return;
|
|
123
138
|
}
|
|
139
|
+
const requestedWindowId = currentWindowId(cmdCtx);
|
|
140
|
+
await cmdCtx.waitForIdle();
|
|
141
|
+
if (!enabled || currentWindowId(cmdCtx) !== requestedWindowId) return;
|
|
142
|
+
const armed = resets.closeOut(requestedWindowId, "manual");
|
|
143
|
+
if (armed === "already-pending") return;
|
|
144
|
+
try {
|
|
145
|
+
pi.sendMessage({ customType: WARNING_TYPE, content: WARNING_CONTENT, display: false }, { triggerTurn: true });
|
|
146
|
+
} catch (error) {
|
|
147
|
+
resets.clear();
|
|
148
|
+
cmdCtx.ui.notify(`pi-context: could not start manual close-out (${String(error)}).`, "error");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
124
151
|
await cmdCtx.waitForIdle();
|
|
125
|
-
if (!enabled) return;
|
|
126
|
-
resets.clear();
|
|
127
|
-
notifyCommittedResets(cmdCtx, persistManualReset(pi, cmdCtx, notifyIncompleteNotes));
|
|
128
152
|
},
|
|
129
153
|
});
|
|
130
154
|
|
|
@@ -133,18 +157,22 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
|
|
|
133
157
|
label: "Wipe memory",
|
|
134
158
|
description: "Wipe your in-context memory and start a fresh context window. Your session, notes, and history survive.",
|
|
135
159
|
parameters: Type.Object({}, { additionalProperties: false }),
|
|
136
|
-
async execute() {
|
|
160
|
+
async execute(_id, _params, _signal, _update, ctx) {
|
|
137
161
|
if (!enabled) return output({ error: "pi-context is off (/pi-context on to enable)" });
|
|
138
|
-
return output({ status: resets.request() }, undefined, true);
|
|
162
|
+
return output({ status: resets.request(currentWindowId(ctx)) }, undefined, true);
|
|
139
163
|
},
|
|
140
164
|
}));
|
|
141
165
|
|
|
142
166
|
const resets = registerResetLifecycle(pi, {
|
|
143
167
|
isEnabled: () => enabled,
|
|
144
|
-
buildReset: (ctx) =>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
168
|
+
buildReset: (ctx, isCurrent) => buildResetDrafts(ctx, notifyIncompleteNotes, isCurrent),
|
|
169
|
+
getLifecycleGeneration: () => lifecycleGeneration,
|
|
170
|
+
onResetReady: (_ctx, drafts) => {
|
|
171
|
+
const boot = drafts.find((draft) => draft.type === "custom_message" && draft.customType === BOOT_TYPE);
|
|
172
|
+
if (boot?.type === "custom_message" && boot.details && typeof boot.details === "object") {
|
|
173
|
+
const windowId = (boot.details as { windowId?: unknown }).windowId;
|
|
174
|
+
if (typeof windowId === "string") pendingResetNotices.add(windowId);
|
|
175
|
+
}
|
|
148
176
|
},
|
|
149
177
|
budget,
|
|
150
178
|
});
|
|
@@ -24,7 +24,7 @@ export function deriveThresholds(reserveTokens: number, margins: PiContextSettin
|
|
|
24
24
|
else {
|
|
25
25
|
const parsed = validMargin(margins.reminderMarginTokens);
|
|
26
26
|
if (parsed === undefined) {
|
|
27
|
-
warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default
|
|
27
|
+
warnings.push(`pi-context: ${reminderKey} must be a positive integer; using the default reminder margin.`);
|
|
28
28
|
reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
|
|
29
29
|
} else reminderMargin = parsed;
|
|
30
30
|
}
|
package/src/dream/cli.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { loadPlaybook, runDreamer, type DreamerSessionFactory, type DreamResult,
|
|
|
8
8
|
import { gitCommit } from "./git.js";
|
|
9
9
|
import { readDreamerSettings, type DreamerSetting } from "./settings.js";
|
|
10
10
|
import { doctor } from "./doctor.js";
|
|
11
|
-
import { notesRoot } from "../notes/
|
|
11
|
+
import { notesRoot } from "../pi/notes/adapter.js";
|
|
12
12
|
|
|
13
13
|
function args(argv: string[]) { const out: Record<string, string | boolean> = {}; for (let i=0;i<argv.length;i++) { const a=argv[i]!; if (a === "--force" || a === "--help") out[a.slice(2)] = true; else if (a.startsWith("--")) out[a.slice(2)] = argv[++i] ?? ""; } return out; }
|
|
14
14
|
function packageRoot(): string {
|
package/src/dream/doctor.ts
CHANGED
|
@@ -27,10 +27,13 @@ export function doctor(home: string): string[] {
|
|
|
27
27
|
if (fields.has(field[1]!)) report(path, `duplicate metadata key ${field[1]}; keep one value`);
|
|
28
28
|
fields.set(field[1]!, field[2]!.replace(/^(["'])(.*)\1$/, "$2"));
|
|
29
29
|
}
|
|
30
|
-
for (const [key, valid] of Object.entries({ origin: /^(user|self|external)$/, status: /^(active|superseded|pending|archived)$/, stale: /^(true|false)$/,
|
|
30
|
+
for (const [key, valid] of Object.entries({ origin: /^(user|self|external)$/, status: /^(active|superseded|pending|archived)$/, stale: /^(true|false)$/, accessCount: /^\d+$/ })) {
|
|
31
31
|
if (!valid.test(fields.get(key) ?? "")) report(path, `missing/invalid ${key}; repair frontmatter`);
|
|
32
32
|
}
|
|
33
|
-
for (const
|
|
33
|
+
for (const [legacy, current] of [["created_at", "createdAt"], ["updated_at", "updatedAt"], ["last_accessed", "lastAccessed"], ["access_count", "accessCount"], ["source_window", "sourceWindow"], ["recurrence_count", "recurrenceCount"], ["recurrence_windows", "recurrenceWindows"]]) {
|
|
34
|
+
if (fields.has(legacy)) report(path, `legacy metadata key ${legacy}; manually migrate to ${current}`);
|
|
35
|
+
}
|
|
36
|
+
for (const key of ["createdAt", "updatedAt", "lastAccessed"]) {
|
|
34
37
|
const value = fields.get(key);
|
|
35
38
|
if (!value || !Number.isFinite(Date.parse(value))) report(path, `missing/invalid ${key}; use an ISO timestamp`);
|
|
36
39
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { VERSION, type ExtensionAPI, type ExtensionFactory, type SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { registerHistoryTools } from "./history/history-tools.js";
|
|
3
|
-
import { registerNotesTools } from "./notes/tools.js";
|
|
3
|
+
import { registerNotesTools } from "./pi/notes/tools.js";
|
|
4
4
|
import { deriveThresholds } from "./context/thresholds.js";
|
|
5
5
|
import { registerContext } from "./context/runtime.js";
|
|
6
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";
|
|
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, WARNING_CONTENT } from "./protocol.js";
|
|
8
8
|
import { assertVirtualPath } from "./notes/address.js";
|
|
9
9
|
export { historyFromSession } from "./history/history.js";
|
|
10
|
-
export { notesFromSession } from "./notes/session-replay.js";
|
|
10
|
+
export { notesFromSession } from "./pi/notes/session-replay.js";
|
|
11
11
|
|
|
12
12
|
function registerPiContext(pi: ExtensionAPI, settingsManager?: SettingsManager): void {
|
|
13
13
|
const [major, minor] = VERSION.split(".").map(Number);
|
|
@@ -32,4 +32,4 @@ export default function piContext(pi: ExtensionAPI): void {
|
|
|
32
32
|
registerPiContext(pi);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
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 };
|
|
35
|
+
export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, CONTINUATION_TYPE, WARNING_PROMPT, WARNING_CONTENT, 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 };
|
package/src/notes/address.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
1
|
+
import type { NotesContext } from "./context.js";
|
|
2
|
+
import { SLUG_PATTERN, type Scope } from "./paths.js";
|
|
3
3
|
|
|
4
4
|
export type NoteAddress = { scope: Scope; path: string; who?: string };
|
|
5
5
|
|
|
@@ -39,7 +39,7 @@ export function globToRegExp(pattern: string): RegExp {
|
|
|
39
39
|
return new RegExp(`${source}$`);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/** Glob patterns are not virtual paths (`*` is legal), so they get their own guard
|
|
42
|
+
/** Glob patterns are not virtual paths (`*` is legal), so they get their own guard. */
|
|
43
43
|
export function assertGlobPattern(value: unknown): string | undefined {
|
|
44
44
|
if (value === undefined || value === null || value === "") return undefined;
|
|
45
45
|
if (typeof value !== "string") throw new Error("glob pattern must be a string");
|
|
@@ -48,12 +48,8 @@ export function assertGlobPattern(value: unknown): string | undefined {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* Decode
|
|
52
|
-
*
|
|
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.
|
|
51
|
+
* Decode one public note address into its physical home and virtual path. @self and @model
|
|
52
|
+
* are relative; explicit agents/models addresses always name a canonical slug.
|
|
57
53
|
*/
|
|
58
54
|
export function assertAddress(value: unknown): NoteAddress {
|
|
59
55
|
if (typeof value !== "string") throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
|
|
@@ -87,13 +83,11 @@ export function assertAddress(value: unknown): NoteAddress {
|
|
|
87
83
|
return { scope, path, who };
|
|
88
84
|
}
|
|
89
85
|
|
|
90
|
-
/** Render a virtual path in its one unambiguous public address form.
|
|
91
|
-
|
|
92
|
-
* name, so listings alone tell every home apart. */
|
|
93
|
-
export function addressFor(ctx: ExtensionContext, scope: Scope, path: string, who?: string): string {
|
|
86
|
+
/** Render a virtual path in its one unambiguous public address form. */
|
|
87
|
+
export function addressFor(context: NotesContext, scope: Scope, path: string, who?: string): string {
|
|
94
88
|
if (scope === "session") return path;
|
|
95
89
|
if (scope === "project") return `@project/${path}`;
|
|
96
90
|
if (scope === "human") return `@human/${path}`;
|
|
97
|
-
if (scope === "agent") return `@agents/${who ??
|
|
98
|
-
return `@models/${who ??
|
|
91
|
+
if (scope === "agent") return `@agents/${who ?? context.agent}/${path}`;
|
|
92
|
+
return `@models/${who ?? context.model}/${path}`;
|
|
99
93
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { SLUG_PATTERN } from "./paths.js";
|
|
3
|
+
|
|
4
|
+
/** Explicit, host-neutral identity for one notes store. */
|
|
5
|
+
export type NotesContext = Readonly<{
|
|
6
|
+
home: string;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
projectKey: string;
|
|
9
|
+
agent: string;
|
|
10
|
+
model: string;
|
|
11
|
+
}>;
|
|
12
|
+
|
|
13
|
+
function requireString(field: string, value: unknown): string {
|
|
14
|
+
if (typeof value !== "string" || value.length === 0) throw new TypeError(`notes context ${field} must be a non-empty string`);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertDirectoryComponent(field: string, value: string): void {
|
|
19
|
+
if (value === "." || value === ".." || /[\\/\0:]/.test(value)) {
|
|
20
|
+
throw new TypeError(`notes context ${field} must be a safe single directory component`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Validate and snapshot caller identity; no global or environment defaults are consulted. */
|
|
25
|
+
export function snapshotNotesContext(value: NotesContext): NotesContext {
|
|
26
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("notes context must be a plain object");
|
|
27
|
+
const prototype = Object.getPrototypeOf(value);
|
|
28
|
+
if (prototype !== Object.prototype && prototype !== null) throw new TypeError("notes context must be a plain object");
|
|
29
|
+
const homeValue = requireString("home", value.home);
|
|
30
|
+
const sessionId = requireString("sessionId", value.sessionId);
|
|
31
|
+
const projectKey = requireString("projectKey", value.projectKey);
|
|
32
|
+
const agent = requireString("agent", value.agent);
|
|
33
|
+
const model = requireString("model", value.model);
|
|
34
|
+
assertDirectoryComponent("sessionId", sessionId);
|
|
35
|
+
assertDirectoryComponent("projectKey", projectKey);
|
|
36
|
+
if (!SLUG_PATTERN.test(agent)) throw new TypeError("notes context agent must be a canonical lowercase slug");
|
|
37
|
+
if (!SLUG_PATTERN.test(model)) throw new TypeError("notes context model must be a canonical lowercase slug");
|
|
38
|
+
const home = resolve(homeValue);
|
|
39
|
+
return Object.freeze({ home, sessionId, projectKey, agent, model });
|
|
40
|
+
}
|
package/src/notes/frontmatter.ts
CHANGED
|
@@ -13,23 +13,26 @@ export type NoteMeta = {
|
|
|
13
13
|
origin: Origin;
|
|
14
14
|
status: NoteStatus;
|
|
15
15
|
stale: boolean;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
createdAt: number;
|
|
17
|
+
updatedAt: number;
|
|
18
|
+
lastAccessed: number;
|
|
19
|
+
accessCount: number;
|
|
20
|
+
sourceWindow?: string;
|
|
21
21
|
supersedes?: string;
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
recurrenceCount?: number;
|
|
23
|
+
recurrenceWindows?: string[];
|
|
24
|
+
/** Project ownership on newly-created session notes; legacy/invalid values are preserved as-is. */
|
|
25
|
+
project?: unknown;
|
|
24
26
|
[key: string]: unknown;
|
|
25
27
|
};
|
|
26
28
|
|
|
27
29
|
const SCOPES: readonly Scope[] = ["session", "project", "human", "agent", "model"];
|
|
28
30
|
const ORIGINS: readonly Origin[] = ["user", "self", "external"];
|
|
29
31
|
const STATUSES: readonly NoteStatus[] = ["active", "superseded", "pending", "archived"];
|
|
30
|
-
const TIMESTAMP_KEYS = ["
|
|
32
|
+
const TIMESTAMP_KEYS = ["createdAt", "updatedAt", "lastAccessed"] as const;
|
|
33
|
+
const LEGACY_KNOWN_KEYS = ["created_at", "updated_at", "last_accessed", "access_count", "source_window", "recurrence_count", "recurrence_windows"] as const;
|
|
31
34
|
/** Emission order, exactly the Design's key list. */
|
|
32
|
-
const KNOWN_KEYS = ["origin", "status", "stale", "
|
|
35
|
+
const KNOWN_KEYS = ["origin", "status", "stale", "createdAt", "updatedAt", "lastAccessed", "accessCount", "sourceWindow", "supersedes", "recurrenceCount", "recurrenceWindows"] as const;
|
|
33
36
|
|
|
34
37
|
const pad2 = (value: number) => String(value).padStart(2, "0");
|
|
35
38
|
|
|
@@ -96,7 +99,7 @@ function parseFrontmatter(raw: string): { fields: Record<string, unknown>; body:
|
|
|
96
99
|
}
|
|
97
100
|
}
|
|
98
101
|
if (close === -1) return { fields: {}, body: raw };
|
|
99
|
-
const fields
|
|
102
|
+
const fields = Object.create(null) as Record<string, unknown>;
|
|
100
103
|
for (let index = 1; index < close; index++) {
|
|
101
104
|
const line = lines[index]!;
|
|
102
105
|
const match = /^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/.exec(line);
|
|
@@ -122,10 +125,13 @@ function parseFrontmatter(raw: string): { fields: Record<string, unknown>; body:
|
|
|
122
125
|
|
|
123
126
|
/**
|
|
124
127
|
* Parse a note file. Missing known keys take the Design defaults (status active, stale false,
|
|
125
|
-
*
|
|
128
|
+
* accessCount 0, timestamps now); unknown keys are carried through untouched. Known
|
|
129
|
+
* snake_case metadata is refused because it requires the explicit manual migration.
|
|
126
130
|
*/
|
|
127
131
|
export function parseNote(raw: string, now = Date.now()): { meta: NoteMeta; body: string } {
|
|
128
132
|
const { fields, body } = parseFrontmatter(raw);
|
|
133
|
+
const legacyKeys = LEGACY_KNOWN_KEYS.filter((key) => Object.hasOwn(fields, key));
|
|
134
|
+
if (legacyKeys.length > 0) throw new Error(`legacy note metadata ${legacyKeys.join(", ")} requires manual migration to camelCase before this note can be used`);
|
|
129
135
|
const meta = { ...fields } as Record<string, unknown>;
|
|
130
136
|
// scope is a legacy on-disk field: store callers derive it from the file's home and
|
|
131
137
|
// overwrite it after parsing, so an absent or outdated value just falls back.
|
|
@@ -134,7 +140,7 @@ export function parseNote(raw: string, now = Date.now()): { meta: NoteMeta; body
|
|
|
134
140
|
meta.status = isStatus(meta.status) ? meta.status : "active";
|
|
135
141
|
meta.stale = meta.stale === true;
|
|
136
142
|
for (const key of TIMESTAMP_KEYS) meta[key] = toEpoch(meta[key], now);
|
|
137
|
-
meta.
|
|
143
|
+
meta.accessCount = typeof meta.accessCount === "number" && Number.isFinite(meta.accessCount) ? meta.accessCount : 0;
|
|
138
144
|
return { meta: meta as NoteMeta, body };
|
|
139
145
|
}
|
|
140
146
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { createNotesStore, NoteError } from "./store.js";
|
|
2
|
+
export { projectKey, slugify } from "./paths.js";
|
|
3
|
+
export type { NotesContext } from "./context.js";
|
|
4
|
+
export type {
|
|
5
|
+
EditOperation,
|
|
6
|
+
EditOptions,
|
|
7
|
+
NoteChange,
|
|
8
|
+
NoteEditResult,
|
|
9
|
+
NoteErrorCode,
|
|
10
|
+
NoteMatch,
|
|
11
|
+
NoteMeta,
|
|
12
|
+
NoteReadResult,
|
|
13
|
+
NoteRow,
|
|
14
|
+
NoteSearchRow,
|
|
15
|
+
NoteStatus,
|
|
16
|
+
NoteWriteResult,
|
|
17
|
+
NotesQuery,
|
|
18
|
+
NotesStore,
|
|
19
|
+
Origin,
|
|
20
|
+
Scope,
|
|
21
|
+
WriteOptions,
|
|
22
|
+
} from "./store.js";
|
package/src/notes/paths.ts
CHANGED
|
@@ -1,34 +1,18 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync,
|
|
3
|
-
import {
|
|
4
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
-
import type {
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
5
|
+
import type { NotesContext } from "./context.js";
|
|
6
6
|
|
|
7
7
|
export type Scope = "session" | "project" | "human" | "agent" | "model";
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
export
|
|
11
|
-
const override = process.env.PI_NOTES_HOME;
|
|
12
|
-
return override && override.length > 0 ? resolve(override) : join(homedir(), ".agents", "notes");
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Absolute directory holding the per-session note homes. */
|
|
16
|
-
export function sessionHomesRoot(home = notesRoot()): string {
|
|
17
|
-
return join(home, "pi", "session");
|
|
18
|
-
}
|
|
9
|
+
/** The one legal home-name shape: lowercase [a-z0-9-] runs separated by single dashes. */
|
|
10
|
+
export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
19
11
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
function gitRoot(cwd: string): string | undefined {
|
|
25
|
-
let dir = resolve(cwd);
|
|
26
|
-
for (;;) {
|
|
27
|
-
if (existsSync(join(dir, ".git"))) return dir;
|
|
28
|
-
const parent = dirname(dir);
|
|
29
|
-
if (parent === dir) return undefined;
|
|
30
|
-
dir = parent;
|
|
31
|
-
}
|
|
12
|
+
/** Slugify a caller-declared identity; identity is never inferred from note content. */
|
|
13
|
+
export function slugify(value: string): string {
|
|
14
|
+
const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
15
|
+
return slug.length > 0 ? slug : "root";
|
|
32
16
|
}
|
|
33
17
|
|
|
34
18
|
/** `<basename(absGitRoot)-sha1(absGitRoot)[:8]>`, or the same formula over cwd with no git root. */
|
|
@@ -39,67 +23,67 @@ export function projectKey(cwd: string): string {
|
|
|
39
23
|
return `${basename(root)}-${digest}`;
|
|
40
24
|
}
|
|
41
25
|
|
|
42
|
-
/** Session identity comes from the pi session manager; ids are filesystem-safe by construction. */
|
|
43
|
-
function sessionId(ctx: ExtensionContext): string {
|
|
44
|
-
return ctx.sessionManager.getSessionId();
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** The one legal home-name shape: lowercase [a-z0-9-] runs separated by single dashes. */
|
|
48
|
-
export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
49
|
-
|
|
50
26
|
/**
|
|
51
|
-
*
|
|
52
|
-
* `
|
|
53
|
-
*
|
|
27
|
+
* Repository root behind one `.git` entry. A `.git` directory is the main checkout
|
|
28
|
+
* itself. A `.git` file is a worktree or submodule pointer: a linked worktree names
|
|
29
|
+
* `<main>/.git/worktrees/<name>` and resolves to `<main>`, so every worktree of one
|
|
30
|
+
* repository shares one project identity. Submodules, bare repositories, and separate
|
|
31
|
+
* git dirs keep the current directory.
|
|
54
32
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
33
|
+
function repositoryRoot(dir: string): string {
|
|
34
|
+
let stats;
|
|
35
|
+
try {
|
|
36
|
+
stats = statSync(join(dir, ".git"));
|
|
37
|
+
} catch {
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
|
40
|
+
if (stats.isDirectory()) return dir;
|
|
41
|
+
let pointer: string;
|
|
42
|
+
try {
|
|
43
|
+
pointer = readFileSync(join(dir, ".git"), "utf8");
|
|
44
|
+
} catch {
|
|
45
|
+
return dir;
|
|
46
|
+
}
|
|
47
|
+
const match = /^gitdir:\s*(.+)$/m.exec(pointer);
|
|
48
|
+
if (!match) return dir;
|
|
49
|
+
const parts = resolve(dir, match[1]!.trim()).split(sep);
|
|
50
|
+
const worktrees = parts.lastIndexOf("worktrees");
|
|
51
|
+
if (worktrees <= 0 || worktrees !== parts.length - 2) return dir;
|
|
52
|
+
const common = parts.slice(0, worktrees).join(sep);
|
|
53
|
+
return basename(common) === ".git" ? dirname(common) : dir;
|
|
63
54
|
}
|
|
64
55
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
56
|
+
/** Absolute repository root for cwd, walking upward until a directory holds a `.git` entry. */
|
|
57
|
+
function gitRoot(cwd: string): string | undefined {
|
|
58
|
+
let dir = resolve(cwd);
|
|
59
|
+
for (;;) {
|
|
60
|
+
if (existsSync(join(dir, ".git"))) return repositoryRoot(dir);
|
|
61
|
+
const parent = dirname(dir);
|
|
62
|
+
if (parent === dir) return undefined;
|
|
63
|
+
dir = parent;
|
|
64
|
+
}
|
|
70
65
|
}
|
|
71
66
|
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
* model live from ctx.model).
|
|
76
|
-
*/
|
|
77
|
-
export function scopeDir(scope: Scope, ctx: ExtensionContext, who?: string): string {
|
|
78
|
-
if (scope === "human") return join(notesRoot(), "human");
|
|
79
|
-
if (scope === "project") return join(notesRoot(), "project", projectKey(ctx.cwd));
|
|
80
|
-
if (scope === "agent") return join(notesRoot(), "agents", who ?? agentSlug(ctx));
|
|
81
|
-
if (scope === "model") return join(notesRoot(), "models", who ?? modelSlug(ctx));
|
|
82
|
-
return join(sessionHomesRoot(), sessionId(ctx));
|
|
67
|
+
/** Absolute directory holding the per-session note homes. */
|
|
68
|
+
export function sessionHomesRoot(home: string): string {
|
|
69
|
+
return join(home, "pi", "session");
|
|
83
70
|
}
|
|
84
71
|
|
|
85
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (existsSync(modern)) return "both personal/ and human/ exist under the notes home; migrate by hand, no automatic merge";
|
|
95
|
-
renameSync(legacy, modern);
|
|
96
|
-
return undefined;
|
|
72
|
+
/** Absolute directory holding one scope's notes. The context's home is already resolved. */
|
|
73
|
+
export function scopeDir(scope: Scope, context: NotesContext, who?: string): string {
|
|
74
|
+
if (!["session", "project", "human", "agent", "model"].includes(scope)) throw new TypeError("invalid notes scope");
|
|
75
|
+
if (who !== undefined && (scope !== "agent" && scope !== "model" || !SLUG_PATTERN.test(who))) throw new TypeError("who must be a canonical agent/model slug");
|
|
76
|
+
if (scope === "human") return join(context.home, "human");
|
|
77
|
+
if (scope === "project") return join(context.home, "project", context.projectKey);
|
|
78
|
+
if (scope === "agent") return join(context.home, "agents", who ?? context.agent);
|
|
79
|
+
if (scope === "model") return join(context.home, "models", who ?? context.model);
|
|
80
|
+
return join(sessionHomesRoot(context.home), context.sessionId);
|
|
97
81
|
}
|
|
98
82
|
|
|
99
|
-
/** Every existing home directory of the agents/ or models/ namespace, as
|
|
100
|
-
export function namespaceSlugs(namespace: "agents" | "models", home
|
|
83
|
+
/** Every existing home directory of the agents/ or models/ namespace, as names. */
|
|
84
|
+
export async function namespaceSlugs(namespace: "agents" | "models", home: string): Promise<string[]> {
|
|
101
85
|
try {
|
|
102
|
-
return
|
|
86
|
+
return (await readdir(join(home, namespace), { withFileTypes: true }))
|
|
103
87
|
.filter((entry) => entry.isDirectory())
|
|
104
88
|
.map((entry) => entry.name)
|
|
105
89
|
.sort();
|
|
@@ -117,6 +101,8 @@ export function noteFileName(vpath: string): string {
|
|
|
117
101
|
}
|
|
118
102
|
|
|
119
103
|
/** Absolute file path for a virtual path in a scope. Callers validate the vpath first. */
|
|
120
|
-
export function physicalPath(scope: Scope, vpath: string,
|
|
121
|
-
|
|
104
|
+
export function physicalPath(scope: Scope, vpath: string, context: NotesContext, who?: string): string {
|
|
105
|
+
if (typeof vpath !== "string" || vpath.length === 0 || vpath.includes("\0") || vpath.includes("\\") || vpath.startsWith("/")) throw new TypeError("path must be a safe virtual relative path");
|
|
106
|
+
if (vpath.split("/").some((part) => part.length === 0 || part === "." || part === "..")) throw new TypeError("path contains an unsupported component");
|
|
107
|
+
return join(scopeDir(scope, context, who), ...noteFileName(vpath).split("/"));
|
|
122
108
|
}
|