@astrosheep/pi-context 0.22.0 → 0.23.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 +5 -1
- package/dist/src/dream/cli.js +13 -1
- package/dist/src/dream/doctor.js +138 -0
- package/dist/src/history-tools.js +3 -4
- package/dist/src/notes/store.js +14 -8
- package/dist/src/notes/tools.js +14 -21
- package/dist/src/reset-lifecycle.js +82 -28
- package/dist/src/tool-output.js +8 -11
- package/dist/test/agent-loop.test.js +25 -9
- package/dist/test/coherence.test.js +32 -36
- package/dist/test/doctor.test.js +44 -0
- package/dist/test/integration.test.js +26 -27
- package/dist/test/notes.test.js +106 -20
- package/dist/test/pagination.property.test.js +24 -29
- package/dist/test/reset-lifecycle.test.js +99 -102
- package/docs/reset-lifecycle.md +4 -2
- package/package.json +1 -1
- package/src/dream/cli.ts +10 -1
- package/src/dream/doctor.ts +97 -0
- package/src/history-tools.ts +3 -4
- package/src/notes/store.ts +16 -9
- package/src/notes/tools.ts +16 -23
- package/src/reset-lifecycle.ts +89 -28
- package/src/tool-output.ts +8 -11
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ pi -e npm:@astrosheep/pi-context
|
|
|
34
34
|
| `notes.list` | `notes_list` |
|
|
35
35
|
| `notes.search` | `notes_search` |
|
|
36
36
|
|
|
37
|
-
The tool descriptions the model sees are the behavioral documentation: search is case-sensitive literal substring;
|
|
37
|
+
The tool descriptions the model sees are the behavioral documentation: note results use `address` as the sole home identity; search is case-sensitive literal substring; both `notes_read` and `history_read` are character windows prefixed with the same `READ WINDOW` block, whose cursors reconstruct the source exactly when only the content after each block is concatenated; anything a response does not deliver is named by an explicit field.
|
|
38
38
|
|
|
39
39
|
- **Runtime toggle** — `/pi-context off` restores Pi's default compaction (including `keepRecentTokens`); `/pi-context on` re-enables; bare `/pi-context` reports the current state.
|
|
40
40
|
|
|
@@ -59,6 +59,10 @@ The dreamer model is configured under the same key. `--dreamer <model pattern>`
|
|
|
59
59
|
}
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
## Check the notes store
|
|
63
|
+
|
|
64
|
+
Run `dream doctor` (or `dream doctor --notes-home <dir>`) to check home layout, note frontmatter, concrete backtick-quoted note addresses, MAP entries, and lock presence/format. It is read-only: no model, git commits, directory creation, or repairs. Exit status is 0 when clean and 1 when issues are found. References needing an unavailable project context are reported as unresolved; prose and example/glob addresses are not validated. A present lock is reported without inferring process liveness.
|
|
65
|
+
|
|
62
66
|
## The dream lock
|
|
63
67
|
|
|
64
68
|
The `dream` CLI takes an exclusive `.dream.lock` in the notes home with a single O_CREAT|O_EXCL creation. The lock is Git-style existence locking: an existing lock refuses a new run regardless of its contents, PID, or age, and `--force` bypasses only the scheduling and material gates, never the lock. A lock is released only by the run that acquired it (and repeated cleanup is harmless), so a live dream is never displaced.
|
package/dist/src/dream/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { materialGate, timeGate } from "./gates.js";
|
|
|
7
7
|
import { loadPlaybook, runDreamer } from "./runner.js";
|
|
8
8
|
import { gitCommit } from "./git.js";
|
|
9
9
|
import { readDreamerSettings } from "../thresholds.js";
|
|
10
|
+
import { doctor } from "./doctor.js";
|
|
10
11
|
import { notesRoot } from "../notes/paths.js";
|
|
11
12
|
function args(argv) { const out = {}; for (let i = 0; i < argv.length; i++) {
|
|
12
13
|
const a = argv[i];
|
|
@@ -67,9 +68,20 @@ function finishDream(home, stamp, reportPath, failed, body, writes) {
|
|
|
67
68
|
return failed || !audit.ok || reportError !== undefined ? 1 : 0;
|
|
68
69
|
}
|
|
69
70
|
export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
71
|
+
if (argv[0] === "doctor") {
|
|
72
|
+
const options = args(argv.slice(1));
|
|
73
|
+
if (options.help) {
|
|
74
|
+
console.log("dream doctor [--notes-home <dir>] — read-only diagnostics; no model or repairs");
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
const home = resolve(String(options["notes-home"] ?? notesRoot()));
|
|
78
|
+
const issues = doctor(home);
|
|
79
|
+
console.log(issues.length ? issues.join("\n") : `dream doctor: OK (${home})`);
|
|
80
|
+
return issues.length ? 1 : 0;
|
|
81
|
+
}
|
|
70
82
|
const a = args(argv);
|
|
71
83
|
if (a.help) {
|
|
72
|
-
console.log("dream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <model pattern>] [--playbook <path>]\nDreamer model: --dreamer wins, else pi-context.dreamer from settings, else the automatic model. Default playbook: <installed package root>/playbook.md; --playbook overrides it.");
|
|
84
|
+
console.log("dream doctor [--notes-home <dir>] — read-only diagnostics\ndream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <model pattern>] [--playbook <path>]\nDreamer model: --dreamer wins, else pi-context.dreamer from settings, else the automatic model. Default playbook: <installed package root>/playbook.md; --playbook overrides it.");
|
|
73
85
|
return 0;
|
|
74
86
|
}
|
|
75
87
|
const home = resolve(String(a["notes-home"] ?? notesRoot()));
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { basename, join, relative } from "node:path";
|
|
3
|
+
import { assertAddress } from "../notes/address.js";
|
|
4
|
+
/** Read-only diagnostics. Never follows symlinks or acquires/removes a dream lock. */
|
|
5
|
+
export function doctor(home) {
|
|
6
|
+
const issues = [];
|
|
7
|
+
const report = (path, message) => issues.push(`${relative(home, path) || "."}: ${message}`);
|
|
8
|
+
const inspect = (path, action) => {
|
|
9
|
+
try {
|
|
10
|
+
action();
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
report(path, `cannot inspect: ${error instanceof Error ? error.message : String(error)}`);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const directory = (path) => {
|
|
17
|
+
const stat = lstatSync(path);
|
|
18
|
+
if (stat.isDirectory())
|
|
19
|
+
return true;
|
|
20
|
+
report(path, "expected a directory (symlinks are not followed); check its location/type");
|
|
21
|
+
return false;
|
|
22
|
+
};
|
|
23
|
+
const checkNote = (path, root, project) => {
|
|
24
|
+
const raw = readFileSync(path, "utf8");
|
|
25
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
|
|
26
|
+
if (!match) {
|
|
27
|
+
report(path, "missing or unclosed frontmatter; add a valid metadata block");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const fields = new Map();
|
|
31
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
32
|
+
const field = /^([\w]+):\s*(.*?)\s*$/.exec(line);
|
|
33
|
+
if (!field)
|
|
34
|
+
continue;
|
|
35
|
+
if (fields.has(field[1]))
|
|
36
|
+
report(path, `duplicate metadata key ${field[1]}; keep one value`);
|
|
37
|
+
fields.set(field[1], field[2].replace(/^(["'])(.*)\1$/, "$2"));
|
|
38
|
+
}
|
|
39
|
+
for (const [key, valid] of Object.entries({ origin: /^(user|self|external)$/, status: /^(active|superseded|pending|archived)$/, stale: /^(true|false)$/, access_count: /^\d+$/ })) {
|
|
40
|
+
if (!valid.test(fields.get(key) ?? ""))
|
|
41
|
+
report(path, `missing/invalid ${key}; repair frontmatter`);
|
|
42
|
+
}
|
|
43
|
+
for (const key of ["created_at", "updated_at", "last_accessed"]) {
|
|
44
|
+
const value = fields.get(key);
|
|
45
|
+
if (!value || !Number.isFinite(Date.parse(value)))
|
|
46
|
+
report(path, `missing/invalid ${key}; use an ISO timestamp`);
|
|
47
|
+
}
|
|
48
|
+
if (fields.has("scope"))
|
|
49
|
+
report(path, "obsolete scope field; remove it (home determines scope)");
|
|
50
|
+
// Check concrete, code-formatted addresses; examples/globs and prose are not links.
|
|
51
|
+
for (const link of raw.slice(match[0].length).matchAll(/`([^`\n]+)`/g)) {
|
|
52
|
+
const address = link[1];
|
|
53
|
+
if (!address.endsWith(".md") || /[<>*?\s]/.test(address))
|
|
54
|
+
continue;
|
|
55
|
+
if (!address.startsWith("@") && basename(path) !== "MAP.md")
|
|
56
|
+
continue;
|
|
57
|
+
try {
|
|
58
|
+
const parsed = assertAddress(address);
|
|
59
|
+
const targetHome = parsed.scope === "personal" ? join(home, "personal") : parsed.scope === "project" ? project : root;
|
|
60
|
+
if (!targetHome) {
|
|
61
|
+
report(path, `${address}: project context unavailable; use a resolvable reference`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!existsSync(join(targetHome, parsed.path)))
|
|
65
|
+
report(path, `${address}: target missing; update or remove the reference`);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
report(path, `${address}: invalid address; use bare, @project/ or @personal/ addresses`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const walk = (dir, root, project) => {
|
|
73
|
+
for (const name of readdirSync(dir)) {
|
|
74
|
+
const path = join(dir, name);
|
|
75
|
+
inspect(path, () => {
|
|
76
|
+
const stat = lstatSync(path);
|
|
77
|
+
if (stat.isSymbolicLink())
|
|
78
|
+
report(path, "symlink not inspected; replace with a regular note/directory");
|
|
79
|
+
else if (stat.isDirectory())
|
|
80
|
+
walk(path, root, project);
|
|
81
|
+
else if (stat.isFile() && name.endsWith(".md"))
|
|
82
|
+
checkNote(path, root, project);
|
|
83
|
+
else
|
|
84
|
+
report(path, "unexpected file in note home; inspect and relocate it");
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
inspect(home, () => {
|
|
89
|
+
if (!directory(home))
|
|
90
|
+
return;
|
|
91
|
+
for (const name of readdirSync(home)) {
|
|
92
|
+
const path = join(home, name);
|
|
93
|
+
inspect(path, () => {
|
|
94
|
+
if (name === "global") {
|
|
95
|
+
report(path, "legacy home; manually migrate to personal/ without overwriting existing files");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (name === ".dream.lock") {
|
|
99
|
+
const valid = lstatSync(path).isFile() && /^[1-9]\d* [\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}\s*$/i.test(readFileSync(path, "utf8"));
|
|
100
|
+
report(path, `${valid ? "lock present" : "malformed lock"}; verify no dream is running before manual removal; liveness not inferred`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if ([".git", "dreams", "snapshots", "trash", ".dream.lock.last-run"].includes(name))
|
|
104
|
+
return;
|
|
105
|
+
if (name === "personal") {
|
|
106
|
+
if (directory(path))
|
|
107
|
+
walk(path, path);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (name === "project" || name === "pi") {
|
|
111
|
+
if (!directory(path))
|
|
112
|
+
return;
|
|
113
|
+
const homes = name === "pi" ? join(path, "session") : path;
|
|
114
|
+
if (name === "pi") {
|
|
115
|
+
for (const entry of readdirSync(path))
|
|
116
|
+
if (entry !== "session")
|
|
117
|
+
report(join(path, entry), "unexpected directory; expected pi/session/<id>/");
|
|
118
|
+
if (!existsSync(homes) || !directory(homes))
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
for (const id of readdirSync(homes)) {
|
|
122
|
+
const root = join(homes, id);
|
|
123
|
+
inspect(root, () => {
|
|
124
|
+
if (!directory(root))
|
|
125
|
+
return;
|
|
126
|
+
if (name === "project" && !/^.+-[\da-f]{8}$/.test(id))
|
|
127
|
+
report(root, "invalid project key; expected <name>-<8 hex>");
|
|
128
|
+
walk(root, root, name === "project" ? root : undefined);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
report(path, "unexpected root entry; expected personal/, project/, pi/session/ or dream artifacts");
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
return issues;
|
|
138
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Type } from "@earendil-works/pi-ai";
|
|
2
2
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow,
|
|
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
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
|
/**
|
|
@@ -64,7 +64,7 @@ export function registerHistoryTools(pi) {
|
|
|
64
64
|
pi.registerTool(defineTool({
|
|
65
65
|
name: "history_read",
|
|
66
66
|
label: "History read item",
|
|
67
|
-
description: "Read a bounded character range from one session item. Each response delivers the longest contiguous prefix of the requested window that fits the wire budget: follow the resume cursor to reconstruct the item exactly. A negative offset_chars counts back from the item's end. Offsets and counts are code points (an emoji or CJK character counts as one). The response
|
|
67
|
+
description: "Read a bounded character range from one session item. Each response delivers the longest contiguous prefix of the requested window that fits the wire budget: follow the resume cursor to reconstruct the item exactly. A negative offset_chars counts back from the item's end. Offsets and counts are code points (an emoji or CJK character counts as one). The response begins with the shared READ WINDOW block naming window_id and item_id; concatenate only the content after that block to reconstruct the item.",
|
|
68
68
|
parameters: Type.Object({ item_id: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from. A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_READ_WINDOW_CHARS, description: `Largest requested window in code points (default ${DEFAULT_READ_WINDOW_CHARS}). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes.` })), window_id: Type.String() }, { additionalProperties: false }),
|
|
69
69
|
async execute(_id, params, _signal, _update, ctx) {
|
|
70
70
|
const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
|
|
@@ -76,10 +76,9 @@ export function registerHistoryTools(pi) {
|
|
|
76
76
|
if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
|
|
77
77
|
return output({ error: `offset_chars ${params.offset_chars} is past the end: the item has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, window_id: item.windowId, item_id: item.itemId, offset_chars: params.offset_chars, total_chars: totalChars });
|
|
78
78
|
}
|
|
79
|
-
const limit_chars = Math.min(params.limit_chars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS);
|
|
80
79
|
return readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => {
|
|
81
80
|
const { content, ...cursor } = window;
|
|
82
|
-
return outputRaw(
|
|
81
|
+
return outputRaw(readWindowBlock([["window_id", item.windowId], ["item_id", item.itemId]], window), content, { window_id: item.windowId, item_id: item.itemId, ...cursor });
|
|
83
82
|
}, (result) => withinTextBudget(result.content[0].text));
|
|
84
83
|
},
|
|
85
84
|
}));
|
package/dist/src/notes/store.js
CHANGED
|
@@ -208,6 +208,13 @@ export function editNote(ctx, vpath, scope, edits, opts = {}) {
|
|
|
208
208
|
atomicWrite(path, serialized);
|
|
209
209
|
return { meta, applied: operations.length, resolved_scope: scope, diff };
|
|
210
210
|
}
|
|
211
|
+
/** Normalize a parsed note exactly as a read does, including its access metadata mutation. */
|
|
212
|
+
function accessedMeta(meta, scope, now) {
|
|
213
|
+
const next = { ...meta, scope };
|
|
214
|
+
next.last_accessed = now;
|
|
215
|
+
next.access_count = (typeof next.access_count === "number" ? next.access_count : 0) + 1;
|
|
216
|
+
return next;
|
|
217
|
+
}
|
|
211
218
|
/** Read a note and, as a side effect, bump last_accessed/access_count in the file. */
|
|
212
219
|
export function readNote(ctx, vpath, scope) {
|
|
213
220
|
assertVirtualPath(vpath);
|
|
@@ -215,13 +222,11 @@ export function readNote(ctx, vpath, scope) {
|
|
|
215
222
|
if (!existsSync(path))
|
|
216
223
|
return undefined;
|
|
217
224
|
const now = Date.now();
|
|
218
|
-
const
|
|
219
|
-
meta
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
atomicWrite(path, serializeNote(meta, body));
|
|
224
|
-
return { meta, body, resolvedScope: scope };
|
|
225
|
+
const parsed = parseNote(readFileSync(path, "utf8"), now);
|
|
226
|
+
const meta = accessedMeta(parsed.meta, scope, now);
|
|
227
|
+
const text = serializeNote(meta, parsed.body);
|
|
228
|
+
atomicWrite(path, text);
|
|
229
|
+
return { meta, body: parsed.body, text, resolvedScope: scope };
|
|
225
230
|
}
|
|
226
231
|
/** Merged rows across homes, most recently updated first (address breaks ties). */
|
|
227
232
|
export function listNotes(ctx, opts = {}) {
|
|
@@ -253,11 +258,12 @@ export function searchNotes(ctx, queries, opts = {}) {
|
|
|
253
258
|
continue;
|
|
254
259
|
const { meta, body } = parseNote(readFileSync(`${root}/${path}`, "utf8"));
|
|
255
260
|
meta.scope = scope;
|
|
261
|
+
const serializedBodyOffset = Array.from(serializeNote(accessedMeta(meta, scope, Date.now()), "")).length;
|
|
256
262
|
let baseChars = 0;
|
|
257
263
|
const matches = [];
|
|
258
264
|
for (const [index, line] of body.split("\n").entries()) {
|
|
259
265
|
if (queries.some((query) => line.includes(query))) {
|
|
260
|
-
matches.push({ line: index + 1, text: line, offsetChars: baseChars + earliestMatchOffsetChars(line, queries) });
|
|
266
|
+
matches.push({ line: index + 1, text: line, offsetChars: serializedBodyOffset + baseChars + earliestMatchOffsetChars(line, queries) });
|
|
261
267
|
}
|
|
262
268
|
baseChars += Array.from(line).length + 1;
|
|
263
269
|
}
|
package/dist/src/notes/tools.js
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
import { Type } from "@earendil-works/pi-ai";
|
|
2
2
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { localIso } from "./model.js";
|
|
4
|
-
import {
|
|
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";
|
|
7
|
-
import { serializeNote, stripLeadingFrontmatter } from "./frontmatter.js";
|
|
8
7
|
import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from "./store.js";
|
|
9
8
|
const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
|
|
10
9
|
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.",
|
|
11
10
|
}));
|
|
12
11
|
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
|
-
function wireMeta(meta) {
|
|
14
|
-
return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
|
|
15
|
-
}
|
|
16
12
|
function failure(error) {
|
|
17
13
|
if (error instanceof NoteError) {
|
|
18
14
|
const payload = { error: error.message };
|
|
@@ -33,8 +29,8 @@ export function registerNotesTools(pi) {
|
|
|
33
29
|
const content = params.content;
|
|
34
30
|
try {
|
|
35
31
|
const destination = assertAddress(params.address);
|
|
36
|
-
|
|
37
|
-
return output({ address: params.address,
|
|
32
|
+
writeNote(ctx, destination.path, content, { scope: destination.scope, origin: (params.origin ?? "self"), stale: params.stale });
|
|
33
|
+
return output({ address: params.address, written: true });
|
|
38
34
|
}
|
|
39
35
|
catch (error) {
|
|
40
36
|
return failure(error);
|
|
@@ -43,13 +39,13 @@ export function registerNotesTools(pi) {
|
|
|
43
39
|
}));
|
|
44
40
|
pi.registerTool(defineTool({
|
|
45
41
|
name: "notes_edit", label: "Notes edit",
|
|
46
|
-
description: `Edit a note body by exact-text replacement; frontmatter is never editable this way. ${ADDRESS_DESCRIPTION} Each oldText must occur exactly once unless replace_all is set; a multi-match anchor fails with its match line numbers and a zero-match anchor names the failing edit index. edits may be omitted (or empty) for a metadata-only update, which requires at least one of origin/stale. Moving while awake means notes_write at a new address and notes_edit at the old address with stale=true. The success return carries
|
|
42
|
+
description: `Edit a note body by exact-text replacement; frontmatter is never editable this way. ${ADDRESS_DESCRIPTION} Each oldText must occur exactly once unless replace_all is set; a multi-match anchor fails with its match line numbers and a zero-match anchor names the failing edit index. edits may be omitted (or empty) for a metadata-only update, which requires at least one of origin/stale. Moving while awake means notes_write at a new address and notes_edit at the old address with stale=true. The success return carries the address and a diff of what changed.`,
|
|
47
43
|
parameters: Type.Object({ address: Type.String(), edits: Type.Optional(Type.Array(Type.Object({ oldText: Type.String(), newText: Type.String() }, { additionalProperties: false }))), origin: ORIGIN, stale: Type.Optional(Type.Boolean()), replace_all: Type.Optional(Type.Boolean()) }, { additionalProperties: false }), executionMode: "sequential",
|
|
48
44
|
async execute(_id, params, _signal, _update, ctx) {
|
|
49
45
|
try {
|
|
50
46
|
const destination = assertAddress(params.address);
|
|
51
|
-
const {
|
|
52
|
-
return output({ address: params.address, applied,
|
|
47
|
+
const { applied, diff } = editNote(ctx, destination.path, destination.scope, params.edits, { origin: params.origin, stale: params.stale, replaceAll: params.replace_all });
|
|
48
|
+
return output({ address: params.address, applied, diff });
|
|
53
49
|
}
|
|
54
50
|
catch (error) {
|
|
55
51
|
return failure(error);
|
|
@@ -58,7 +54,7 @@ export function registerNotesTools(pi) {
|
|
|
58
54
|
}));
|
|
59
55
|
pi.registerTool(defineTool({
|
|
60
56
|
name: "notes_read", label: "Notes read",
|
|
61
|
-
description: `Read a character window of a note file, frontmatter included. ${ADDRESS_DESCRIPTION} offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end — and limit_chars caps the window (default ${DEFAULT_READ_WINDOW_CHARS}, max ${MAX_READ_WINDOW_CHARS}). Each response delivers the longest fitting prefix of that window
|
|
57
|
+
description: `Read a character window of a note file, frontmatter included. ${ADDRESS_DESCRIPTION} offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end — and limit_chars caps the window (default ${DEFAULT_READ_WINDOW_CHARS}, max ${MAX_READ_WINDOW_CHARS}). Each response delivers the longest fitting prefix of that window in the shared READ WINDOW block: concatenate only the content after the block to reconstruct the note.`,
|
|
62
58
|
parameters: Type.Object({ address: Type.String(), offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from (default 0). A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_READ_WINDOW_CHARS, description: `Largest requested window in code points (default ${DEFAULT_READ_WINDOW_CHARS}). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes.` })) }, { additionalProperties: false }),
|
|
63
59
|
async execute(_id, params, _signal, _update, ctx) {
|
|
64
60
|
let note;
|
|
@@ -71,22 +67,19 @@ export function registerNotesTools(pi) {
|
|
|
71
67
|
}
|
|
72
68
|
if (!note)
|
|
73
69
|
return output({ error: "note not found", address: params.address });
|
|
74
|
-
const text =
|
|
70
|
+
const text = note.text;
|
|
75
71
|
const totalChars = Array.from(text).length;
|
|
76
72
|
if (typeof params.offset_chars === "number" && params.offset_chars > totalChars)
|
|
77
73
|
return output({ error: `offset_chars ${params.offset_chars} is past the end: the note has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, address: params.address, offset_chars: params.offset_chars, total_chars: totalChars });
|
|
78
|
-
const created_at = localIso(note.meta.created_at);
|
|
79
|
-
const updated_at = localIso(note.meta.updated_at);
|
|
80
|
-
const limit_chars = Math.min(params.limit_chars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS);
|
|
81
74
|
return readCharacterWindow(text, params.offset_chars, params.limit_chars, (window) => {
|
|
82
75
|
const { content, ...rest } = window;
|
|
83
|
-
return outputRaw(
|
|
76
|
+
return outputRaw(readWindowBlock([["address", params.address]], window), content, { address: params.address, ...rest });
|
|
84
77
|
}, (result) => withinTextBudget(result.content[0].text));
|
|
85
78
|
},
|
|
86
79
|
}));
|
|
87
80
|
pi.registerTool(defineTool({
|
|
88
81
|
name: "notes_list", label: "Notes list",
|
|
89
|
-
description: `List note files as rows carrying address,
|
|
82
|
+
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.`,
|
|
90
83
|
parameters: Type.Object({ pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
|
|
91
84
|
async execute(_id, params, _signal, _update, ctx) {
|
|
92
85
|
let rows;
|
|
@@ -96,7 +89,7 @@ export function registerNotesTools(pi) {
|
|
|
96
89
|
catch (error) {
|
|
97
90
|
return failure(error);
|
|
98
91
|
}
|
|
99
|
-
const files = rows.map((row) => ({ address: row.address,
|
|
92
|
+
const files = rows.map((row) => ({ address: row.address, stale: row.meta.stale, updated_at: localIso(row.meta.updated_at) }));
|
|
100
93
|
return output(page(files, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
|
|
101
94
|
if (fits(file))
|
|
102
95
|
return file;
|
|
@@ -107,7 +100,7 @@ export function registerNotesTools(pi) {
|
|
|
107
100
|
}));
|
|
108
101
|
pi.registerTool(defineTool({
|
|
109
102
|
name: "notes_search", label: "Notes search",
|
|
110
|
-
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
|
|
103
|
+
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.`,
|
|
111
104
|
parameters: Type.Object({ query: searchQuery(), pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
|
|
112
105
|
async execute(_id, params, _signal, _update, ctx) {
|
|
113
106
|
const queries = searchQueries(params.query);
|
|
@@ -120,8 +113,8 @@ export function registerNotesTools(pi) {
|
|
|
120
113
|
}
|
|
121
114
|
const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
|
|
122
115
|
const result = rows.map((row) => {
|
|
123
|
-
const matches = row.matches.map((match) => ({ line: match.line, text: match.text, truncated: false,
|
|
124
|
-
return { address: row.address,
|
|
116
|
+
const matches = row.matches.map((match) => ({ line: match.line, text: match.text, truncated: false, offset_chars: match.offsetChars }));
|
|
117
|
+
return { address: row.address, updated_at: localIso(row.meta.updated_at), stale: row.meta.stale, matches_total: matches.length, matches: matches.slice(0, maxPerFile) };
|
|
125
118
|
});
|
|
126
119
|
const fitFile = (file, fits) => {
|
|
127
120
|
if (fits(file))
|
|
@@ -3,38 +3,40 @@ export function registerResetLifecycle(pi, options) {
|
|
|
3
3
|
let state = { phase: "idle" };
|
|
4
4
|
let handledEntry;
|
|
5
5
|
let active = true;
|
|
6
|
+
const release = (attempt) => {
|
|
7
|
+
if (attempt.settled)
|
|
8
|
+
return;
|
|
9
|
+
attempt.settled = true;
|
|
10
|
+
if (state.phase === "compacting" && state.attempt === attempt) {
|
|
11
|
+
state = { phase: "idle" };
|
|
12
|
+
handledEntry = undefined;
|
|
13
|
+
}
|
|
14
|
+
attempt.release();
|
|
15
|
+
};
|
|
6
16
|
const clear = () => {
|
|
17
|
+
if (state.phase === "compacting")
|
|
18
|
+
release(state.attempt);
|
|
7
19
|
state = { phase: "idle" };
|
|
8
20
|
handledEntry = undefined;
|
|
9
21
|
};
|
|
10
22
|
const valid = (request, ctx) => active && options.isEnabled() && state.phase === "compacting" && state.attempt === request && ctx.sessionManager.getSessionId() === request.sessionId;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
pi.on("agent_settled", (_event, ctx) => {
|
|
26
|
-
if (!active || !options.isEnabled() || state.phase === "compacting" || !ctx.isIdle())
|
|
27
|
-
return;
|
|
28
|
-
if (state.phase !== "requested")
|
|
29
|
-
return;
|
|
30
|
-
// One owner for requested resets. Consume the request before any external call;
|
|
31
|
-
// repeated settled events and reentrant callbacks are harmless.
|
|
32
|
-
const request = { completed: false, sessionId: ctx.sessionManager.getSessionId(), explicit: true };
|
|
23
|
+
const begin = (ctx) => {
|
|
24
|
+
let releaseWait;
|
|
25
|
+
const request = {
|
|
26
|
+
completed: false,
|
|
27
|
+
explicit: true,
|
|
28
|
+
nextRequested: false,
|
|
29
|
+
continuationStarted: false,
|
|
30
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
31
|
+
settled: false,
|
|
32
|
+
wait: new Promise((resolve) => { releaseWait = resolve; }),
|
|
33
|
+
release: () => releaseWait(),
|
|
34
|
+
};
|
|
33
35
|
state = { phase: "compacting", attempt: request };
|
|
34
36
|
const onError = (error) => {
|
|
35
37
|
if (!valid(request, ctx))
|
|
36
38
|
return;
|
|
37
|
-
|
|
39
|
+
release(request);
|
|
38
40
|
// Do not retry from settled in a tight loop. A later prompt may trigger a
|
|
39
41
|
// native reset or explicitly request one.
|
|
40
42
|
ctx.ui.notify(`pi-context: reset did not complete (${error.message}). The conversation is retained; resume with another prompt.`, "warning");
|
|
@@ -44,13 +46,24 @@ export function registerResetLifecycle(pi, options) {
|
|
|
44
46
|
onComplete: () => {
|
|
45
47
|
if (!valid(request, ctx))
|
|
46
48
|
return;
|
|
47
|
-
state = { phase: "idle" };
|
|
48
49
|
// session_compact only confirms the boundary. onComplete runs after
|
|
49
50
|
// Pi clears compaction state; sending inside the hook starts too early.
|
|
50
51
|
// A queued user prompt may already have started at compaction_end.
|
|
51
52
|
if (request.completed && ctx.isIdle() && !ctx.hasPendingMessages()) {
|
|
52
|
-
|
|
53
|
+
// The SDK detaches sendMessage, so own the next settled event before
|
|
54
|
+
// starting it. The originating agent_settled handler awaits wait.
|
|
55
|
+
if (request.continuationStarted)
|
|
56
|
+
return;
|
|
57
|
+
request.continuationStarted = true;
|
|
58
|
+
try {
|
|
59
|
+
pi.sendMessage(options.continuation, { triggerTurn: true });
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
onError(error instanceof Error ? error : new Error(String(error)));
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
53
65
|
}
|
|
66
|
+
release(request);
|
|
54
67
|
},
|
|
55
68
|
onError,
|
|
56
69
|
});
|
|
@@ -58,6 +71,42 @@ export function registerResetLifecycle(pi, options) {
|
|
|
58
71
|
catch (error) {
|
|
59
72
|
onError(error instanceof Error ? error : new Error(String(error)));
|
|
60
73
|
}
|
|
74
|
+
return request;
|
|
75
|
+
};
|
|
76
|
+
// State is intentionally not resumed from a pending request: a loaded session must
|
|
77
|
+
// not execute work from a tool that belonged to a previous runtime or tree branch.
|
|
78
|
+
pi.on("session_start", () => { clear(); active = true; });
|
|
79
|
+
pi.on("session_shutdown", () => { clear(); active = false; });
|
|
80
|
+
pi.on("session_tree", clear);
|
|
81
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
82
|
+
if (!active || !options.isEnabled())
|
|
83
|
+
return;
|
|
84
|
+
if (ctx.signal?.aborted) {
|
|
85
|
+
// Esc cancels the user's run. Do not reset or resurrect it at settled.
|
|
86
|
+
clear();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
90
|
+
if (!active || !options.isEnabled() || !ctx.isIdle())
|
|
91
|
+
return;
|
|
92
|
+
if (state.phase === "compacting" && state.attempt.continuationStarted) {
|
|
93
|
+
const preceding = state.attempt;
|
|
94
|
+
if (!preceding.nextRequested) {
|
|
95
|
+
release(preceding);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// This settled event belongs to the continuation started by preceding.
|
|
99
|
+
// If it requested another reset, retain preceding until that reset's own
|
|
100
|
+
// continuation settles. Its eventual nested handler only releases its own
|
|
101
|
+
// waiter, so it never awaits itself.
|
|
102
|
+
const next = begin(ctx);
|
|
103
|
+
return next.wait.then(() => release(preceding));
|
|
104
|
+
}
|
|
105
|
+
if (state.phase !== "requested")
|
|
106
|
+
return;
|
|
107
|
+
// One owner for requested resets. Consume the request before any external call;
|
|
108
|
+
// repeated settled events and reentrant callbacks are harmless.
|
|
109
|
+
return begin(ctx).wait;
|
|
61
110
|
});
|
|
62
111
|
pi.on("session_before_compact", (event, ctx) => {
|
|
63
112
|
if (!active || !options.isEnabled())
|
|
@@ -91,10 +140,15 @@ export function registerResetLifecycle(pi, options) {
|
|
|
91
140
|
});
|
|
92
141
|
return {
|
|
93
142
|
request() {
|
|
94
|
-
|
|
95
|
-
if (!pending)
|
|
143
|
+
if (state.phase === "idle") {
|
|
96
144
|
state = { phase: "requested" };
|
|
97
|
-
|
|
145
|
+
return "rollover_requested";
|
|
146
|
+
}
|
|
147
|
+
if (state.phase === "compacting" && state.attempt.continuationStarted && !state.attempt.nextRequested) {
|
|
148
|
+
state.attempt.nextRequested = true;
|
|
149
|
+
return "rollover_requested";
|
|
150
|
+
}
|
|
151
|
+
return "rollover_already_pending";
|
|
98
152
|
},
|
|
99
153
|
clear,
|
|
100
154
|
};
|
package/dist/src/tool-output.js
CHANGED
|
@@ -102,16 +102,14 @@ export function readCharacterWindow(text, offsetChars, limitChars, render, measu
|
|
|
102
102
|
return render(build(content));
|
|
103
103
|
}
|
|
104
104
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* metadata (notes add their timestamps) inside the same brackets.
|
|
105
|
+
* Fixed metadata block preceding any raw character-window payload. Callers supply their
|
|
106
|
+
* source identity fields in wire order; range and continuation semantics are shared.
|
|
108
107
|
*/
|
|
109
|
-
export function
|
|
110
|
-
// The range end is offset + delivered count, never `total_chars`: a read resolved past the
|
|
111
|
-
// end delivers zero characters there, and the header must not render an inverted range.
|
|
108
|
+
export function readWindowBlock(identity, window) {
|
|
112
109
|
const end = window.offset_chars + Array.from(window.content).length;
|
|
113
|
-
const
|
|
114
|
-
|
|
110
|
+
const next = window.next_offset_chars === null ? "null" : String(window.next_offset_chars);
|
|
111
|
+
const fields = identity.map(([name, value]) => `${name}: ${value}`).join("\n");
|
|
112
|
+
return `--- READ WINDOW ---\n${fields}\nchars: [${window.offset_chars},${end}) of ${window.total_chars}\nnext_offset_chars: ${next}\n`;
|
|
115
113
|
}
|
|
116
114
|
/**
|
|
117
115
|
* Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
|
|
@@ -166,9 +164,8 @@ export function output(value, details, terminate = false) {
|
|
|
166
164
|
return { content: [{ type: "text", text: json(value) }], details, terminate };
|
|
167
165
|
}
|
|
168
166
|
/**
|
|
169
|
-
* Encode a prose payload as raw text:
|
|
170
|
-
* verbatim.
|
|
171
|
-
* `details` carries the slim metadata object and never duplicates the payload.
|
|
167
|
+
* Encode a prose payload as raw text: metadata prefix, a blank line, then the payload
|
|
168
|
+
* verbatim. `details` carries the slim metadata object and never duplicates the payload.
|
|
172
169
|
*/
|
|
173
170
|
export function outputRaw(header, content, details, terminate = false) {
|
|
174
171
|
return { content: [{ type: "text", text: `${header}\n${content}` }], details, terminate };
|