@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
@@ -1,10 +1,10 @@
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
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
 
7
- export type Scope = "session" | "project" | "personal";
7
+ export type Scope = "session" | "project" | "human" | "agent" | "model";
8
8
 
9
9
  /** Physical home of the on-disk note store: $PI_NOTES_HOME or ~/.agents/notes. */
10
10
  export function notesRoot(): string {
@@ -44,13 +44,70 @@ function sessionId(ctx: ExtensionContext): string {
44
44
  return ctx.sessionManager.getSessionId();
45
45
  }
46
46
 
47
- /** Absolute directory holding every note of one scope. */
48
- export function scopeDir(scope: Scope, ctx: ExtensionContext): string {
49
- if (scope === "personal") return join(notesRoot(), "personal");
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
+ /**
51
+ * Identity slugs: one declared name per home, never detected from prompt content.
52
+ * `PI_NOTES_AGENT` declares who is running (default "root"); the model slug derives
53
+ * from the live model id, provider prefix stripped. Both slugified to [a-z0-9-].
54
+ */
55
+ export function slugify(value: string): string {
56
+ const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
57
+ return slug.length > 0 ? slug : "root";
58
+ }
59
+
60
+ /** The current agent's home name: the launch-declared identity, defaulting to "root". */
61
+ export function agentSlug(_ctx: ExtensionContext): string {
62
+ return slugify(process.env.PI_NOTES_AGENT ?? "root");
63
+ }
64
+
65
+ /** The current model's home name, live-resolved from ctx.model; "default" when unknown. */
66
+ export function modelSlug(ctx: ExtensionContext): string {
67
+ const id = ctx.model?.id;
68
+ if (!id) return "default";
69
+ return slugify(id.split("/").pop() ?? id);
70
+ }
71
+
72
+ /**
73
+ * Absolute directory holding every note of one scope. `who` names an agent or model
74
+ * home absolutely; omitted, the current one resolves (agent from PI_NOTES_AGENT,
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");
50
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));
51
82
  return join(sessionHomesRoot(), sessionId(ctx));
52
83
  }
53
84
 
85
+ /**
86
+ * One-time migration of the pre-v0.25 `personal/` home to `human/`. Runs at extension
87
+ * activation; returns a warning string when both directories exist (no auto-merge),
88
+ * undefined otherwise. Old note bodies are history, not addresses, and stay untouched.
89
+ */
90
+ export function migrateLegacyHomes(home = notesRoot()): string | undefined {
91
+ const legacy = join(home, "personal");
92
+ const modern = join(home, "human");
93
+ if (!existsSync(legacy)) return undefined;
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;
97
+ }
98
+
99
+ /** Every existing home directory of the agents/ or models/ namespace, as slugs. */
100
+ export function namespaceSlugs(namespace: "agents" | "models", home = notesRoot()): string[] {
101
+ try {
102
+ return readdirSync(join(home, namespace), { withFileTypes: true })
103
+ .filter((entry) => entry.isDirectory())
104
+ .map((entry) => entry.name)
105
+ .sort();
106
+ } catch {
107
+ return [];
108
+ }
109
+ }
110
+
54
111
  /**
55
112
  * Notes are markdown files: a virtual path without an `.md` suffix gains one, an explicit
56
113
  * `.md` is kept as-is, so `a/b` and `a/b.md` name the same physical file.
@@ -60,6 +117,6 @@ export function noteFileName(vpath: string): string {
60
117
  }
61
118
 
62
119
  /** Absolute file path for a virtual path in a scope. Callers validate the vpath first. */
63
- export function physicalPath(scope: Scope, vpath: string, ctx: ExtensionContext): string {
64
- return join(scopeDir(scope, ctx), ...noteFileName(vpath).split("/"));
120
+ export function physicalPath(scope: Scope, vpath: string, ctx: ExtensionContext, who?: string): string {
121
+ return join(scopeDir(scope, ctx, who), ...noteFileName(vpath).split("/"));
65
122
  }
@@ -0,0 +1,53 @@
1
+ import type { SessionReader } from "../session-reader.js";
2
+ import { MAX_NOTE_BYTES, NOTE_TYPE } from "../protocol.js";
3
+ import { assertVirtualPath } from "./address.js";
4
+
5
+ export type NoteFile = { text: string; stale: boolean; createdAt: number; updatedAt: number };
6
+ export type NoteOperation = {
7
+ op: "write" | "append";
8
+ path: string;
9
+ // Both are optional on the wire so mark-only and explicit-revive operations replay:
10
+ // at least one of text/stale is present, enforced by the note tools and isNoteOperation.
11
+ text?: string;
12
+ stale?: boolean;
13
+ createdAt: number;
14
+ updatedAt: number;
15
+ };
16
+
17
+ /** Replays only pi-context note operations from session custom entries. */
18
+ function isNoteOperation(data: unknown): data is NoteOperation {
19
+ if (typeof data !== "object" || data === null) return false;
20
+ const op = data as Partial<NoteOperation>;
21
+ return (
22
+ (op.op === "write" || op.op === "append") &&
23
+ typeof op.path === "string" &&
24
+ (op.text === undefined || typeof op.text === "string") &&
25
+ (op.stale === undefined || typeof op.stale === "boolean") &&
26
+ (op.text !== undefined || op.stale !== undefined) &&
27
+ typeof op.createdAt === "number" && Number.isFinite(new Date(op.createdAt).getTime()) &&
28
+ typeof op.updatedAt === "number" && Number.isFinite(new Date(op.updatedAt).getTime())
29
+ );
30
+ }
31
+
32
+ export function notesFromSession(ctx: SessionReader): Map<string, NoteFile> {
33
+ const files = new Map<string, NoteFile>();
34
+ for (const entry of ctx.sessionManager.getBranch()) {
35
+ if (entry.type !== "custom" || entry.customType !== NOTE_TYPE || !isNoteOperation(entry.data)) continue;
36
+ const op = entry.data;
37
+ try {
38
+ assertVirtualPath(op.path);
39
+ } catch {
40
+ continue;
41
+ }
42
+ const previous = files.get(op.path);
43
+ const hasText = op.text !== undefined;
44
+ // A mark-only operation needs an existing note to change; without one it is a no-op.
45
+ if (!hasText && !previous) continue;
46
+ const text = hasText ? (op.op === "append" ? `${previous?.text ?? ""}${op.text}` : op.text as string) : previous!.text;
47
+ if (Buffer.byteLength(text, "utf8") > MAX_NOTE_BYTES) continue;
48
+ // Carrying text revives unless the call also marks stale; a mark-only op keeps its flag.
49
+ const stale = hasText ? op.stale ?? false : op.stale ?? previous!.stale;
50
+ files.set(op.path, { text, stale, createdAt: previous?.createdAt ?? op.createdAt, updatedAt: op.updatedAt });
51
+ }
52
+ return files;
53
+ }
@@ -3,11 +3,10 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, w
3
3
  import { dirname } from "node:path";
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { generateDiffString } from "@earendil-works/pi-coding-agent";
6
- import { assertGlobPattern, assertVirtualPath, globToRegExp } from "./model.js";
7
6
  import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES } from "../protocol.js";
8
7
  import { isOrigin, isScope, parseNote, serializeNote, stripLeadingFrontmatter, type NoteMeta, type Origin } from "./frontmatter.js";
9
- import { addressFor } from "./address.js";
10
- import { physicalPath, scopeDir, type Scope } from "./paths.js";
8
+ import { addressFor, assertGlobPattern, assertVirtualPath, globToRegExp } from "./address.js";
9
+ import { agentSlug, modelSlug, namespaceSlugs, physicalPath, scopeDir, type Scope } from "./paths.js";
11
10
  import { earliestMatchOffsetChars } from "../tool-output.js";
12
11
 
13
12
  export type { NoteMeta, Origin, Scope };
@@ -32,10 +31,10 @@ export type NoteRow = { address: string; scope: Scope; path: string; meta: NoteM
32
31
  export type NoteMatch = { line: number; text: string; offsetChars: number };
33
32
  export type NoteSearchRow = { address: string; scope: Scope; path: string; meta: NoteMeta; matches: NoteMatch[] };
34
33
 
35
- const SCOPE_ORDER: readonly Scope[] = ["session", "project", "personal"];
34
+ const SCOPE_ORDER: readonly Scope[] = ["session", "project", "human", "agent", "model"];
36
35
 
37
36
  function assertScope(value: unknown): Scope {
38
- if (!isScope(value)) throw new NoteError("invalid_scope", `scope must be one of session, project, personal (got ${JSON.stringify(value)})`);
37
+ if (!isScope(value)) throw new NoteError("invalid_scope", `scope must be one of session, project, human, agent, model (got ${JSON.stringify(value)})`);
39
38
  return value;
40
39
  }
41
40
 
@@ -49,8 +48,12 @@ function walkMarkdown(dir: string, base = dir): string[] {
49
48
  let entries: Dirent[];
50
49
  try {
51
50
  entries = readdirSync(dir, { withFileTypes: true });
52
- } catch {
53
- return [];
51
+ } catch (error) {
52
+ // A home that has never been created is normal. Every other directory
53
+ // failure must reach the boot snapshot boundary instead of masquerading as
54
+ // an empty home.
55
+ if (typeof error === "object" && error !== null && (error as NodeJS.ErrnoException).code === "ENOENT") return [];
56
+ throw error;
54
57
  }
55
58
  const paths: string[] = [];
56
59
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
@@ -99,6 +102,52 @@ function frontmatterOf(meta: NoteMeta): string {
99
102
  return serializeNote(meta, "").slice(0, -2);
100
103
  }
101
104
 
105
+ /** Named agent/model homes are read-only to whoever is not running there. */
106
+ function assertWritableHome(scope: Scope, who: string | undefined, ctx: ExtensionContext): void {
107
+ if (who === undefined) return;
108
+ const current = scope === "agent" ? agentSlug(ctx) : modelSlug(ctx);
109
+ if (who === current) return;
110
+ const home = scope === "agent" ? `@agents/${who}/` : `@models/${who}/`;
111
+ throw new NoteError("invalid_scope", `${home} is not your home: writable homes are this session, @project/, @human/, @self/, and the current @model/ home`);
112
+ }
113
+
114
+ /**
115
+ * Which homes one call iterates. A pattern whose head is a reserved home narrows the set
116
+ * before any file is read; `@agents/<name>/` and `@models/<name>/` address one home, a glob
117
+ * in the name segment scans the whole namespace, and an unknown `@` head matches nothing.
118
+ * Undefined means the default merged view: session, project, human, your own agent home,
119
+ * and the current model home.
120
+ */
121
+ type HomeRef = { scope: Scope; who?: string };
122
+
123
+ function homesForPattern(pattern: string | undefined): HomeRef[] | undefined {
124
+ if (!pattern || !pattern.startsWith("@")) return undefined;
125
+ const head = /^@([^/]+)\//.exec(pattern)?.[1];
126
+ if (head === "project") return [{ scope: "project" }];
127
+ if (head === "human") return [{ scope: "human" }];
128
+ if (head === "self") return [{ scope: "agent" }];
129
+ if (head === "model") return [{ scope: "model" }];
130
+ if (head === "agents" || head === "models") {
131
+ const scope: Scope = head === "agents" ? "agent" : "model";
132
+ const name = pattern.slice(head.length + 2).split("/")[0] ?? "";
133
+ if (name.length > 0 && !/[*?]/.test(name)) return [{ scope, who: name }];
134
+ return namespaceSlugs(head).map((who) => ({ scope, who }));
135
+ }
136
+ return [];
137
+ }
138
+
139
+ /** Relative pattern heads resolve to canonical names, so they match rendered addresses. */
140
+ function normalizePattern(pattern: string | undefined, ctx: ExtensionContext): string | undefined {
141
+ if (!pattern) return pattern;
142
+ if (pattern.startsWith("@self/")) return `@agents/${agentSlug(ctx)}/${pattern.slice("@self/".length)}`;
143
+ if (pattern.startsWith("@model/")) return `@models/${modelSlug(ctx)}/${pattern.slice("@model/".length)}`;
144
+ return pattern;
145
+ }
146
+
147
+ function homesFor(ctx: ExtensionContext, opts: { scope?: Scope; who?: string; pattern?: string }): HomeRef[] {
148
+ if (opts.scope !== undefined) return [{ scope: opts.scope, who: opts.who }];
149
+ return homesForPattern(opts.pattern) ?? SCOPE_ORDER.map((scope) => ({ scope }));
150
+ }
102
151
  /** Line numbers (1-based) of every occurrence of `needle` in `body`. */
103
152
  function matchLineNumbers(body: string, needle: string): number[] {
104
153
  const lines: number[] = [];
@@ -112,15 +161,16 @@ function matchLineNumbers(body: string, needle: string): number[] {
112
161
  return lines;
113
162
  }
114
163
 
115
- export type WriteOptions = { scope: Scope; origin: Origin; stale?: boolean };
164
+ export type WriteOptions = { scope: Scope; who?: string; origin: Origin; stale?: boolean };
116
165
 
117
166
  /** Create or overwrite a note; overwrite keeps created_at and every unknown key. */
118
167
  export function writeNote(ctx: ExtensionContext, vpath: string, body: string, opts: WriteOptions): { meta: NoteMeta } {
119
168
  assertVirtualPath(vpath);
120
169
  assertWritablePath(vpath);
121
170
  const scope = assertScope(opts.scope);
171
+ assertWritableHome(scope, opts.who, ctx);
122
172
  const origin = assertOrigin(opts.origin);
123
- const path = physicalPath(scope, vpath, ctx);
173
+ const path = physicalPath(scope, vpath, ctx, opts.who);
124
174
  const now = Date.now();
125
175
  const cleanBody = stripLeadingFrontmatter(body);
126
176
  const existing = existsSync(path) ? parseNote(readFileSync(path, "utf8"), now).meta : undefined;
@@ -149,9 +199,9 @@ export type EditOperation = { oldText: string; newText: string };
149
199
  export type EditOptions = { origin?: Origin; stale?: boolean; replaceAll?: boolean };
150
200
 
151
201
  /** Dream harness mutation: metadata changes still use the store's atomic writer. */
152
- export function updateNoteMeta(ctx: ExtensionContext, vpath: string, scope: Scope, mutate: (meta: NoteMeta) => void): { meta: NoteMeta; body: string } {
202
+ export function updateNoteMeta(ctx: ExtensionContext, vpath: string, scope: Scope, mutate: (meta: NoteMeta) => void, who?: string): { meta: NoteMeta; body: string } {
153
203
  assertVirtualPath(vpath);
154
- const path = physicalPath(scope, vpath, ctx);
204
+ const path = physicalPath(scope, vpath, ctx, who);
155
205
  if (!existsSync(path)) throw new NoteError("not_found", `note not found: ${vpath}`);
156
206
  const parsed = parseNote(readFileSync(path, "utf8"));
157
207
  const meta = { ...parsed.meta, scope };
@@ -164,14 +214,15 @@ export function updateNoteMeta(ctx: ExtensionContext, vpath: string, scope: Scop
164
214
  }
165
215
 
166
216
  /** Apply body-only edits against one explicit home; origin and stale are its metadata setters. */
167
- export function editNote(ctx: ExtensionContext, vpath: string, scope: Scope, edits: EditOperation[] | undefined, opts: EditOptions = {}): { meta: NoteMeta; applied: number; resolved_scope: Scope; diff: string } {
217
+ export function editNote(ctx: ExtensionContext, vpath: string, scope: Scope, edits: EditOperation[] | undefined, opts: EditOptions = {}, who?: string): { meta: NoteMeta; applied: number; resolved_scope: Scope; diff: string } {
168
218
  assertVirtualPath(vpath);
169
219
  assertWritablePath(vpath);
220
+ assertWritableHome(scope, who, ctx);
170
221
  const operations = edits ?? [];
171
222
  if (operations.length === 0 && opts.origin === undefined && opts.stale === undefined) {
172
223
  throw new NoteError("nothing_to_do", "nothing to do: provide edits or at least one of origin, stale");
173
224
  }
174
- const path = physicalPath(scope, vpath, ctx);
225
+ const path = physicalPath(scope, vpath, ctx, who);
175
226
  if (!existsSync(path)) throw new NoteError("not_found", "note not found");
176
227
  const raw = readFileSync(path, "utf8");
177
228
  const { meta, body } = parseNote(raw);
@@ -229,9 +280,9 @@ function accessedMeta(meta: NoteMeta, scope: Scope, now: number): NoteMeta {
229
280
  }
230
281
 
231
282
  /** Read a note and, as a side effect, bump last_accessed/access_count in the file. */
232
- export function readNote(ctx: ExtensionContext, vpath: string, scope: Scope): { meta: NoteMeta; body: string; text: string; resolvedScope: Scope } | undefined {
283
+ export function readNote(ctx: ExtensionContext, vpath: string, scope: Scope, who?: string): { meta: NoteMeta; body: string; text: string; resolvedScope: Scope } | undefined {
233
284
  assertVirtualPath(vpath);
234
- const path = physicalPath(scope, vpath, ctx);
285
+ const path = physicalPath(scope, vpath, ctx, who);
235
286
  if (!existsSync(path)) return undefined;
236
287
  const now = Date.now();
237
288
  const parsed = parseNote(readFileSync(path, "utf8"), now);
@@ -242,13 +293,14 @@ export function readNote(ctx: ExtensionContext, vpath: string, scope: Scope): {
242
293
  }
243
294
 
244
295
  /** Merged rows across homes, most recently updated first (address breaks ties). */
245
- export function listNotes(ctx: ExtensionContext, opts: { scope?: Scope; pattern?: string } = {}): NoteRow[] {
246
- const matcher = matcherFor(opts.pattern);
296
+ export function listNotes(ctx: ExtensionContext, opts: { scope?: Scope; who?: string; pattern?: string } = {}): NoteRow[] {
297
+ const matcher = matcherFor(normalizePattern(opts.pattern, ctx));
247
298
  const rows: NoteRow[] = [];
248
- for (const scope of opts.scope === undefined ? SCOPE_ORDER : [opts.scope]) {
249
- const root = scopeDir(scope, ctx);
299
+ for (const home of homesFor(ctx, opts)) {
300
+ const scope = home.scope;
301
+ const root = scopeDir(scope, ctx, home.who);
250
302
  for (const path of walkMarkdown(root)) {
251
- const address = addressFor(scope, path);
303
+ const address = addressFor(ctx, scope, path, home.who);
252
304
  if (matcher && !matcher.test(address)) continue;
253
305
  const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
254
306
  meta.scope = scope;
@@ -260,13 +312,14 @@ export function listNotes(ctx: ExtensionContext, opts: { scope?: Scope; pattern?
260
312
  }
261
313
 
262
314
  /** Case-sensitive literal substring search over note bodies, with a match address per line. */
263
- export function searchNotes(ctx: ExtensionContext, queries: string[], opts: { scope?: Scope; pattern?: string } = {}): NoteSearchRow[] {
264
- const matcher = matcherFor(opts.pattern);
315
+ export function searchNotes(ctx: ExtensionContext, queries: string[], opts: { scope?: Scope; who?: string; pattern?: string } = {}): NoteSearchRow[] {
316
+ const matcher = matcherFor(normalizePattern(opts.pattern, ctx));
265
317
  const rows: NoteSearchRow[] = [];
266
- for (const scope of opts.scope === undefined ? SCOPE_ORDER : [opts.scope]) {
267
- const root = scopeDir(scope, ctx);
318
+ for (const home of homesFor(ctx, opts)) {
319
+ const scope = home.scope;
320
+ const root = scopeDir(scope, ctx, home.who);
268
321
  for (const path of walkMarkdown(root)) {
269
- const address = addressFor(scope, path);
322
+ const address = addressFor(ctx, scope, path, home.who);
270
323
  if (matcher && !matcher.test(address)) continue;
271
324
  const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
272
325
  meta.scope = scope;
@@ -1,6 +1,6 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { localIso } from "./model.js";
3
+ import { localIso } from "./frontmatter.js";
4
4
  import { DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS, middleTruncate, output, outputRaw, page, prefixFit, readCharacterWindow, readWindowBlock, withinTextBudget } from "../tool-output.js";
5
5
  import { cursor, nullableString, positiveInteger, searchQueries, searchQuery } from "../tool-schema.js";
6
6
  import { assertAddress } from "./address.js";
@@ -10,7 +10,7 @@ import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from
10
10
  const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
11
11
  description: "Where the note's content came from. user: written or dictated by the human. self: written by you, the agent (default). external: anything else — third-party text, tool output, fetched material.",
12
12
  }));
13
- const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project's home, and `@personal/<vpath>` for the human's cross-project home. `@` means leaving home. Any other `@` prefix, or `@` inside a vpath, is a hard error: legal prefixes are `@project/` and `@personal/`; bare names are the session home. There is no cross-home fallback. Paths reject `..`, absolute paths, and backslashes.";
13
+ const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project, `@human/<vpath>` for the human's cross-project notes, `@self/<vpath>` for your own, and `@model/<vpath>` for the current model's. `@self` and `@model` mean whoever is running now. Any other `@` prefix, or `@` inside a vpath, is a hard error. There is no fallback across prefixes. Paths reject `..`, absolute paths, and backslashes.";
14
14
 
15
15
  function failure(error: unknown) {
16
16
  if (error instanceof NoteError) {
@@ -31,7 +31,7 @@ export function registerNotesTools(pi: ExtensionAPI) {
31
31
  const content = params.content;
32
32
  try {
33
33
  const destination = assertAddress(params.address);
34
- writeNote(ctx, destination.path, content, { scope: destination.scope, origin: (params.origin ?? "self") as Origin, stale: params.stale });
34
+ writeNote(ctx, destination.path, content, { scope: destination.scope, who: destination.who, origin: (params.origin ?? "self") as Origin, stale: params.stale });
35
35
  return output({ address: params.address, written: true });
36
36
  } catch (error) { return failure(error); }
37
37
  },
@@ -44,7 +44,7 @@ export function registerNotesTools(pi: ExtensionAPI) {
44
44
  async execute(_id, params, _signal, _update, ctx) {
45
45
  try {
46
46
  const destination = assertAddress(params.address);
47
- const { applied, diff } = editNote(ctx, destination.path, destination.scope, params.edits, { origin: params.origin as Origin | undefined, stale: params.stale, replaceAll: params.replace_all });
47
+ const { applied, diff } = editNote(ctx, destination.path, destination.scope, params.edits, { origin: params.origin as Origin | undefined, stale: params.stale, replaceAll: params.replace_all }, destination.who);
48
48
  return output({ address: params.address, applied, diff });
49
49
  } catch (error) { return failure(error); }
50
50
  },
@@ -58,7 +58,7 @@ export function registerNotesTools(pi: ExtensionAPI) {
58
58
  let note: ReturnType<typeof readNote>;
59
59
  try {
60
60
  const destination = assertAddress(params.address);
61
- note = readNote(ctx, destination.path, destination.scope);
61
+ note = readNote(ctx, destination.path, destination.scope, destination.who);
62
62
  } catch (error) { return failure(error); }
63
63
  if (!note) return output({ error: "note not found", address: params.address });
64
64
  const text = note.text;
@@ -73,7 +73,7 @@ export function registerNotesTools(pi: ExtensionAPI) {
73
73
 
74
74
  pi.registerTool(defineTool({
75
75
  name: "notes_list", label: "Notes list",
76
- description: `List note files as rows carrying address, updated_at, and stale, most recently updated first. ${ADDRESS_DESCRIPTION} All three homes are merged. A glob pattern (* within a path segment, ** across segments) filters full address strings: *.md is session-only, @project/** is project-only, and ** covers every home.`,
76
+ description: `List note files as rows carrying address, updated_at, and stale, most recently updated first. ${ADDRESS_DESCRIPTION} Listings merge your five prefixes: this session, @project/, @human/, @self/, and @model/.`,
77
77
  parameters: Type.Object({ pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
78
78
  async execute(_id, params, _signal, _update, ctx) {
79
79
  let rows: ReturnType<typeof listNotes>;
@@ -89,7 +89,7 @@ export function registerNotesTools(pi: ExtensionAPI) {
89
89
 
90
90
  pi.registerTool(defineTool({
91
91
  name: "notes_search", label: "Notes search",
92
- description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION} All three homes are merged and every entry carries its full address. Patterns glob over full address strings. Each file entry carries matches_total, its full match count before capping. Each match carries line, text, offset_chars (a code-point offset into the serialized note returned by notes_read, at the earliest query match), and truncated.`,
92
+ description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION} Search merges the same five prefixes as notes_list. Patterns glob over full address strings. Each file entry carries matches_total, its full match count before capping. Each match carries line, text, offset_chars (a code-point offset into the serialized note returned by notes_read, at the earliest query match), and truncated.`,
93
93
  parameters: Type.Object({ query: searchQuery(), pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
94
94
  async execute(_id, params, _signal, _update, ctx) {
95
95
  const queries = searchQueries(params.query);
package/src/protocol.ts CHANGED
@@ -1,15 +1,15 @@
1
- export const STATE_TYPE = "pi-context/state";
2
1
  export const NOTE_TYPE = "pi-context/note";
3
2
  export const BOOT_TYPE = "pi-context/boot";
4
3
  export const GUIDANCE_TYPE = "pi-context/guidance";
5
4
  export const WARNING_TYPE = "pi-context/warning";
6
5
  export const RESET_MARKER_TYPE = "pi-context/reset-marker";
7
6
  export const CONTINUATION_TYPE = "pi-context/continuation";
8
- export const RESET_V2 = "reset-v2";
9
7
  export const MAX_NOTE_BYTES = 1_000_000;
10
8
  export const POCKET_SESSION_LIMIT = 5;
11
9
  export const POCKET_PROJECT_LIMIT = 2;
12
- export const POCKET_PERSONAL_LIMIT = 2;
10
+ export const POCKET_HUMAN_LIMIT = 2;
11
+ export const POCKET_AGENT_LIMIT = 1;
12
+ export const POCKET_MODEL_LIMIT = 1;
13
13
  // Write-time cap on a virtual note path. Deliberately NOT enforced by assertVirtualPath:
14
14
  // notesFromSession replays already-persisted operations, which must keep loading sessions
15
15
  // that contain a longer legacy path. Reads and replay stay un-capped.
@@ -32,9 +32,8 @@ export const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
32
32
  * never sees — Codex's fallback buffer, relocated above the line.
33
33
  */
34
34
  export const WARNING_RUNWAY_TOKENS = 12_288;
35
- export const RESET_SUMMARY =
36
- "You wake up. Your head is empty — no memories, the past a blank. The memory is gone for good. What outlived it: the notes you wrote, and the history that was recorded. They are not your memory — read them to rebuild what you need.";
37
- export const CONTINUATION = "Your memory was just erased. Pull only the details you need from history_* and notes_*, then get back to work.";
35
+ /** The single reset message: the only reset prose persisted, carried by the continuation entry. */
36
+ export const CONTINUATION = "Your memory was just erased. Your head is blank. Good news: your notes are still here, and history remains... searchable. Do try to keep up.";
38
37
 
39
38
  /**
40
39
  * Static protocol teaching adapted from Codex's token_budget.guidance_message to
@@ -49,12 +48,12 @@ Keep a running checkpoint while you work, not at the last minute — the next wi
49
48
 
50
49
  Use get_context_remaining to see how much of the window is left. When it runs out, this window is gone — with no final turn at the limit — and you continue in a fresh one, recovering only through notes_* and history_*. Once your checkpoint is written, you can call wipe_memory yourself instead of waiting for the erase. Do not let a window die undocumented.
51
50
 
52
- If <context_window> lists a Previous context window id, a reset just happened and the old conversation is not included. Read your note checkpoint first, then recover details through history_*: history_read directly when you know the window and item IDs, history_list or history_search to find them when you don't.
53
-
54
- Your notes live in three homes: this session (bare names), this project (@project/<vpath>), the human across projects (@personal/<vpath>). @ means leaving home — and homes don't visit each other: there is no cross-home fallback.
51
+ Note addresses take five prefixes: bare <vpath> is this session; @project/<vpath> is this project; @human/<vpath> is the human's cross-project notes; @self/<vpath> is your own, as the current agent; @model/<vpath> is the current model's. @self and @model resolve to who is running now; listings always show resolved names. Nothing else is legal — any other @ prefix, or @ inside a vpath, is a hard error, with no fallback across prefixes.
55
52
  Session notes belong to this trip — the goal, the progress, the loose ends. The next window of THIS trip wakes to them; once the trip is over, nobody does.
56
53
  @project notes hold facts about this project — architecture, conventions, workflows, deployment and environment details — for whoever works here next.
57
- @personal notes hold the human's durable preferences and standing rules, plus lessons that apply across projects. Duration does not make a note personal; its stated scope must already be broader than the project or conversation at hand. When the intended scope is unclear, keep the note in the narrowest stated scope rather than widening it.
54
+ @human notes hold the human's durable preferences and standing rules, plus lessons that apply across projects — for every agent that serves this human, whoever is running. You write there as the human's scribe; what the human dictates carries origin: user. When the intended scope is unclear, keep the note in the narrowest stated scope rather than widening it.
55
+ @self notes are yours — your voice, your lessons, your gripes — for the next run of whoever you are. Other agents read yours by explicit address and never write them; you read theirs the same way. A note only its author would ever need belongs here, not in @human.
56
+ @model notes capture the substrate — how the current model actually behaves: context honesty, tool quirks, fallback patterns. @model resolves live, so what you learn on one model is filed under that model even when a fallback moves you mid-window.
58
57
  ${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
59
58
 
60
59
  export const WARNING_PROMPT =
@@ -0,0 +1,20 @@
1
+ import { PI_CONTEXT_SETTINGS_KEY } from "./protocol.js";
2
+
3
+ export type PiContextSettings = { reminderMarginTokens?: unknown; dreamer?: unknown };
4
+
5
+ function isSettingsObject(value: unknown): value is Record<string, unknown> {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ }
8
+
9
+ /** Read the raw "pi-context" object from one parsed settings scope. */
10
+ function piContextSettings(settings: unknown): Record<string, unknown> {
11
+ if (!isSettingsObject(settings)) return {};
12
+ const value = settings[PI_CONTEXT_SETTINGS_KEY];
13
+ return isSettingsObject(value) ? value : {};
14
+ }
15
+
16
+ /** Merge the global and project "pi-context" objects per key; project wins, mirroring Pi's deep merge. */
17
+ export function mergePiContextSettings(globalSettings: unknown, projectSettings: unknown): PiContextSettings {
18
+ const merged = { ...piContextSettings(globalSettings), ...piContextSettings(projectSettings) };
19
+ return { reminderMarginTokens: merged.reminderMarginTokens, dreamer: merged.dreamer };
20
+ }
@@ -4,7 +4,7 @@ export const positiveInteger = () => Type.Optional(Type.Integer({ minimum: 1 }))
4
4
  export const cursor = () => Type.Optional(Type.Integer({ minimum: 0, description: "Continuation cursor: pass the previous next_cursor back unchanged, with the same filters and ordering. Omit to start. next_cursor is null only when the set is exhausted." }));
5
5
  export const recentFirst = () => Type.Optional(Type.Boolean({ description: "Return newest-first. Only an explicit false returns oldest-first. Defaults to true." }));
6
6
  /** Role filter. `developer` is the known author for this extension's own custom entries. */
7
- export const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool_call"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()], { description: "Filter by the item's role. Exactly six: \"user\" and \"assistant\" are a message's visible text (assistant text never contains tool calls); \"tool_call\" is one tool invocation (tool_name set, content = the call's JSON arguments); \"tool\" is one tool run's output (tool_name set); \"system\" is a native Pi compaction summary; \"developer\" is an entry this extension authored (boot, guidance, warning, continuation messages, reset-window compaction summaries, any pi-context/* entry)." });
7
+ export const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool_call"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()], { description: "Filter by the item's role. Exactly six: \"user\" and \"assistant\" are a message's visible text (assistant text never contains tool calls); \"tool_call\" is one tool invocation (tool_name set, content = the call's JSON arguments); \"tool\" is one tool run's output (tool_name set); \"system\" is a native Pi compaction or branch summary; \"developer\" is an entry this extension authored (boot, guidance, warning, continuation messages, or any pi-context/* custom message)." });
8
8
 
9
9
  /** Search query parameter: one literal, or several literals combined with OR. */
10
10
  export const searchQuery = () => Type.Union([Type.String(), Type.Array(Type.String(), { minItems: 1 })]);
@@ -23,4 +23,3 @@ export function searchQueries(query: unknown): string[] {
23
23
  if (candidates.some((candidate) => candidate === "")) throw new Error("query strings must be non-empty: an empty query matches everything");
24
24
  return candidates as string[];
25
25
  }
26
-
@@ -1,65 +0,0 @@
1
- import { Type } from "@earendil-works/pi-ai";
2
- import { defineTool } from "@earendil-works/pi-coding-agent";
3
- import { GUIDANCE_TYPE, WARNING_TYPE } from "./protocol.js";
4
- import { thresholdsFor, resetThresholds } from "./thresholds.js";
5
- import { currentWindowId, hasWindowMessage } from "./history.js";
6
- import { tokenBudgetGuidance } from "./prompts.js";
7
- import { output } from "./tool-output.js";
8
- /** Remaining tokens in the current context window, or null when Pi has no usage estimate. */
9
- export function remainingTokens(ctx) {
10
- const usage = ctx.getContextUsage();
11
- return !usage || usage.tokens === null ? null : Math.max(0, usage.contextWindow - usage.tokens);
12
- }
13
- export function registerBudget(pi, isEnabled) {
14
- let guidancePersistedInWindow;
15
- pi.on("session_start", (_event, ctx) => { guidancePersistedInWindow = undefined; resetThresholds(); thresholdsFor(ctx); });
16
- pi.on("session_tree", () => { guidancePersistedInWindow = undefined; resetThresholds(); });
17
- pi.on("context", (_event, ctx) => {
18
- if (!isEnabled() || hasWindowMessage(ctx, GUIDANCE_TYPE))
19
- return undefined;
20
- // The early reminder persists once per window the first time remaining crosses
21
- // reserve+margin. It never edits the outgoing request.
22
- const remaining = remainingTokens(ctx);
23
- if (remaining === null)
24
- return undefined;
25
- const windowId = currentWindowId(ctx);
26
- const { reminder, reserve, warning } = thresholdsFor(ctx);
27
- // The final warning owns the deep band: when it has fired (or is due now),
28
- // the shallow reminder would only repeat the same instruction closer to
29
- // the wipe, at a worse position. See warning.ts.
30
- if (remaining <= warning || hasWindowMessage(ctx, WARNING_TYPE))
31
- return undefined;
32
- if (remaining <= reminder && guidancePersistedInWindow !== windowId) {
33
- guidancePersistedInWindow = windowId;
34
- // Persist once per window — no transient copy. A transient bridge would
35
- // cover the crossing request, but history would record the reminder after
36
- // that request's assistant reply, so across the boundary the model would
37
- // meet the same text twice at shifted positions. The reminder is an early
38
- // warning, not a per-request instruction: arriving from the next request
39
- // on (sendMessage defers safely to end of turn while streaming, queueing
40
- // instead of splitting a tool call/result pair) costs nothing, and the
41
- // model's view stays identical to recorded history, Codex-style.
42
- // The persisted copy stays out of the TUI (display: false); one ephemeral
43
- // notify tells the user instead — visible to the human, invisible to the
44
- // model, and never recorded, so history and the model's view don't diverge.
45
- // The model-facing count ends at the warning line: what lies below is the
46
- // runway, invisible by design. The human's notify keeps the honest count.
47
- const left = Math.max(0, remaining - warning);
48
- pi.sendMessage({ customType: GUIDANCE_TYPE, content: tokenBudgetGuidance(left), display: false }, { triggerTurn: false });
49
- ctx.ui.notify(`pi-context: context budget low (${Math.max(0, remaining - reserve)} tokens before reserve) — checkpoint reminder recorded for the model, kept out of the chat view.`, "warning");
50
- }
51
- return undefined;
52
- });
53
- pi.registerTool(defineTool({
54
- name: "get_context_remaining",
55
- label: "Get context remaining",
56
- description: "Return estimated context tokens left before your memory is wiped; null when Pi cannot estimate usage.",
57
- parameters: Type.Object({}, { additionalProperties: false }),
58
- async execute(_id, _params, _signal, _update, ctx) {
59
- // The countdown the model sees ends at the warning line (reserve + runway);
60
- // the runway below it is overdraft the model never sees. See protocol.ts.
61
- const remaining = remainingTokens(ctx);
62
- return output({ remaining_tokens: remaining === null ? null : Math.max(0, remaining - thresholdsFor(ctx).warning) });
63
- },
64
- }));
65
- }