@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.
Files changed (82) hide show
  1. package/README.md +52 -5
  2. package/dist/build-info.json +4 -0
  3. package/dist/extension.js +1951 -0
  4. package/dist/src/context/boot.js +46 -0
  5. package/dist/src/context/budget.js +150 -0
  6. package/dist/src/context/context-window.js +112 -0
  7. package/dist/src/context/prompts.js +91 -0
  8. package/dist/src/context/reset-artifacts.js +86 -0
  9. package/dist/src/context/reset-lifecycle.js +182 -0
  10. package/dist/src/context/runtime.js +151 -0
  11. package/dist/src/context/thresholds.js +62 -0
  12. package/dist/src/dream/cli.js +1 -1
  13. package/dist/src/dream/doctor.js +34 -6
  14. package/dist/src/dream/runner.js +1 -1
  15. package/dist/src/dream/settings.js +30 -0
  16. package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
  17. package/dist/src/{history.js → history/history.js} +8 -46
  18. package/dist/src/index.js +27 -94
  19. package/dist/src/notes/address.js +97 -16
  20. package/dist/src/notes/frontmatter.js +18 -3
  21. package/dist/src/notes/notes-snapshot.js +30 -0
  22. package/dist/src/notes/paths.js +64 -7
  23. package/dist/src/notes/session-replay.js +41 -0
  24. package/dist/src/notes/store.js +76 -22
  25. package/dist/src/notes/tools.js +7 -7
  26. package/dist/src/protocol.js +9 -9
  27. package/dist/src/settings.js +16 -0
  28. package/dist/src/tool-schema.js +1 -1
  29. package/dist/test/agent-loop.test.js +815 -221
  30. package/dist/test/boot.integration.test.js +219 -0
  31. package/dist/test/budget-settings.integration.test.js +126 -0
  32. package/dist/test/doctor.test.js +14 -36
  33. package/dist/test/dream.test.js +37 -380
  34. package/dist/test/helpers/extension.js +392 -0
  35. package/dist/test/history.integration.test.js +316 -0
  36. package/dist/test/notes.integration.test.js +270 -0
  37. package/dist/test/notes.test.js +40 -359
  38. package/dist/test/reset-lifecycle.test.js +443 -178
  39. package/docs/architecture.md +35 -18
  40. package/docs/reset-lifecycle.md +73 -14
  41. package/package.json +11 -10
  42. package/src/context/boot.ts +68 -0
  43. package/src/context/budget.ts +148 -0
  44. package/src/context/context-window.ts +118 -0
  45. package/src/context/prompts.ts +108 -0
  46. package/src/context/reset-artifacts.ts +101 -0
  47. package/src/context/reset-lifecycle.ts +272 -0
  48. package/src/context/runtime.ts +151 -0
  49. package/src/context/thresholds.ts +78 -0
  50. package/src/dream/cli.ts +1 -1
  51. package/src/dream/doctor.ts +27 -6
  52. package/src/dream/runner.ts +1 -1
  53. package/src/dream/settings.ts +32 -0
  54. package/src/{history-tools.ts → history/history-tools.ts} +3 -3
  55. package/src/{history.ts → history/history.ts} +9 -48
  56. package/src/index.ts +27 -89
  57. package/src/notes/address.ts +82 -16
  58. package/src/notes/frontmatter.ts +20 -3
  59. package/src/notes/notes-snapshot.ts +40 -0
  60. package/src/notes/paths.ts +64 -7
  61. package/src/notes/session-replay.ts +53 -0
  62. package/src/notes/store.ts +78 -25
  63. package/src/notes/tools.ts +7 -7
  64. package/src/protocol.ts +9 -10
  65. package/src/settings.ts +20 -0
  66. package/src/tool-schema.ts +1 -2
  67. package/dist/src/budget.js +0 -65
  68. package/dist/src/notes/model.js +0 -101
  69. package/dist/src/prompts.js +0 -88
  70. package/dist/src/reset-lifecycle.js +0 -155
  71. package/dist/src/thresholds.js +0 -102
  72. package/dist/src/warning.js +0 -44
  73. package/dist/test/coherence.test.js +0 -371
  74. package/dist/test/history.test.js +0 -26
  75. package/dist/test/integration.test.js +0 -1759
  76. package/dist/test/pagination.property.test.js +0 -471
  77. package/src/budget.ts +0 -67
  78. package/src/notes/model.ts +0 -109
  79. package/src/prompts.ts +0 -91
  80. package/src/reset-lifecycle.ts +0 -173
  81. package/src/thresholds.ts +0 -110
  82. package/src/warning.ts +0 -46
@@ -0,0 +1,78 @@
1
+ import { SettingsManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS } from "../protocol.js";
3
+ import { mergePiContextSettings, type PiContextSettings } from "../settings.js";
4
+
5
+ export type ResolvedThresholds = { reminder: number; reserve: number; warning: number };
6
+ /** A margin is usable only as a positive integer; anything else is ignored. */
7
+ function validMargin(raw: unknown): number | undefined {
8
+ if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw <= 0) return undefined;
9
+ return raw;
10
+ }
11
+
12
+ /**
13
+ * Pure derivation of the thresholds from Pi's reserve: the reminder fires at reserve
14
+ * plus the pi-context margin, the warning steer at reserve plus WARNING_RUNWAY_TOKENS.
15
+ * An invalid margin degrades to the default and reports one warning. Automatic
16
+ * threshold/overflow handling is represented by reset lifecycle boundary drafts;
17
+ * no compaction summary is generated.
18
+ */
19
+ export function deriveThresholds(reserveTokens: number, margins: PiContextSettings): { thresholds: ResolvedThresholds; warnings: string[] } {
20
+ const warnings: string[] = [];
21
+ const reminderKey = `${PI_CONTEXT_SETTINGS_KEY}.reminderMarginTokens`;
22
+ let reminderMargin: number;
23
+ if (margins.reminderMarginTokens === undefined) reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
24
+ else {
25
+ const parsed = validMargin(margins.reminderMarginTokens);
26
+ if (parsed === undefined) {
27
+ warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default ${DEFAULT_REMINDER_MARGIN_TOKENS}.`);
28
+ reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
29
+ } else reminderMargin = parsed;
30
+ }
31
+ return { thresholds: { reminder: reserveTokens + reminderMargin, reserve: reserveTokens, warning: reserveTokens + WARNING_RUNWAY_TOKENS }, warnings };
32
+ }
33
+
34
+ /**
35
+ * Read the active compaction reserve, enablement, and pi-context margin settings. This
36
+ * function deliberately has no cache: the budget owner supplies the invocation-scoped
37
+ * cache so two live piContext instances cannot share mutable policy state.
38
+ */
39
+ export type ThresholdSettingsResolution = {
40
+ thresholds: ResolvedThresholds;
41
+ automatic: boolean;
42
+ warnings: string[];
43
+ };
44
+
45
+ function readThresholdSettingsFromManager(ctx: ExtensionContext, settingsManager: SettingsManager): ThresholdSettingsResolution {
46
+ // Resolve the active provider/model override from the public settings API.
47
+ const model = ctx.model;
48
+ const compaction = settingsManager.getCompactionSettings(model ? { provider: model.provider, id: model.id } : undefined);
49
+ const derived = deriveThresholds(
50
+ compaction.reserveTokens,
51
+ mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()),
52
+ );
53
+ return { thresholds: derived.thresholds, automatic: compaction.enabled, warnings: derived.warnings };
54
+ }
55
+
56
+ /**
57
+ * Resolve policy from either the explicitly supplied SDK authority or Pi's default
58
+ * file-backed settings. The caller owns diagnostics and any lifecycle caching.
59
+ */
60
+ export function readThresholdSettings(ctx: ExtensionContext, settingsManager?: SettingsManager): ThresholdSettingsResolution {
61
+ try {
62
+ if (settingsManager) return readThresholdSettingsFromManager(ctx, settingsManager);
63
+ return readThresholdSettingsFromManager(
64
+ ctx,
65
+ SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() }),
66
+ );
67
+ } catch (error) {
68
+ return {
69
+ thresholds: {
70
+ reminder: DEFAULT_RESERVE_TOKENS + DEFAULT_REMINDER_MARGIN_TOKENS,
71
+ reserve: DEFAULT_RESERVE_TOKENS,
72
+ warning: DEFAULT_RESERVE_TOKENS + WARNING_RUNWAY_TOKENS,
73
+ },
74
+ automatic: true,
75
+ warnings: [`pi-context: could not read settings; using defaults (${String(error)}).`],
76
+ };
77
+ }
78
+ }
package/src/dream/cli.ts CHANGED
@@ -6,7 +6,7 @@ import { acquireLock, failLock, lastRunPath, releaseLock } from "./lock.js";
6
6
  import { materialGate, timeGate } from "./gates.js";
7
7
  import { loadPlaybook, runDreamer, type DreamerSessionFactory, type DreamResult, type DreamWrite } from "./runner.js";
8
8
  import { gitCommit } from "./git.js";
9
- import { readDreamerSettings, type DreamerSetting } from "../thresholds.js";
9
+ import { readDreamerSettings, type DreamerSetting } from "./settings.js";
10
10
  import { doctor } from "./doctor.js";
11
11
  import { notesRoot } from "../notes/paths.js";
12
12
 
@@ -1,6 +1,7 @@
1
1
  import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { basename, join, relative } from "node:path";
3
- import { assertAddress } from "../notes/address.js";
3
+ import { ADDRESS_FORMS, assertAddress } from "../notes/address.js";
4
+ import { SLUG_PATTERN } from "../notes/paths.js";
4
5
 
5
6
  /** Read-only diagnostics. Never follows symlinks or acquires/removes a dream lock. */
6
7
  export function doctor(home: string): string[] {
@@ -41,10 +42,17 @@ export function doctor(home: string): string[] {
41
42
  if (!address.startsWith("@") && basename(path) !== "MAP.md") continue;
42
43
  try {
43
44
  const parsed = assertAddress(address);
44
- const targetHome = parsed.scope === "personal" ? join(home, "personal") : parsed.scope === "project" ? project : root;
45
+ // Relative homes (@self/, @model/) name whoever is running; a static doctor
46
+ // cannot resolve them, so only absolute links are checked.
47
+ if ((parsed.scope === "agent" || parsed.scope === "model") && parsed.who === undefined) continue;
48
+ const targetHome = parsed.scope === "human" ? join(home, "human")
49
+ : parsed.scope === "project" ? project
50
+ : parsed.scope === "agent" ? join(home, "agents", parsed.who!)
51
+ : parsed.scope === "model" ? join(home, "models", parsed.who!)
52
+ : root;
45
53
  if (!targetHome) { report(path, `${address}: project context unavailable; use a resolvable reference`); continue; }
46
54
  if (!existsSync(join(targetHome, parsed.path))) report(path, `${address}: target missing; update or remove the reference`);
47
- } catch { report(path, `${address}: invalid address; use bare, @project/ or @personal/ addresses`); }
55
+ } catch { report(path, `${address}: invalid address; ${ADDRESS_FORMS}`); }
48
56
  }
49
57
  };
50
58
  const walk = (dir: string, root: string, project?: string) => {
@@ -64,14 +72,27 @@ export function doctor(home: string): string[] {
64
72
  for (const name of readdirSync(home)) {
65
73
  const path = join(home, name);
66
74
  inspect(path, () => {
67
- if (name === "global") { report(path, "legacy home; manually migrate to personal/ without overwriting existing files"); return; }
75
+ if (name === "global") { report(path, "legacy home; manually migrate to human/ without overwriting existing files"); return; }
76
+ if (name === "personal") { report(path, "legacy home; migrate to human/ (rename the directory), merging by hand if human/ already exists"); return; }
68
77
  if (name === ".dream.lock") {
69
78
  const valid = lstatSync(path).isFile() && /^[1-9]\d* [\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}\s*$/i.test(readFileSync(path, "utf8"));
70
79
  report(path, `${valid ? "lock present" : "malformed lock"}; verify no dream is running before manual removal; liveness not inferred`);
71
80
  return;
72
81
  }
73
82
  if ([".git", "dreams", "snapshots", "trash", ".dream.lock.last-run"].includes(name)) return;
74
- if (name === "personal") { if (directory(path)) walk(path, path); return; }
83
+ if (name === "human") { if (directory(path)) walk(path, path); return; }
84
+ if (name === "agents" || name === "models") {
85
+ if (!directory(path)) return;
86
+ for (const slug of readdirSync(path)) {
87
+ const dir = join(path, slug);
88
+ inspect(dir, () => {
89
+ if (!SLUG_PATTERN.test(slug)) report(dir, `invalid ${name.slice(0, -1)} slug; expected [a-z0-9-]`);
90
+ if (!directory(dir)) return;
91
+ walk(dir, dir);
92
+ });
93
+ }
94
+ return;
95
+ }
75
96
  if (name === "project" || name === "pi") {
76
97
  if (!directory(path)) return;
77
98
  const homes = name === "pi" ? join(path, "session") : path;
@@ -89,7 +110,7 @@ export function doctor(home: string): string[] {
89
110
  }
90
111
  return;
91
112
  }
92
- report(path, "unexpected root entry; expected personal/, project/, pi/session/ or dream artifacts");
113
+ report(path, "unexpected root entry; expected human/, project/, agents/, models/, pi/session/ or dream artifacts");
93
114
  });
94
115
  }
95
116
  });
@@ -3,7 +3,7 @@ import { lstat, mkdir, realpath } from "node:fs/promises";
3
3
  import { dirname, isAbsolute, relative, resolve } from "node:path";
4
4
  import { createAgentSession, createEditToolDefinition, createWriteToolDefinition, ModelRuntime, resolveModelScopeWithDiagnostics, SessionManager, type AgentSession, type ToolDefinition } from "@earendil-works/pi-coding-agent";
5
5
  import type { Api, Model } from "@earendil-works/pi-ai";
6
- import { contentText } from "../history.js";
6
+ import { contentText } from "../history/history.js";
7
7
 
8
8
  export type DreamWrite = { tool: "write" | "edit"; path: string };
9
9
  export type DreamResult = { report: string; writes: DreamWrite[]; error?: string };
@@ -0,0 +1,32 @@
1
+ import { SettingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { PI_CONTEXT_DREAMER_KEY, PI_CONTEXT_SETTINGS_KEY } from "../protocol.js";
3
+ import { mergePiContextSettings, type PiContextSettings } from "../settings.js";
4
+
5
+ export type DreamerSetting = { pattern?: string; warnings: string[] };
6
+
7
+ /**
8
+ * `pi-context.dreamer` is a non-empty model pattern. Anything else present is ignored
9
+ * with one warning; absent means no configured pattern, so the automatic model applies.
10
+ */
11
+ export function deriveDreamer(settings: PiContextSettings): DreamerSetting {
12
+ const raw = settings.dreamer;
13
+ if (raw === undefined) return { warnings: [] };
14
+ if (typeof raw !== "string" || raw.trim().length === 0) {
15
+ return { warnings: [`pi-context: ${PI_CONTEXT_SETTINGS_KEY}.${PI_CONTEXT_DREAMER_KEY} must be a non-empty string; ignoring it.`] };
16
+ }
17
+ return { pattern: raw.trim(), warnings: [] };
18
+ }
19
+
20
+ /**
21
+ * Resolve the configurable dreamer model from Pi settings for a CLI invocation: global
22
+ * `~/.pi/agent/settings.json` merged with the project's `.pi/settings.json`, project
23
+ * values winning per key. A settings read failure degrades to no pattern with one warning.
24
+ */
25
+ export function readDreamerSettings(cwd = process.cwd()): DreamerSetting {
26
+ try {
27
+ const settingsManager = SettingsManager.create(cwd, undefined, { projectTrusted: true });
28
+ return deriveDreamer(mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()));
29
+ } catch (error) {
30
+ return { warnings: [`pi-context: could not read settings; using the automatic dreamer model (${String(error)}).`] };
31
+ }
32
+ }
@@ -1,7 +1,7 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, readWindowBlock, withinTextBudget, DEFAULT_READ_WINDOW_CHARS, HISTORY_PREVIEW_CHARS, MAX_READ_WINDOW_CHARS } from "./tool-output.js";
4
- import { positiveInteger, recentFirst, nullableString, role, cursor, searchQuery, searchQueries } from "./tool-schema.js";
3
+ import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, readWindowBlock, withinTextBudget, DEFAULT_READ_WINDOW_CHARS, HISTORY_PREVIEW_CHARS, MAX_READ_WINDOW_CHARS } from "../tool-output.js";
4
+ import { positiveInteger, recentFirst, nullableString, role, cursor, searchQuery, searchQueries } from "../tool-schema.js";
5
5
  import { historyFromSession, filteredItems, visibleItem, allItems, vacuousRoleToolCombo, unknownWindowId } from "./history.js";
6
6
 
7
7
  /**
@@ -46,7 +46,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
46
46
  pi.registerTool(defineTool({
47
47
  name: "history_list",
48
48
  label: "History list items",
49
- description: "List durable session items, including items before compaction, using opaque item and window IDs; the role parameter's description enumerates the six roles. Every item carries truncated and total_chars: when truncated is true, truncated_content is a plain prefix of the item's content with no marker, and total_chars is its full code-point length. max_chars_per_item: 1 therefore yields pure addresses you can resolve with history_read.",
49
+ description: "List durable session items, including items from earlier reset windows, using opaque item and window IDs; native compaction and branch summaries remain history items in their current window. The role parameter's description enumerates the six roles. Every item carries truncated and total_chars: when truncated is true, truncated_content is a plain prefix of the item's content with no marker, and total_chars is its full code-point length. max_chars_per_item: 1 therefore yields pure addresses you can resolve with history_read.",
50
50
  parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), recent_first: recentFirst(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
51
51
  async execute(_id, params, _signal, _update, ctx) {
52
52
  const invalid = vacuousRoleToolCombo(params);
@@ -1,8 +1,8 @@
1
1
  import type { TextContent, ToolCall } from "@earendil-works/pi-ai";
2
2
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
- import type { SessionReader } from "./session-reader.js";
4
- import { RESET_V2 } from "./protocol.js";
5
- import { HISTORY_PREVIEW_CHARS } from "./tool-output.js";
3
+ import type { SessionReader } from "../session-reader.js";
4
+ import { isWindowMarker, rootWindowId } from "../context/context-window.js";
5
+ import { HISTORY_PREVIEW_CHARS } from "../tool-output.js";
6
6
 
7
7
  type HistoryItem = {
8
8
  windowId: string;
@@ -94,38 +94,22 @@ function toolCallItems(windowId: string, entry: { id: string; timestamp?: string
94
94
  return items;
95
95
  }
96
96
 
97
- /** The extension-owned window id baked onto a reset-v2 compaction entry, if present. */
98
- export function resetV2WindowId(details: unknown): string | undefined {
99
- if (typeof details !== "object" || details === null) return undefined;
100
- const candidate = details as { piContext?: unknown; windowId?: unknown };
101
- if (candidate.piContext !== RESET_V2 || typeof candidate.windowId !== "string") return undefined;
102
- return candidate.windowId;
103
- }
104
-
105
- /** A compaction entry's window id: the extension-minted id for reset-v2, else Pi's entry id. */
106
- export function windowIdOf(sessionId: string, entry: { id: string; details?: unknown }): string {
107
- return resetV2WindowId(entry.details) ?? `pcw:${sessionId.slice(0, 8)}:${entry.id}`;
108
- }
109
-
110
- /** Mint the durable identity of a session's root history window. */
111
- export function rootWindowId(sessionId: string): string {
112
- return `pcw:${sessionId.slice(0, 8)}:root`;
113
- }
114
-
115
97
  /** Build durable, on-demand history directly from every entry on the current session branch. */
116
98
  export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
117
99
  const sessionId = ctx.sessionManager.getSessionId();
118
100
  let window: HistoryWindow = { windowId: rootWindowId(sessionId), items: [] };
119
101
  const windows = [window];
120
102
  for (const entry of ctx.sessionManager.getBranch()) {
121
- if (entry.type === "compaction") {
122
- window = { windowId: windowIdOf(sessionId, entry), createdAt: entry.timestamp, items: [] };
103
+ if (isWindowMarker(entry)) {
104
+ window = { windowId: entry.data.windowId, createdAt: entry.timestamp, items: [] };
123
105
  windows.push(window);
106
+ continue;
107
+ }
108
+ if (entry.type === "compaction" || entry.type === "branch_summary") {
124
109
  window.items.push({
125
110
  windowId: window.windowId,
126
111
  itemId: entry.id,
127
- // A reset-v2 compaction is authored by this extension; a native Pi compaction is not.
128
- role: resetV2WindowId(entry.details) === undefined ? "system" : "developer",
112
+ role: "system",
129
113
  content: entry.summary,
130
114
  createdAt: entry.timestamp,
131
115
  });
@@ -215,26 +199,3 @@ export function filteredItems(ctx: SessionReader, params: HistoryFilter): Histor
215
199
  if (params.recent_first !== false) items.reverse();
216
200
  return items;
217
201
  }
218
-
219
-
220
- /** Persisted messages in the active window, excluding earlier windows on this branch. */
221
- export function hasWindowMessage(ctx: SessionReader, customType: string): boolean {
222
- const branch = ctx.sessionManager.getBranch();
223
- for (let i = branch.length - 1; i >= 0; i--) {
224
- const entry = branch[i];
225
- if (entry.type === "compaction") break;
226
- if (entry.type === "custom_message" && entry.customType === customType) return true;
227
- }
228
- return false;
229
- }
230
-
231
- /** Cheap current-window lookup: scan the branch tail for the latest compaction entry. */
232
- export function currentWindowId(ctx: SessionReader): string {
233
- const sessionId = ctx.sessionManager.getSessionId();
234
- const branch = ctx.sessionManager.getBranch();
235
- for (let i = branch.length - 1; i >= 0; i--) {
236
- const entry = branch[i];
237
- if (entry?.type === "compaction") return windowIdOf(sessionId, entry);
238
- }
239
- return rootWindowId(sessionId);
240
- }
package/src/index.ts CHANGED
@@ -1,97 +1,35 @@
1
- import { registerHistoryTools } from "./history-tools.js";
1
+ import { VERSION, type ExtensionAPI, type ExtensionFactory, type SettingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { registerHistoryTools } from "./history/history-tools.js";
2
3
  import { registerNotesTools } from "./notes/tools.js";
3
- import { registerBudget } from "./budget.js";
4
- import { output } from "./tool-output.js";
5
- import { deriveThresholds, mergePiContextSettings } from "./thresholds.js";
6
- import { STATE_TYPE, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, RESET_V2, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS, RESET_SUMMARY, CONTINUATION, WARNING_PROMPT } from "./protocol.js";
7
- import { historyFromSession, hasWindowMessage, currentWindowId, resetV2WindowId, rootWindowId, windowIdOf } from "./history.js";
8
- import { assertVirtualPath } from "./notes/model.js";
9
- import { bootBlock } from "./prompts.js";
10
- export { historyFromSession } from "./history.js";
11
- export { notesFromSession } from "./notes/model.js";
12
- import { registerResetLifecycle } from "./reset-lifecycle.js";
13
- import { registerWarning } from "./warning.js";
14
- import { randomUUID } from "node:crypto";
15
- import { Type } from "@earendil-works/pi-ai";
16
- import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
-
18
- export default function piContext(pi: ExtensionAPI) {
19
- let enabled = true;
20
- registerBudget(pi, () => enabled);
21
- registerWarning(pi, () => enabled);
22
-
23
- pi.on("session_start", (_event, ctx) => {
24
- if (!enabled) return;
25
- // The root window has no compaction entry to carry the boot block, so persist
26
- // it once as a hidden custom message. Reset windows already carry theirs at
27
- // position 0 in the compaction summary, so a resumed session adds nothing.
28
- const rootId = rootWindowId(ctx.sessionManager.getSessionId());
29
- if (currentWindowId(ctx) !== rootId || hasWindowMessage(ctx, BOOT_TYPE)) return;
30
- pi.sendMessage({ customType: BOOT_TYPE, content: bootBlock(ctx, rootId, undefined, false), display: false }, { triggerTurn: false });
31
- });
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) =>
36
- ["on", "off"].filter((a) => a.startsWith(prefix)).map((a) => ({ value: a, label: a })),
37
- handler: async (args, cmdCtx) => {
38
- const arg = args.trim().toLowerCase();
39
- if (arg === "on") enabled = true;
40
- else if (arg === "off") { enabled = false; resets.clear(); }
41
- else if (arg !== "") {
42
- cmdCtx.ui.notify("Usage: /pi-context [on|off]", "error");
43
- return;
44
- }
45
- cmdCtx.ui.notify(`pi-context: ${enabled ? "on" : "off"}`, "info");
46
- },
47
- });
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";
48
11
 
12
+ function registerPiContext(pi: ExtensionAPI, settingsManager?: SettingsManager): void {
13
+ const [major, minor] = VERSION.split(".").map(Number);
14
+ if (!(major > 0 || (major === 0 && minor >= 87))) {
15
+ throw new Error(`pi-context requires Pi >= 0.87.0; running ${VERSION}. Upgrade Pi and restart the process; /reload only reloads extensions.`);
16
+ }
17
+ registerContext(pi, settingsManager);
49
18
  registerHistoryTools(pi);
50
19
  registerNotesTools(pi);
20
+ }
51
21
 
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) return output({ error: "pi-context is off (/pi-context on to enable)" });
59
- return output({ status: resets.request() }, undefined, true);
60
- },
61
- }));
22
+ /**
23
+ * Create an extension factory bound to an SDK settings authority. The host must pass
24
+ * the same manager to createAgentSession and to this factory's resource loader.
25
+ */
26
+ export function createPiContext(options: { settingsManager?: SettingsManager } = {}): ExtensionFactory {
27
+ return (pi) => registerPiContext(pi, options.settingsManager);
28
+ }
62
29
 
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))) minted = { id: randomUUID().slice(0, 8) };
79
- const windowId = windowIdOf(sessionId, minted);
80
- const previousId = windows[windows.length - 1]?.windowId ?? rootWindowId(sessionId);
81
- // The reset marker stays as firstKeptEntryId; it no longer names the window.
82
- pi.appendEntry(RESET_MARKER_TYPE, { version: 1, reason: event.reason, requested: explicit });
83
- const markerId = ctx.sessionManager.getLeafId();
84
- if (!markerId) return { cancel: true };
85
- return {
86
- compaction: {
87
- summary: bootBlock(ctx, windowId, previousId, true),
88
- firstKeptEntryId: markerId,
89
- tokensBefore: event.preparation.tokensBefore,
90
- details: { piContext: RESET_V2, windowId },
91
- },
92
- };
93
- },
94
- });
30
+ /** The Pi-discovered extension keeps the standard file-backed settings behavior. */
31
+ export default function piContext(pi: ExtensionAPI): void {
32
+ registerPiContext(pi);
95
33
  }
96
34
 
97
- 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 };
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 };
@@ -1,33 +1,99 @@
1
- import { assertVirtualPath } from "./model.js";
2
- import type { Scope } from "./paths.js";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { agentSlug, modelSlug, SLUG_PATTERN, type Scope } from "./paths.js";
3
3
 
4
- export type NoteAddress = { scope: Scope; path: string };
4
+ export type NoteAddress = { scope: Scope; path: string; who?: string };
5
5
 
6
- const ADDRESS_FORMS = "legal prefixes are @project/ and @personal/; bare names are the session home";
6
+ export const ADDRESS_FORMS = "legal prefixes are @project/, @human/, @self/, and @model/; bare names are this session";
7
+
8
+ export function assertVirtualPath(value: unknown): string {
9
+ if (typeof value !== "string" || value.length === 0) throw new Error("path must be a non-empty virtual relative path");
10
+ if (value.includes("\0") || value.includes("\\") || value.startsWith("/")) throw new Error("path must be a safe virtual relative path");
11
+ const parts = value.split("/");
12
+ if (parts.some((part) => part.length === 0 || part === "." || part === "..")) throw new Error("path contains an unsupported component");
13
+ return value;
14
+ }
15
+
16
+ /**
17
+ * Minimal glob over virtual note paths: `*` matches any run within a segment (never
18
+ * `/`), `**` matches any run across segments (a leading double-star followed by a
19
+ * slash also matches zero segments, so it covers the root too), `?` matches exactly
20
+ * one non-`/` character. Everything else is literal and the match is anchored to the
21
+ * whole path.
22
+ */
23
+ export function globToRegExp(pattern: string): RegExp {
24
+ let source = "^";
25
+ for (let index = 0; index < pattern.length; index++) {
26
+ const char = pattern[index]!;
27
+ if (char === "*") {
28
+ if (pattern[index + 1] === "*") {
29
+ const followedBySlash = pattern[index + 2] === "/";
30
+ source += followedBySlash ? "(?:[^]*\\/)?" : "[^]*";
31
+ index += followedBySlash ? 2 : 1;
32
+ } else {
33
+ source += "[^/]*";
34
+ }
35
+ } else {
36
+ source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
37
+ }
38
+ }
39
+ return new RegExp(`${source}$`);
40
+ }
41
+
42
+ /** Glob patterns are not virtual paths (`*` is legal), so they get their own guard: no NUL, no backslashes. */
43
+ export function assertGlobPattern(value: unknown): string | undefined {
44
+ if (value === undefined || value === null || value === "") return undefined;
45
+ if (typeof value !== "string") throw new Error("glob pattern must be a string");
46
+ if (value.includes("\0") || value.includes("\\")) throw new Error("glob pattern must not contain NUL or backslashes");
47
+ return value;
48
+ }
7
49
 
8
50
  /**
9
51
  * Decode the one public note address into its physical home and virtual path. This is a
10
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.
11
57
  */
12
58
  export function assertAddress(value: unknown): NoteAddress {
13
59
  if (typeof value !== "string") throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
14
60
  let scope: Scope = "session";
15
61
  let path = value;
16
- if (value.startsWith("@project/")) {
17
- scope = "project";
18
- path = value.slice("@project/".length);
19
- } else if (value.startsWith("@personal/")) {
20
- scope = "personal";
21
- path = value.slice("@personal/".length);
22
- } else if (value.startsWith("@")) {
23
- throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
62
+ let who: string | undefined;
63
+ if (value.startsWith("@")) {
64
+ const rest = value.slice(1);
65
+ const headEnd = rest.indexOf("/");
66
+ const head = headEnd === -1 ? rest : rest.slice(0, headEnd);
67
+ const tail = headEnd === -1 ? "" : rest.slice(headEnd + 1);
68
+ path = tail;
69
+ if (head === "project") scope = "project";
70
+ else if (head === "human") scope = "human";
71
+ else if (head === "self") scope = "agent";
72
+ else if (head === "model") scope = "model";
73
+ else if (head === "agents" || head === "models") {
74
+ const nameEnd = tail.indexOf("/");
75
+ who = nameEnd === -1 ? tail : tail.slice(0, nameEnd);
76
+ if (!SLUG_PATTERN.test(who)) throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
77
+ scope = head === "agents" ? "agent" : "model";
78
+ path = nameEnd === -1 ? "" : tail.slice(nameEnd + 1);
79
+ } else {
80
+ throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
81
+ }
82
+ if (path === "" && scope !== "agent" && scope !== "model") throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
83
+ if (path === "" && who === undefined) throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
24
84
  }
25
85
  if (path.includes("@")) throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
26
86
  assertVirtualPath(path);
27
- return { scope, path };
87
+ return { scope, path, who };
28
88
  }
29
89
 
30
- /** Render a virtual path in its one unambiguous public address form. */
31
- export function addressFor(scope: Scope, path: string): string {
32
- return scope === "session" ? path : `@${scope}/${path}`;
90
+ /** Render a virtual path in its one unambiguous public address form. Relative forms
91
+ * (@self/, @model/) never render: the canonical address always carries the resolved
92
+ * name, so listings alone tell every home apart. */
93
+ export function addressFor(ctx: ExtensionContext, scope: Scope, path: string, who?: string): string {
94
+ if (scope === "session") return path;
95
+ if (scope === "project") return `@project/${path}`;
96
+ if (scope === "human") return `@human/${path}`;
97
+ if (scope === "agent") return `@agents/${who ?? agentSlug(ctx)}/${path}`;
98
+ return `@models/${who ?? modelSlug(ctx)}/${path}`;
33
99
  }
@@ -1,4 +1,3 @@
1
- import { localIso } from "./model.js";
2
1
  import type { Scope } from "./paths.js";
3
2
 
4
3
  export type NoteStatus = "active" | "superseded" | "pending" | "archived";
@@ -25,13 +24,29 @@ export type NoteMeta = {
25
24
  [key: string]: unknown;
26
25
  };
27
26
 
28
- const SCOPES: readonly Scope[] = ["session", "project", "personal"];
27
+ const SCOPES: readonly Scope[] = ["session", "project", "human", "agent", "model"];
29
28
  const ORIGINS: readonly Origin[] = ["user", "self", "external"];
30
29
  const STATUSES: readonly NoteStatus[] = ["active", "superseded", "pending", "archived"];
31
30
  const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"] as const;
32
31
  /** Emission order, exactly the Design's key list. */
33
32
  const KNOWN_KEYS = ["origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"] as const;
34
33
 
34
+ const pad2 = (value: number) => String(value).padStart(2, "0");
35
+
36
+ /**
37
+ * Format epoch milliseconds as an ISO 8601 string in the host's local time zone with an
38
+ * explicit numeric offset (e.g. 2026-09-15T17:31:45.392+08:00). A UTC host renders
39
+ * "+00:00"; the "Z" designator is never used, and Date.parse round-trips the value.
40
+ */
41
+ export function localIso(epochMs: number): string {
42
+ const date = new Date(epochMs);
43
+ const offsetMinutes = -date.getTimezoneOffset();
44
+ const absOffset = Math.abs(offsetMinutes);
45
+ const offset = `${offsetMinutes < 0 ? "-" : "+"}${pad2(Math.floor(absOffset / 60))}:${pad2(absOffset % 60)}`;
46
+ 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")}`;
47
+ return `${wallClock}${offset}`;
48
+ }
49
+
35
50
  export function isScope(value: unknown): value is Scope {
36
51
  return typeof value === "string" && (SCOPES as readonly string[]).includes(value);
37
52
  }
@@ -112,7 +127,9 @@ function parseFrontmatter(raw: string): { fields: Record<string, unknown>; body:
112
127
  export function parseNote(raw: string, now = Date.now()): { meta: NoteMeta; body: string } {
113
128
  const { fields, body } = parseFrontmatter(raw);
114
129
  const meta = { ...fields } as Record<string, unknown>;
115
- meta.scope = isScope(meta.scope) ? meta.scope : "personal";
130
+ // scope is a legacy on-disk field: store callers derive it from the file's home and
131
+ // overwrite it after parsing, so an absent or outdated value just falls back.
132
+ meta.scope = isScope(meta.scope) ? meta.scope : "session";
116
133
  meta.origin = isOrigin(meta.origin) ? meta.origin : "self";
117
134
  meta.status = isStatus(meta.status) ? meta.status : "active";
118
135
  meta.stale = meta.stale === true;
@@ -0,0 +1,40 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { listNotes, type NoteRow, type Scope } from "./store.js";
3
+
4
+ const NOTES_HOMES = [
5
+ { scope: "session", label: "this session" },
6
+ { scope: "project", label: "@project" },
7
+ { scope: "human", label: "@human" },
8
+ { scope: "agent", label: "@self" },
9
+ { scope: "model", label: "@model" },
10
+ ] as const satisfies ReadonlyArray<{ scope: Scope; label: string }>;
11
+
12
+ export type NotesHome = (typeof NOTES_HOMES)[number];
13
+ export type NotesLoader = (ctx: ExtensionContext, scope: Scope) => NoteRow[];
14
+ export type NotesSnapshot = {
15
+ /** Wall-clock instant captured when this boot began; rendering never consults Date.now(). */
16
+ readonly openedAt: number;
17
+ readonly homes: ReadonlyMap<Scope, readonly NoteRow[]>;
18
+ readonly unavailable: readonly NotesHome[];
19
+ };
20
+
21
+ /**
22
+ * Acquire the five homes once for one boot. Only filesystem-style errno failures are isolated;
23
+ * malformed note data and unrelated construction errors remain visible to the caller.
24
+ */
25
+ export function loadNotesSnapshot(ctx: ExtensionContext, loadHome: NotesLoader = (context, scope) => listNotes(context, { scope })): NotesSnapshot {
26
+ const openedAt = Date.now();
27
+ const homes = new Map<Scope, readonly NoteRow[]>();
28
+ const unavailable: NotesHome[] = [];
29
+ for (const home of NOTES_HOMES) {
30
+ try {
31
+ homes.set(home.scope, loadHome(ctx, home.scope));
32
+ } catch (error) {
33
+ const code = typeof error === "object" && error !== null ? (error as NodeJS.ErrnoException).code : undefined;
34
+ if (typeof code !== "string" || !/^E[A-Z0-9_]+$/.test(code) || code.startsWith("ERR_")) throw error;
35
+ homes.set(home.scope, []);
36
+ unavailable.push(home);
37
+ }
38
+ }
39
+ return { openedAt, homes, unavailable };
40
+ }