@astrosheep/pi-context 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,7 +71,7 @@ The nine Codex History/Notes actions are flattened because Pi tools have one glo
71
71
 
72
72
  `history_*` and paged `notes_*` read/search results share a **32 KiB (32 * 1024 UTF-8 bytes) per-result budget**. List/search tools return whole items only and include `next_offset` (an integer cursor, or `null` when exhausted); pass that cursor back with the same parameters to continue. `history_read_item` defaults to `limit_chars: 12000`, accepts at most 50000, and returns `total_chars` plus `next_offset_chars` when more content remains. `notes_read_file` returns whole lines only, includes `total_lines`, and uses `next_start_line` for a bounded continuation. When one indivisible unit is larger than the whole budget (a single note line, search match, or history item), it is returned middle-truncated — head and tail joined by a `…[truncated N chars]…` marker, mirroring Codex's `truncate_middle` — instead of being dropped, so a page is never empty and its cursor always advances. `max_chars_per_item` limits Unicode code points including the truncation ellipsis (`…`). The 32 KiB choice matches Aider's default and Claude Code's roughly 30k-character limit while keeping worst-case CJK output (about 11k tokens) below Pi's default `reserveTokens` of 16384.
73
73
 
74
- `notes_*` stores operation entries in the same append-only Pi session under `pi-context/note`. They are session-scoped, survive JSONL reload, never enter provider context, and use safe relative virtual paths only (no absolute paths, `..`, `.`, empty components, or backslashes). Searches are literal and case-sensitive. `notes_read_file` accepts inclusive 1-based line ranges; negative line numbers count from the last line. `notes_read_file` success results and every matched file object from `notes_search_contents` also carry `created_at` and `updated_at`, the same fields `notes_list_files_by_prefix` returns. All note timestamps are local-time ISO 8601 strings with an explicit UTC offset (for example `2026-09-15T17:31:45.392+08:00`; a UTC host renders `+00:00`, never `Z`), while the persisted `NoteFile`/`NoteOperation` metadata keeps plain epoch milliseconds. Error results carry no timestamps. Writes are capped at 1,000,000 UTF-8 bytes. `notes_write_file` and `notes_append_to_file` declare `executionMode: "sequential"` (the Pi equivalent of Codex's `supports_parallel_tool_calls = false`), so a tool batch containing either runs its calls one at a time and note read-modify-write cannot race with itself.
74
+ `notes_*` stores operation entries in the same append-only Pi session under `pi-context/note`. They are session-scoped, survive JSONL reload, never enter provider context, and use safe relative virtual paths only (no absolute paths, `..`, `.`, empty components, or backslashes). Searches are literal and case-sensitive. `notes_read_file` accepts inclusive 1-based line ranges; negative line numbers count from the last line. Staleness is explicit and never inferred from timestamps. `notes_write_file` and `notes_append_to_file` accept an optional `mark_stale` boolean, and `notes_list_files_by_prefix` reports each file's `stale` flag. Stale notes leave the boot notes index — which omits itself once no fresh note remains — but stay listed, readable, and searchable. Each call must carry `text`, `mark_stale`, or both: `mark_stale: true` alone flags a note without touching its content, `mark_stale: false` alone clears the flag without touching content, and either call may carry `text` too — so final content can be written and marked stale in one call, and a closing log line can be appended and marked stale in one call. Carrying `text` without `mark_stale` revives a stale note, and marking a path that does not exist is an error. `notes_read_file` success results and every matched file object from `notes_search_contents` also carry `created_at` and `updated_at`, the same fields `notes_list_files_by_prefix` returns. All note timestamps are local-time ISO 8601 strings with an explicit UTC offset (for example `2026-09-15T17:31:45.392+08:00`; a UTC host renders `+00:00`, never `Z`), while the persisted `NoteFile`/`NoteOperation` metadata keeps plain epoch milliseconds. Error results carry no timestamps. Writes are capped at 1,000,000 UTF-8 bytes. `notes_write_file` and `notes_append_to_file` declare `executionMode: "sequential"` (the Pi equivalent of Codex's `supports_parallel_tool_calls = false`), so a tool batch containing either runs its calls one at a time and note read-modify-write cannot race with itself.
75
75
 
76
76
  Unlike Codex, the history tools do not advertise `agent_name`: Pi has no cross-agent session routing, so the parameter is omitted from the schemas entirely (strict `additionalProperties: false` still rejects it) instead of costing schema tokens on every request.
77
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "description": "Codex-style context windows for Pi: reset-style compaction, durable session history tools, and persistent notes.",
6
6
  "license": "MIT",
package/src/note-tools.ts CHANGED
@@ -23,7 +23,7 @@ export function registerNoteTools(pi: ExtensionAPI) {
23
23
  const key = params.file_order_by ?? "name";
24
24
  files.sort(([aPath, a], [bPath, b]) => key === "name" ? aPath.localeCompare(bPath) : (key === "created_at" ? a.createdAt - b.createdAt : a.updatedAt - b.updatedAt));
25
25
  if (params.file_order === "descending") files.reverse();
26
- const listed = files.map(([path, file]) => ({ path, size_bytes: Buffer.byteLength(file.text, "utf8"), created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt) }));
26
+ const listed = files.map(([path, file]) => ({ path, size_bytes: Buffer.byteLength(file.text, "utf8"), stale: file.stale, created_at: localIso(file.createdAt), updated_at: localIso(file.updatedAt) }));
27
27
  return output(page(listed, params.offset ?? 0, "files", params.max_results, (file, fits) => ({ ...file, path: middleTruncate(file.path, (candidate) => fits({ ...file, path: candidate })) })));
28
28
  },
29
29
  }));
@@ -81,21 +81,30 @@ export function registerNoteTools(pi: ExtensionAPI) {
81
81
  pi.registerTool(defineTool({
82
82
  name,
83
83
  label: name === "notes_append_to_file" ? "Notes append" : "Notes write",
84
- description: name === "notes_append_to_file" ? "Append exact text to a persistent virtual note file." : "Create or replace a persistent virtual note file.",
85
- parameters: Type.Object({ text: Type.String(), path: Type.String() }, { additionalProperties: false }),
84
+ description: name === "notes_append_to_file"
85
+ ? "Append exact text to a persistent virtual note file. Appending suits chronological logs; for current-state notes, replace the whole file with notes_write_file instead. Accepts the same mark_stale flag to close a note."
86
+ : "Create or replace a persistent virtual note file. Keep notes small and split by topic; replace outdated notes whole. With mark_stale: true, flag the note as stale instead — optionally writing its final content in the same call: stale notes leave the boot index but stay readable and searchable, and rewriting revives them.",
87
+ parameters: Type.Object({ text: Type.Optional(Type.String()), path: Type.String(), mark_stale: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
86
88
  // Codex sets supports_parallel_tool_calls = false on notes.write_file/append_to_file.
87
89
  // Pi's per-tool equivalent is executionMode "sequential": a batch containing either
88
90
  // tool runs its calls one at a time, so note read-modify-write cannot race.
89
91
  executionMode: "sequential",
90
92
  async execute(_id, params, _signal, _update, ctx) {
91
93
  const path = assertVirtualPath(params.path);
94
+ const hasText = params.text !== undefined;
95
+ const hasStale = params.mark_stale !== undefined;
96
+ if (!hasText && !hasStale) return output({ error: "provide text, mark_stale, or both", path });
92
97
  const old = notesFromSession(ctx).get(path);
93
- const next = op === "append" ? `${old?.text ?? ""}${params.text}` : params.text;
98
+ if (!hasText && !old) return output({ error: "note file not found", path });
99
+ const next = hasText ? (op === "append" ? `${old?.text ?? ""}${params.text}` : params.text as string) : old!.text;
94
100
  const bytes = Buffer.byteLength(next, "utf8");
95
- if (bytes > MAX_NOTE_BYTES) return output({ error: `note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes`, path, size_bytes: bytes });
101
+ if (hasText && bytes > MAX_NOTE_BYTES) return output({ error: `note exceeds ${MAX_NOTE_BYTES} UTF-8 bytes`, path, size_bytes: bytes });
96
102
  const now = Date.now();
97
- saveNote({ op, path, text: params.text, createdAt: old?.createdAt ?? now, updatedAt: now });
98
- return output({ path, size_bytes: bytes, operation: op });
103
+ const operation: NoteOperation = { op, path, createdAt: old?.createdAt ?? now, updatedAt: now };
104
+ if (hasText) operation.text = params.text;
105
+ if (hasStale) operation.stale = params.mark_stale;
106
+ saveNote(operation);
107
+ return output({ path, size_bytes: bytes, operation: op, stale: hasStale ? params.mark_stale : false });
99
108
  },
100
109
  }));
101
110
  }
package/src/notes.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  import type { SessionReader } from "./session-reader.js";
2
2
  import { MAX_NOTE_BYTES, NOTE_TYPE } from "./protocol.js";
3
3
 
4
- export type NoteFile = { text: string; createdAt: number; updatedAt: number };
4
+ export type NoteFile = { text: string; stale: boolean; createdAt: number; updatedAt: number };
5
5
  export type NoteOperation = {
6
6
  op: "write" | "append";
7
7
  path: string;
8
- text: string;
8
+ // Both are optional on the wire so mark-only and explicit-revive operations replay:
9
+ // at least one of text/stale is present, enforced by the note tools and isNoteOperation.
10
+ text?: string;
11
+ stale?: boolean;
9
12
  createdAt: number;
10
13
  updatedAt: number;
11
14
  };
@@ -29,7 +32,9 @@ function isNoteOperation(data: unknown): data is NoteOperation {
29
32
  return (
30
33
  (op.op === "write" || op.op === "append") &&
31
34
  typeof op.path === "string" &&
32
- typeof op.text === "string" &&
35
+ (op.text === undefined || typeof op.text === "string") &&
36
+ (op.stale === undefined || typeof op.stale === "boolean") &&
37
+ (op.text !== undefined || op.stale !== undefined) &&
33
38
  typeof op.createdAt === "number" && Number.isFinite(new Date(op.createdAt).getTime()) &&
34
39
  typeof op.updatedAt === "number" && Number.isFinite(new Date(op.updatedAt).getTime())
35
40
  );
@@ -46,10 +51,14 @@ export function notesFromSession(ctx: SessionReader): Map<string, NoteFile> {
46
51
  continue;
47
52
  }
48
53
  const previous = files.get(op.path);
49
- const text = op.op === "append" ? `${previous?.text ?? ""}${op.text}` : op.text;
50
- if (Buffer.byteLength(text, "utf8") <= MAX_NOTE_BYTES) {
51
- files.set(op.path, { text, createdAt: previous?.createdAt ?? op.createdAt, updatedAt: op.updatedAt });
52
- }
54
+ const hasText = op.text !== undefined;
55
+ // A mark-only operation needs an existing note to change; without one it is a no-op.
56
+ if (!hasText && !previous) continue;
57
+ const text = hasText ? (op.op === "append" ? `${previous?.text ?? ""}${op.text}` : op.text as string) : previous!.text;
58
+ if (Buffer.byteLength(text, "utf8") > MAX_NOTE_BYTES) continue;
59
+ // Carrying text revives unless the call also marks stale; a mark-only op keeps its flag.
60
+ const stale = hasText ? op.stale ?? false : op.stale ?? previous!.stale;
61
+ files.set(op.path, { text, stale, createdAt: previous?.createdAt ?? op.createdAt, updatedAt: op.updatedAt });
53
62
  }
54
63
  return files;
55
64
  }
package/src/prompts.ts CHANGED
@@ -15,15 +15,16 @@ function identityBlock(agentName: string, firstWindowId: string, currentWindowId
15
15
  }
16
16
 
17
17
  /**
18
- * Recent-notes index: up to three most-recent notes. Each note shows its path, line count,
19
- * UTF-8 byte count and local ISO update time, followed by an indented inline preview: the
20
- * whole text when it fits in NOTE_PREVIEW_CHARS, otherwise its first NOTE_PREVIEW_HEAD_CHARS
21
- * and last NOTE_PREVIEW_TAIL_CHARS Unicode characters joined by an explicit ellipsis. The
22
- * two slices never overlap, so the preview never duplicates head content as tail content.
23
- * Empty when the session has no notes.
18
+ * Recent-notes index: up to three most-recent fresh (non-stale) notes. Each note shows its
19
+ * path, line count, UTF-8 byte count and local ISO update time, followed by an indented inline
20
+ * preview: the whole text when it fits in NOTE_PREVIEW_CHARS, otherwise its first
21
+ * NOTE_PREVIEW_HEAD_CHARS and last NOTE_PREVIEW_TAIL_CHARS Unicode characters joined by an
22
+ * explicit ellipsis. The two slices never overlap, so the preview never duplicates head content
23
+ * as tail content. Stale notes are excluded entirely; empty when no fresh notes remain.
24
24
  */
25
25
  function notesIndex(ctx: ExtensionContext): string {
26
26
  const recentNotes = [...notesFromSession(ctx)]
27
+ .filter(([, file]) => !file.stale)
27
28
  .sort((a, b) => b[1].updatedAt - a[1].updatedAt)
28
29
  .slice(0, 3);
29
30
  if (recentNotes.length === 0) return "";