@astrosheep/pi-context 0.19.0 → 0.21.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/dist/src/budget.js +65 -0
- package/dist/src/dream/cli.js +83 -0
- package/dist/src/dream/gates.js +22 -0
- package/dist/src/dream/git.js +28 -0
- package/dist/src/dream/lock.js +58 -0
- package/dist/src/dream/runner.js +115 -0
- package/dist/src/history-tools.js +105 -0
- package/dist/src/history.js +215 -0
- package/dist/src/index.js +98 -0
- package/dist/src/notes/address.js +31 -0
- package/dist/src/notes/frontmatter.js +136 -0
- package/dist/src/notes/model.js +101 -0
- package/dist/src/notes/paths.js +58 -0
- package/dist/src/notes/store.js +270 -0
- package/dist/src/notes/tools.js +153 -0
- package/dist/src/prompts.js +81 -0
- package/dist/src/protocol.js +56 -0
- package/dist/src/reset-lifecycle.js +101 -0
- package/dist/src/session-reader.js +1 -0
- package/dist/src/thresholds.js +75 -0
- package/dist/src/tool-output.js +175 -0
- package/dist/src/tool-schema.js +26 -0
- package/dist/src/warning.js +44 -0
- package/dist/test/agent-loop.test.js +214 -0
- package/dist/test/coherence.test.js +375 -0
- package/dist/test/dream.test.js +142 -0
- package/dist/test/history.test.js +26 -0
- package/dist/test/integration.test.js +1766 -0
- package/dist/test/notes.test.js +474 -0
- package/dist/test/pagination.property.test.js +476 -0
- package/dist/test/reset-lifecycle.test.js +199 -0
- package/package.json +13 -7
- package/playbook.md +32 -0
- package/src/budget.ts +11 -9
- package/src/dream/cli.ts +33 -0
- package/src/dream/gates.ts +20 -0
- package/src/dream/git.ts +27 -0
- package/src/dream/lock.ts +39 -0
- package/src/dream/runner.ts +111 -0
- package/src/history-tools.ts +5 -5
- package/src/history.ts +12 -7
- package/src/index.ts +13 -14
- package/src/notes/address.ts +33 -0
- package/src/{memory → notes}/frontmatter.ts +5 -3
- package/src/{notes.ts → notes/model.ts} +2 -2
- package/src/{memory → notes}/paths.ts +6 -1
- package/src/{memory → notes}/store.ts +62 -77
- package/src/notes/tools.ts +132 -0
- package/src/prompts.ts +31 -29
- package/src/protocol.ts +9 -5
- package/src/thresholds.ts +4 -1
- package/src/tool-output.ts +4 -1
- package/src/warning.ts +3 -3
- package/src/memory/tools.ts +0 -166
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrosheep/pi-context",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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",
|
|
@@ -20,15 +20,21 @@
|
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
22
22
|
"src",
|
|
23
|
+
"dist",
|
|
23
24
|
"docs",
|
|
24
25
|
"LICENSE",
|
|
25
|
-
"README.md"
|
|
26
|
+
"README.md",
|
|
27
|
+
"playbook.md"
|
|
26
28
|
],
|
|
29
|
+
"bin": {
|
|
30
|
+
"dream": "dist/src/dream/cli.js"
|
|
31
|
+
},
|
|
27
32
|
"scripts": {
|
|
28
|
-
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
33
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node -e \"require('node:fs').chmodSync('dist/src/dream/cli.js', 0o755)\"",
|
|
29
34
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
30
35
|
"test": "npm run build && node --test dist/test/*.test.js",
|
|
31
|
-
"prepublishOnly": "npm run typecheck"
|
|
36
|
+
"prepublishOnly": "npm run typecheck",
|
|
37
|
+
"prepack": "npm run build"
|
|
32
38
|
},
|
|
33
39
|
"peerDependencies": {
|
|
34
40
|
"@earendil-works/pi-agent-core": "*",
|
|
@@ -36,9 +42,9 @@
|
|
|
36
42
|
"@earendil-works/pi-coding-agent": "*"
|
|
37
43
|
},
|
|
38
44
|
"devDependencies": {
|
|
39
|
-
"@earendil-works/pi-agent-core": "^0.
|
|
40
|
-
"@earendil-works/pi-ai": "^0.
|
|
41
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
45
|
+
"@earendil-works/pi-agent-core": "^0.86.0",
|
|
46
|
+
"@earendil-works/pi-ai": "^0.86.0",
|
|
47
|
+
"@earendil-works/pi-coding-agent": "^0.86.0",
|
|
42
48
|
"@types/node": "^22.19.19",
|
|
43
49
|
"typescript": "^5.9.3"
|
|
44
50
|
},
|
package/playbook.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
I am dreaming over my notes. They are plain markdown files in three homes, addressed as:
|
|
2
|
+
|
|
3
|
+
- bare `<vpath>` for this session
|
|
4
|
+
- `@project/<vpath>` for this project
|
|
5
|
+
- `@global/<vpath>` for global notes
|
|
6
|
+
|
|
7
|
+
Every note has this frontmatter block:
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
---
|
|
11
|
+
origin: user | self | external
|
|
12
|
+
status: active
|
|
13
|
+
stale: false
|
|
14
|
+
created_at: <timestamp>
|
|
15
|
+
updated_at: <timestamp>
|
|
16
|
+
last_accessed: <timestamp>
|
|
17
|
+
access_count: 0
|
|
18
|
+
---
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Dream rules
|
|
22
|
+
|
|
23
|
+
1. **Probe before you trust.** Before keeping or promoting a note, verify its world referents with read-only file tools: paths in the body — do they still exist? branches — still present? Dead referents are why a note gets merged away or marked stale, never promoted.
|
|
24
|
+
2. **Merge threshold.** Supersede another note only when all three hold: same topic (name it in the survivor's body), same kind of note (checkpoint/design/log…), and the survivor is strictly newer or strictly more specific. Otherwise keep both and record the open conflict in the survivor.
|
|
25
|
+
3. **Size budget.** Keep every note under ~200 lines / ~8KB. Oversized notes get split by topic with a one-line cross-link in each (`see also: @home/<vpath>`). Checkpoints may exceed the budget — trim prose, never facts.
|
|
26
|
+
4. **Keep the maps.** Each home's `MAP.md` maps that home's durable notes: one line per entry — its address and a short gist in your own words, never a mechanical body slice. Project notes go on `@project/MAP.md`, cross-project knowledge on `@global/MAP.md`; session notes are never mapped — the pocket covers them. When a note is promoted across homes, move its line to the destination map; when a note goes stale, drop its line. Maps obey the same size budget as any note.
|
|
27
|
+
5. **Jurisdiction.** Your mandate is the whole store — every session home, every project home, global. Nothing is skipped: notes are never physically deleted and every run is bracketed by git commits, so the human gate can audit and revert whatever you touch. Group your report by home so the gate can see what moved. In every home: map entry lines are yours to maintain, but prose that carries rules or guidance is not — flag it in your report instead of rewriting it.
|
|
28
|
+
6. **Leave stable notes alone.** Change notes to incorporate new evidence, resolve verified errors, merge genuine duplicates, or split oversized files—not merely to shorten or rephrase them. Preserve facts, conditions, exceptions, and uncertainty. No change is a valid outcome.
|
|
29
|
+
|
|
30
|
+
Read the files and merge genuinely duplicate notes by editing the survivor, then set `stale: true` in the absorbed note's frontmatter. Nothing is physically deleted; stale notes remain readable. Promote durable cross-project knowledge by writing or editing at `@global/<vpath>`. Keep notes compact and preserve useful provenance in the body.
|
|
31
|
+
|
|
32
|
+
Do not write skill ideas as files. Put skill ideas and unresolved questions in your final assistant message as proposals for the human. Your final message should be a concise report of what you inspected, changed, and left unresolved. If you made no file writes, say so.
|
package/src/budget.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { Type } from "@earendil-works/pi-ai";
|
|
2
2
|
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { GUIDANCE_TYPE, WARNING_TYPE } from "./protocol.js";
|
|
4
|
-
import { thresholdsFor, resetThresholds
|
|
4
|
+
import { thresholdsFor, resetThresholds } from "./thresholds.js";
|
|
5
5
|
import { currentWindowId, hasWindowMessage } from "./history.js";
|
|
6
6
|
import { tokenBudgetGuidance } from "./prompts.js";
|
|
7
7
|
import { output } from "./tool-output.js";
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/** Remaining tokens in the current context window, or null when Pi has no usage estimate. */
|
|
10
|
+
export function remainingTokens(ctx: Pick<ExtensionContext, "getContextUsage">): number | null {
|
|
11
|
+
const usage = ctx.getContextUsage();
|
|
12
|
+
return !usage || usage.tokens === null ? null : Math.max(0, usage.contextWindow - usage.tokens);
|
|
13
|
+
}
|
|
10
14
|
|
|
11
15
|
export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean) {
|
|
12
16
|
let guidancePersistedInWindow: string | undefined;
|
|
@@ -17,16 +21,15 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean) {
|
|
|
17
21
|
if (!isEnabled() || hasWindowMessage(ctx, GUIDANCE_TYPE)) return undefined;
|
|
18
22
|
// The early reminder persists once per window the first time remaining crosses
|
|
19
23
|
// reserve+margin. It never edits the outgoing request.
|
|
20
|
-
const
|
|
21
|
-
if (
|
|
22
|
-
const remaining = Math.max(0, usage.contextWindow - usage.tokens);
|
|
24
|
+
const remaining = remainingTokens(ctx);
|
|
25
|
+
if (remaining === null) return undefined;
|
|
23
26
|
const windowId = currentWindowId(ctx);
|
|
24
27
|
const { reminder, reserve, warning } = thresholdsFor(ctx);
|
|
25
28
|
// The final warning owns the deep band: when it has fired (or is due now),
|
|
26
29
|
// the shallow reminder would only repeat the same instruction closer to
|
|
27
30
|
// the wipe, at a worse position. See warning.ts.
|
|
28
31
|
if (remaining <= warning || hasWindowMessage(ctx, WARNING_TYPE)) return undefined;
|
|
29
|
-
if (remaining <= reminder && guidancePersistedInWindow !== windowId
|
|
32
|
+
if (remaining <= reminder && guidancePersistedInWindow !== windowId) {
|
|
30
33
|
guidancePersistedInWindow = windowId;
|
|
31
34
|
// Persist once per window — no transient copy. A transient bridge would
|
|
32
35
|
// cover the crossing request, but history would record the reminder after
|
|
@@ -54,11 +57,10 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean) {
|
|
|
54
57
|
description: "Return estimated context tokens left before your memory is wiped; null when Pi cannot estimate usage.",
|
|
55
58
|
parameters: Type.Object({}, { additionalProperties: false }),
|
|
56
59
|
async execute(_id, _params, _signal, _update, ctx) {
|
|
57
|
-
const usage = ctx.getContextUsage();
|
|
58
60
|
// The countdown the model sees ends at the warning line (reserve + runway);
|
|
59
61
|
// the runway below it is overdraft the model never sees. See protocol.ts.
|
|
60
|
-
const remaining =
|
|
61
|
-
return output({ remaining_tokens: remaining });
|
|
62
|
+
const remaining = remainingTokens(ctx);
|
|
63
|
+
return output({ remaining_tokens: remaining === null ? null : Math.max(0, remaining - thresholdsFor(ctx as ExtensionContext).warning) });
|
|
62
64
|
},
|
|
63
65
|
}));
|
|
64
66
|
|
package/src/dream/cli.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { acquireLock, failLock, releaseLock } from "./lock.js";
|
|
5
|
+
import { materialGate, timeGate } from "./gates.js";
|
|
6
|
+
import { loadPlaybook, runDreamer } from "./runner.js";
|
|
7
|
+
import { gitCommit } from "./git.js";
|
|
8
|
+
import { notesRoot } from "../notes/paths.js";
|
|
9
|
+
|
|
10
|
+
function args(argv: string[]) { const out: Record<string, string | boolean> = {}; for (let i=0;i<argv.length;i++) { const a=argv[i]!; if (a === "--force" || a === "--help") out[a.slice(2)] = true; else if (a.startsWith("--")) out[a.slice(2)] = argv[++i] ?? ""; } return out; }
|
|
11
|
+
function packageRoot(): string {
|
|
12
|
+
let dir = dirname(new URL(import.meta.url).pathname);
|
|
13
|
+
while (true) { if (existsSync(join(dir, "package.json"))) return dir; const parent = dirname(dir); if (parent === dir) throw new Error("could not locate installed package root"); dir = parent; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function main(argv = process.argv.slice(2)): Promise<number> {
|
|
17
|
+
const a = args(argv); if (a.help) { console.log("dream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <model pattern>] [--playbook <path>]\nDefault dreamer: in-process pi SDK session with jailed file tools. Default playbook: <installed package root>/playbook.md; --playbook overrides it."); return 0; }
|
|
18
|
+
const home = resolve(String(a["notes-home"] ?? notesRoot())); process.env.PI_NOTES_HOME = home; mkdirSync(home, { recursive: true });
|
|
19
|
+
const lockPath = join(home, ".dream.lock"); const minHours = Number(a["min-hours"] ?? 24); const minSessions = Number(a["min-sessions"] ?? 3);
|
|
20
|
+
const time = timeGate(lockPath, minHours); console.log(time.reason); if (!a.force && !time.ok) return 0;
|
|
21
|
+
const material = materialGate(home, existsSync(lockPath) ? statSync(lockPath).mtimeMs : 0, minSessions); console.log(material.reason); if (!a.force && !material.ok) return 0;
|
|
22
|
+
let lock; try { lock = acquireLock(lockPath); } catch (e) { console.log(`lock gate: ${e instanceof Error ? e.message : e}`); return 0; } if (!lock.held) { console.log(lock.reason); return 0; }
|
|
23
|
+
const stamp = new Date(lock.startedAt).toISOString().replace(/[:.]/g, "-"); const reportPath = join(home, "dreams", `${stamp}.md`);
|
|
24
|
+
gitCommit(home, `baseline ${stamp}`);
|
|
25
|
+
try {
|
|
26
|
+
const defaultBook = join(packageRoot(), "playbook.md");
|
|
27
|
+
const playbookPath = String(a.playbook ?? defaultBook);
|
|
28
|
+
const playbook = loadPlaybook(playbookPath); const result = await runDreamer(playbook, home, { modelPattern: a.dreamer ? String(a.dreamer) : undefined });
|
|
29
|
+
const writes = result.writes.length ? result.writes.map((w) => `- ${w.tool}: ${w.path}`).join("\n") : "- no changes";
|
|
30
|
+
mkdirSync(join(home, "dreams"), { recursive: true }); writeFileSync(reportPath, `# Dream ${stamp}\n\n${result.report}\n\n${writes}\n`); gitCommit(home, `dream ${stamp}`); console.log(reportPath); return 0;
|
|
31
|
+
} catch (e) { failLock(lock); console.error(e instanceof Error ? e.message : e); return 1; } finally { releaseLock(lock); }
|
|
32
|
+
}
|
|
33
|
+
main().then((code) => { process.exitCode = code; });
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { sessionHomesRoot } from "../notes/paths.js";
|
|
4
|
+
|
|
5
|
+
export type GateResult = { ok: boolean; reason: string };
|
|
6
|
+
export function timeGate(lockPath: string, minHours: number, now = Date.now()): GateResult {
|
|
7
|
+
if (!existsSync(lockPath)) return { ok: true, reason: "time gate: no prior lock" };
|
|
8
|
+
const age = now - statSync(lockPath).mtimeMs;
|
|
9
|
+
return age >= minHours * 3600000 ? { ok: true, reason: "time gate: stale" } : { ok: false, reason: "time gate: lock is too fresh" };
|
|
10
|
+
}
|
|
11
|
+
export function materialGate(home: string, lockMtime: number, minSessions: number): GateResult {
|
|
12
|
+
const root = sessionHomesRoot(home);
|
|
13
|
+
let changed = 0;
|
|
14
|
+
if (existsSync(root)) for (const dir of readdirSync(root, { withFileTypes: true })) {
|
|
15
|
+
if (!dir.isDirectory()) continue;
|
|
16
|
+
const files = readdirSync(join(root, dir.name), { withFileTypes: true });
|
|
17
|
+
if (files.some((f) => f.isFile() && statSync(join(root, dir.name, f.name)).mtimeMs > lockMtime)) changed++;
|
|
18
|
+
}
|
|
19
|
+
return changed >= minSessions ? { ok: true, reason: `material gate: ${changed} changed sessions` } : { ok: false, reason: `material gate: only ${changed} changed sessions` };
|
|
20
|
+
}
|
package/src/dream/git.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Git audit layer for a dream run: one commit before (baseline) and one after
|
|
7
|
+
* (dream), so the human gate reviews `git show` instead of trusting a report,
|
|
8
|
+
* and rollback is `git revert`. This layer is a garnish, never load-bearing:
|
|
9
|
+
* every failure is logged and swallowed — a notes home without git, or a
|
|
10
|
+
* broken repo, still dreams. Nothing is committed when the tree is clean.
|
|
11
|
+
*/
|
|
12
|
+
export function gitCommit(home: string, message: string): void {
|
|
13
|
+
try {
|
|
14
|
+
if (!existsSync(join(home, ".git"))) {
|
|
15
|
+
execFileSync("git", ["init", "-q"], { cwd: home, stdio: "ignore" });
|
|
16
|
+
}
|
|
17
|
+
execFileSync("git", ["add", "-A"], { cwd: home, stdio: "ignore" });
|
|
18
|
+
try {
|
|
19
|
+
execFileSync("git", ["diff", "--cached", "--quiet"], { cwd: home, stdio: "ignore" });
|
|
20
|
+
return; // clean tree — no empty commit
|
|
21
|
+
} catch { /* staged changes exist — fall through to commit */ }
|
|
22
|
+
execFileSync("git", ["commit", "-q", "-m", message], { cwd: home, stdio: "ignore" });
|
|
23
|
+
console.log(`git: committed "${message}"`);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.log(`git audit layer skipped: ${error instanceof Error ? error.message : error}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export type LockState = { path: string; held: boolean; reason?: string; startedAt: number; priorMtime?: number };
|
|
4
|
+
const HOUR = 60 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
function live(pid: number): boolean {
|
|
7
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
8
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function acquireLock(path: string): LockState {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
let priorMtime: number | undefined;
|
|
14
|
+
if (existsSync(path)) {
|
|
15
|
+
const stat = statSync(path);
|
|
16
|
+
priorMtime = stat.mtimeMs;
|
|
17
|
+
let pid = 0;
|
|
18
|
+
try { pid = Number.parseInt(readFileSync(path, "utf8").trim(), 10); } catch { /* reclaim */ }
|
|
19
|
+
if (now - stat.mtimeMs <= HOUR && live(pid)) return { path, held: false, reason: "lock gate: live process holds the lock", startedAt: now };
|
|
20
|
+
try { unlinkSync(path); } catch { return { path, held: false, reason: "lock gate: lock could not be reclaimed", startedAt: now }; }
|
|
21
|
+
}
|
|
22
|
+
writeFileSync(path, String(process.pid), { flag: "wx" });
|
|
23
|
+
return { path, held: true, startedAt: now, priorMtime };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function releaseLock(lock: LockState): void {
|
|
27
|
+
// The lock is also the durable last-dream timestamp. Leave the PID marker in place;
|
|
28
|
+
// the next acquisition reclaims it once the PID is dead or it is older than an hour.
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function restoreMtime(path: string, mtimeMs: number): void {
|
|
32
|
+
try { utimesSync(path, new Date(), new Date(mtimeMs)); } catch { /* advisory */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function failLock(lock: LockState): void {
|
|
36
|
+
if (!lock.held) return;
|
|
37
|
+
if (lock.priorMtime === undefined) { try { unlinkSync(lock.path); } catch { /* best effort */ } }
|
|
38
|
+
else restoreMtime(lock.path, lock.priorMtime);
|
|
39
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, realpath } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import { createAgentSession, createEditToolDefinition, createWriteToolDefinition, ModelRuntime, resolveModelScopeWithDiagnostics, SessionManager, type AgentSession, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
6
|
+
import { contentText } from "../history.js";
|
|
7
|
+
|
|
8
|
+
export type DreamWrite = { tool: "write" | "edit"; path: string };
|
|
9
|
+
export type DreamResult = { report: string; writes: DreamWrite[] };
|
|
10
|
+
export type DreamerSession = Pick<AgentSession, "prompt" | "subscribe" | "dispose">;
|
|
11
|
+
export type DreamerSessionFactory = (options: { cwd: string; modelPattern?: string; tools: string[] }) => Promise<DreamerSession>;
|
|
12
|
+
export const DREAMER_TOOLS = ["read", "grep", "find", "ls", "write", "edit"];
|
|
13
|
+
|
|
14
|
+
function isOutside(notesHome: string, target: string): boolean {
|
|
15
|
+
const fromHome = relative(notesHome, target);
|
|
16
|
+
return fromHome === ".." || fromHome.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(fromHome);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function jailWritePath(notesHome: string, path: string): Promise<void> {
|
|
20
|
+
let realNotesHome: string;
|
|
21
|
+
try {
|
|
22
|
+
realNotesHome = await realpath(notesHome);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(`write jail: cannot resolve notes home ${notesHome}`);
|
|
25
|
+
}
|
|
26
|
+
const target = resolve(realNotesHome, path);
|
|
27
|
+
const targetParent = dirname(target);
|
|
28
|
+
if (isOutside(realNotesHome, targetParent)) throw new Error(`write jail: ${path} is outside notes home ${notesHome}`);
|
|
29
|
+
// write creates parent directories itself. Create only after the lexical check, then
|
|
30
|
+
// canonicalize the parent so a symlink cannot lead the underlying tool out of home.
|
|
31
|
+
await mkdir(targetParent, { recursive: true });
|
|
32
|
+
let realTargetParent: string;
|
|
33
|
+
try {
|
|
34
|
+
realTargetParent = await realpath(targetParent);
|
|
35
|
+
} catch {
|
|
36
|
+
throw new Error(`write jail: cannot resolve target parent in notes home ${notesHome}`);
|
|
37
|
+
}
|
|
38
|
+
if (isOutside(realNotesHome, realTargetParent)) throw new Error(`write jail: ${path} is outside notes home ${notesHome}`);
|
|
39
|
+
let targetStats;
|
|
40
|
+
try {
|
|
41
|
+
targetStats = await lstat(target);
|
|
42
|
+
} catch (error: any) {
|
|
43
|
+
if (error.code !== "ENOENT") throw new Error(`write jail: cannot inspect target in notes home ${notesHome}`);
|
|
44
|
+
}
|
|
45
|
+
if (targetStats?.isSymbolicLink()) {
|
|
46
|
+
let realTarget: string;
|
|
47
|
+
try {
|
|
48
|
+
realTarget = await realpath(target);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(`write jail: cannot resolve target in notes home ${notesHome}`);
|
|
51
|
+
}
|
|
52
|
+
if (isOutside(realNotesHome, realTarget)) throw new Error(`write jail: ${path} is outside notes home ${notesHome}`);
|
|
53
|
+
}
|
|
54
|
+
if (targetStats && targetStats.nlink > 1) throw new Error(`write jail: ${path} has hard links and is not allowed in notes home ${notesHome}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function jailToolDefinition<T extends ToolDefinition<any, any, any>>(definition: T, notesHome: string): T {
|
|
58
|
+
const execute = definition.execute;
|
|
59
|
+
return {
|
|
60
|
+
...definition,
|
|
61
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
62
|
+
await jailWritePath(notesHome, (params as { path: string }).path);
|
|
63
|
+
return execute(toolCallId, params, signal, onUpdate, ctx);
|
|
64
|
+
},
|
|
65
|
+
} as T;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The only custom definitions in the dream session replace the two built-ins with jailed versions. */
|
|
69
|
+
export function dreamerWriteToolDefinitions(notesHome: string): ToolDefinition<any, any, any>[] {
|
|
70
|
+
return [
|
|
71
|
+
jailToolDefinition(createWriteToolDefinition(notesHome), notesHome),
|
|
72
|
+
jailToolDefinition(createEditToolDefinition(notesHome), notesHome),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const defaultDreamerSessionFactory: DreamerSessionFactory = async ({ cwd, modelPattern, tools }) => {
|
|
77
|
+
let model: Model<Api> | undefined;
|
|
78
|
+
if (modelPattern) {
|
|
79
|
+
const runtime = await ModelRuntime.create({ allowModelNetwork: false, refreshOnCreate: false });
|
|
80
|
+
const result = await resolveModelScopeWithDiagnostics([modelPattern], runtime);
|
|
81
|
+
model = result.scopedModels[0]?.model;
|
|
82
|
+
if (!model) throw new Error(`dreamer model pattern "${modelPattern}" did not resolve to an available model`);
|
|
83
|
+
}
|
|
84
|
+
const { session } = await createAgentSession({ cwd, sessionManager: SessionManager.inMemory(cwd), tools, customTools: dreamerWriteToolDefinitions(cwd), noTools: "all", model, thinkingLevel: "off" });
|
|
85
|
+
return session;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export async function runDreamer(playbook: string, cwd: string, options: { modelPattern?: string; sessionFactory?: DreamerSessionFactory } = {}): Promise<DreamResult> {
|
|
89
|
+
const session = await (options.sessionFactory ?? defaultDreamerSessionFactory)({ cwd, modelPattern: options.modelPattern, tools: DREAMER_TOOLS });
|
|
90
|
+
let answer = "";
|
|
91
|
+
let providerError: string | undefined;
|
|
92
|
+
const writes: DreamWrite[] = [];
|
|
93
|
+
const unsubscribe = session.subscribe((event: any) => {
|
|
94
|
+
const tool = event.toolName ?? event.tool?.name;
|
|
95
|
+
const args = event.args ?? event.arguments ?? event.tool?.arguments;
|
|
96
|
+
if ((tool === "write" || tool === "edit") && args && typeof args === "object" && typeof args.path === "string") writes.push({ tool, path: args.path });
|
|
97
|
+
if (event.type !== "message_end" || event.message?.role !== "assistant") return;
|
|
98
|
+
if (event.message.stopReason === "error") { providerError = event.message.errorMessage ?? "unknown provider error"; return; }
|
|
99
|
+
answer = contentText(event.message.content);
|
|
100
|
+
});
|
|
101
|
+
try {
|
|
102
|
+
await session.prompt(playbook);
|
|
103
|
+
if (providerError) throw new Error(`dreamer failed: ${providerError}`);
|
|
104
|
+
return { report: answer, writes };
|
|
105
|
+
} finally {
|
|
106
|
+
unsubscribe?.();
|
|
107
|
+
session.dispose();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function loadPlaybook(path: string): string { return readFileSync(path, "utf8"); }
|
package/src/history-tools.ts
CHANGED
|
@@ -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 { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, characterWindowHeader, withinTextBudget } from "./tool-output.js";
|
|
3
|
+
import { output, outputRaw, page, middleTruncate, prefixFit, earliestMatchOffsetChars, readCharacterWindow, characterWindowHeader, 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
|
|
|
@@ -53,7 +53,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
53
53
|
if (invalid) return output({ error: invalid, role: params.role, tool_name: params.tool_name });
|
|
54
54
|
const badWindow = unknownWindowId(ctx, params);
|
|
55
55
|
if (badWindow) return output({ error: badWindow.message, window_id: params.window_id, known_windows: badWindow.known });
|
|
56
|
-
const items = filteredItems(ctx, params).map((item) => visibleItem(item, params.max_chars_per_item ??
|
|
56
|
+
const items = filteredItems(ctx, params).map((item) => visibleItem(item, params.max_chars_per_item ?? HISTORY_PREVIEW_CHARS));
|
|
57
57
|
return output(page(items, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
|
|
58
58
|
},
|
|
59
59
|
}));
|
|
@@ -62,7 +62,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
62
62
|
name: "history_read",
|
|
63
63
|
label: "History read item",
|
|
64
64
|
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 is the raw item text behind a one-line [bracketed] header naming the item, the resolved offset, the delivered char range, and the resume cursor (continue at offset_chars=N, or end).",
|
|
65
|
-
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:
|
|
65
|
+
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 }),
|
|
66
66
|
async execute(_id, params, _signal, _update, ctx) {
|
|
67
67
|
const item = allItems(ctx).find((candidate) => candidate.windowId === params.window_id && candidate.itemId === params.item_id);
|
|
68
68
|
if (!item) return output({ error: "unknown item_id or window_id" });
|
|
@@ -72,7 +72,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
72
72
|
if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
|
|
73
73
|
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 });
|
|
74
74
|
}
|
|
75
|
-
const limit_chars = Math.min(params.limit_chars ??
|
|
75
|
+
const limit_chars = Math.min(params.limit_chars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS);
|
|
76
76
|
return readCharacterWindow(item.content, params.offset_chars, params.limit_chars, (window) => {
|
|
77
77
|
const { content, ...cursor } = window;
|
|
78
78
|
return outputRaw(characterWindowHeader(`${item.windowId} · item ${item.itemId}`, window), content, { window_id: item.windowId, item_id: item.itemId, ...cursor, limit_chars });
|
|
@@ -93,7 +93,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
93
93
|
const queries = searchQueries(params.query);
|
|
94
94
|
const matching = filteredItems(ctx, params)
|
|
95
95
|
.filter((item) => queries.some((query) => item.content.includes(query)))
|
|
96
|
-
.map((item) => ({ ...visibleItem(item, params.max_chars_per_item ??
|
|
96
|
+
.map((item) => ({ ...visibleItem(item, params.max_chars_per_item ?? HISTORY_PREVIEW_CHARS), match_offset_chars: earliestMatchOffsetChars(item.content, queries) }));
|
|
97
97
|
return output(page(matching, params.cursor ?? 0, "items", params.limit, truncateHistoryItem));
|
|
98
98
|
},
|
|
99
99
|
}));
|
package/src/history.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { TextContent, ToolCall } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
3
|
import type { SessionReader } from "./session-reader.js";
|
|
4
4
|
import { RESET_V2 } from "./protocol.js";
|
|
5
|
+
import { HISTORY_PREVIEW_CHARS } from "./tool-output.js";
|
|
5
6
|
|
|
6
7
|
type HistoryItem = {
|
|
7
8
|
windowId: string;
|
|
@@ -29,9 +30,9 @@ function isTextContent(part: unknown): part is TextContent {
|
|
|
29
30
|
return typeof part === "object" && part !== null && (part as TextContent).type === "text" && typeof (part as TextContent).text === "string";
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
function contentText(content:
|
|
33
|
+
export function contentText(content: unknown): string {
|
|
33
34
|
if (typeof content === "string") return content;
|
|
34
|
-
return content.filter(isTextContent).map((part) => part.text).join("\n");
|
|
35
|
+
return Array.isArray(content) ? content.filter(isTextContent).map((part) => part.text).join("\n") : "";
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
function mapRole(role: AgentMessage["role"]): HistoryItem["role"] | undefined {
|
|
@@ -102,14 +103,19 @@ export function resetV2WindowId(details: unknown): string | undefined {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
/** A compaction entry's window id: the extension-minted id for reset-v2, else Pi's entry id. */
|
|
105
|
-
function windowIdOf(sessionId: string, entry: { id: string; details?: unknown }): string {
|
|
106
|
+
export function windowIdOf(sessionId: string, entry: { id: string; details?: unknown }): string {
|
|
106
107
|
return resetV2WindowId(entry.details) ?? `pcw:${sessionId.slice(0, 8)}:${entry.id}`;
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
/** Mint the durable identity of a session's root history window. */
|
|
111
|
+
export function rootWindowId(sessionId: string): string {
|
|
112
|
+
return `pcw:${sessionId.slice(0, 8)}:root`;
|
|
113
|
+
}
|
|
114
|
+
|
|
109
115
|
/** Build durable, on-demand history directly from every entry on the current session branch. */
|
|
110
116
|
export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
|
|
111
117
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
112
|
-
let window: HistoryWindow = { windowId:
|
|
118
|
+
let window: HistoryWindow = { windowId: rootWindowId(sessionId), items: [] };
|
|
113
119
|
const windows = [window];
|
|
114
120
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
115
121
|
if (entry.type === "compaction") {
|
|
@@ -153,7 +159,7 @@ export function historyFromSession(ctx: SessionReader): HistoryWindow[] {
|
|
|
153
159
|
return windows;
|
|
154
160
|
}
|
|
155
161
|
|
|
156
|
-
export function visibleItem(item: HistoryItem, maxChars =
|
|
162
|
+
export function visibleItem(item: HistoryItem, maxChars = HISTORY_PREVIEW_CHARS) {
|
|
157
163
|
const characters = Array.from(item.content);
|
|
158
164
|
const truncated = characters.length > maxChars;
|
|
159
165
|
return {
|
|
@@ -230,6 +236,5 @@ export function currentWindowId(ctx: SessionReader): string {
|
|
|
230
236
|
const entry = branch[i];
|
|
231
237
|
if (entry?.type === "compaction") return windowIdOf(sessionId, entry);
|
|
232
238
|
}
|
|
233
|
-
return
|
|
239
|
+
return rootWindowId(sessionId);
|
|
234
240
|
}
|
|
235
|
-
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { registerHistoryTools } from "./history-tools.js";
|
|
2
|
-
import {
|
|
3
|
-
import { registerBudget
|
|
2
|
+
import { registerNotesTools } from "./notes/tools.js";
|
|
3
|
+
import { registerBudget } from "./budget.js";
|
|
4
4
|
import { output } from "./tool-output.js";
|
|
5
|
-
|
|
5
|
+
import { deriveThresholds, mergePiContextSettings } from "./thresholds.js";
|
|
6
6
|
import { STATE_TYPE, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, RESET_V2, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS, RESET_SUMMARY, CONTINUATION, WARNING_PROMPT } from "./protocol.js";
|
|
7
|
-
import { historyFromSession, hasWindowMessage, currentWindowId, resetV2WindowId } from "./history.js";
|
|
8
|
-
import { assertVirtualPath } from "./notes.js";
|
|
7
|
+
import { historyFromSession, hasWindowMessage, currentWindowId, resetV2WindowId, rootWindowId, windowIdOf } from "./history.js";
|
|
8
|
+
import { assertVirtualPath } from "./notes/model.js";
|
|
9
9
|
import { bootBlock } from "./prompts.js";
|
|
10
10
|
export { historyFromSession } from "./history.js";
|
|
11
|
-
export { notesFromSession } from "./notes.js";
|
|
11
|
+
export { notesFromSession } from "./notes/model.js";
|
|
12
12
|
import { registerResetLifecycle } from "./reset-lifecycle.js";
|
|
13
13
|
import { registerWarning } from "./warning.js";
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
@@ -25,8 +25,7 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
25
25
|
// The root window has no compaction entry to carry the boot block, so persist
|
|
26
26
|
// it once as a hidden custom message. Reset windows already carry theirs at
|
|
27
27
|
// position 0 in the compaction summary, so a resumed session adds nothing.
|
|
28
|
-
const
|
|
29
|
-
const rootId = `pcw:${sessionId.slice(0, 8)}:root`;
|
|
28
|
+
const rootId = rootWindowId(ctx.sessionManager.getSessionId());
|
|
30
29
|
if (currentWindowId(ctx) !== rootId || hasWindowMessage(ctx, BOOT_TYPE)) return;
|
|
31
30
|
pi.sendMessage({ customType: BOOT_TYPE, content: bootBlock(ctx, rootId, undefined, false), display: false }, { triggerTurn: false });
|
|
32
31
|
});
|
|
@@ -48,7 +47,7 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
48
47
|
});
|
|
49
48
|
|
|
50
49
|
registerHistoryTools(pi);
|
|
51
|
-
|
|
50
|
+
registerNotesTools(pi);
|
|
52
51
|
|
|
53
52
|
pi.registerTool(defineTool({
|
|
54
53
|
name: "new_context",
|
|
@@ -70,15 +69,15 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
70
69
|
},
|
|
71
70
|
onReset: (entryId) => pi.appendEntry(STATE_TYPE, { version: 1, lastResetEntryId: entryId }),
|
|
72
71
|
buildReset: (event, ctx, explicit) => {
|
|
73
|
-
const
|
|
72
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
74
73
|
// Window IDs are independent of Pi entry IDs. Avoid reusing a window
|
|
75
74
|
// identity already present on this branch.
|
|
76
75
|
const windows = historyFromSession(ctx);
|
|
77
76
|
const usedIds = new Set(windows.map((window) => window.windowId));
|
|
78
|
-
let minted = randomUUID().slice(0, 8);
|
|
79
|
-
while (usedIds.has(
|
|
80
|
-
const windowId =
|
|
81
|
-
const previousId = windows[windows.length - 1]?.windowId ??
|
|
77
|
+
let minted = { id: randomUUID().slice(0, 8) };
|
|
78
|
+
while (usedIds.has(windowIdOf(sessionId, minted))) minted = { id: randomUUID().slice(0, 8) };
|
|
79
|
+
const windowId = windowIdOf(sessionId, minted);
|
|
80
|
+
const previousId = windows[windows.length - 1]?.windowId ?? rootWindowId(sessionId);
|
|
82
81
|
// The reset marker stays as firstKeptEntryId; it no longer names the window.
|
|
83
82
|
pi.appendEntry(RESET_MARKER_TYPE, { version: 1, reason: event.reason, requested: explicit });
|
|
84
83
|
const markerId = ctx.sessionManager.getLeafId();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { assertVirtualPath } from "./model.js";
|
|
2
|
+
import type { Scope } from "./paths.js";
|
|
3
|
+
|
|
4
|
+
export type NoteAddress = { scope: Scope; path: string };
|
|
5
|
+
|
|
6
|
+
const ADDRESS_FORMS = "legal prefixes are @project/ and @global/; bare names are the session home";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Decode the one public note address into its physical home and virtual path. This is a
|
|
10
|
+
* tool-boundary rule: replay paths keep using assertVirtualPath directly and are untouched.
|
|
11
|
+
*/
|
|
12
|
+
export function assertAddress(value: unknown): NoteAddress {
|
|
13
|
+
if (typeof value !== "string") throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
|
|
14
|
+
let scope: Scope = "session";
|
|
15
|
+
let path = value;
|
|
16
|
+
if (value.startsWith("@project/")) {
|
|
17
|
+
scope = "project";
|
|
18
|
+
path = value.slice("@project/".length);
|
|
19
|
+
} else if (value.startsWith("@global/")) {
|
|
20
|
+
scope = "global";
|
|
21
|
+
path = value.slice("@global/".length);
|
|
22
|
+
} else if (value.startsWith("@")) {
|
|
23
|
+
throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
|
|
24
|
+
}
|
|
25
|
+
if (path.includes("@")) throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
|
|
26
|
+
assertVirtualPath(path);
|
|
27
|
+
return { scope, path };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Render a virtual path in its one unambiguous public address form. */
|
|
31
|
+
export function addressFor(scope: Scope, path: string): string {
|
|
32
|
+
return scope === "session" ? path : `@${scope}/${path}`;
|
|
33
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { localIso } from "
|
|
1
|
+
import { localIso } from "./model.js";
|
|
2
2
|
import type { Scope } from "./paths.js";
|
|
3
3
|
|
|
4
4
|
export type NoteStatus = "active" | "superseded" | "pending" | "archived";
|
|
@@ -30,7 +30,7 @@ const ORIGINS: readonly Origin[] = ["user", "self", "external"];
|
|
|
30
30
|
const STATUSES: readonly NoteStatus[] = ["active", "superseded", "pending", "archived"];
|
|
31
31
|
const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"] as const;
|
|
32
32
|
/** Emission order, exactly the Design's key list. */
|
|
33
|
-
const KNOWN_KEYS = ["
|
|
33
|
+
const KNOWN_KEYS = ["origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"] as const;
|
|
34
34
|
|
|
35
35
|
export function isScope(value: unknown): value is Scope {
|
|
36
36
|
return typeof value === "string" && (SCOPES as readonly string[]).includes(value);
|
|
@@ -140,7 +140,9 @@ export function serializeNote(meta: NoteMeta, body: string): string {
|
|
|
140
140
|
else lines.push(`${key}: ${yamlScalar(value)}`);
|
|
141
141
|
}
|
|
142
142
|
for (const key of Object.keys(meta)) {
|
|
143
|
-
|
|
143
|
+
// scope is a legacy on-disk field. Store callers derive it from the home's location,
|
|
144
|
+
// but serialization intentionally drops it on the next write.
|
|
145
|
+
if (key === "scope" || (KNOWN_KEYS as readonly string[]).includes(key)) continue;
|
|
144
146
|
if (meta[key] === undefined) continue;
|
|
145
147
|
lines.push(`${key}: ${yamlScalar(meta[key])}`);
|
|
146
148
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { SessionReader } from "
|
|
2
|
-
import { MAX_NOTE_BYTES, NOTE_TYPE } from "
|
|
1
|
+
import type { SessionReader } from "../session-reader.js";
|
|
2
|
+
import { MAX_NOTE_BYTES, NOTE_TYPE } from "../protocol.js";
|
|
3
3
|
|
|
4
4
|
export type NoteFile = { text: string; stale: boolean; createdAt: number; updatedAt: number };
|
|
5
5
|
export type NoteOperation = {
|
|
@@ -12,6 +12,11 @@ export function notesRoot(): string {
|
|
|
12
12
|
return override && override.length > 0 ? resolve(override) : join(homedir(), ".agents", "notes");
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** Absolute directory holding the per-session note homes. */
|
|
16
|
+
export function sessionHomesRoot(home = notesRoot()): string {
|
|
17
|
+
return join(home, "pi", "session");
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
/**
|
|
16
21
|
* Absolute git root for `cwd`, walking upward until a directory holds a `.git` entry.
|
|
17
22
|
* No git root yields undefined, which projectKey then replaces with the cwd itself.
|
|
@@ -43,7 +48,7 @@ function sessionId(ctx: ExtensionContext): string {
|
|
|
43
48
|
export function scopeDir(scope: Scope, ctx: ExtensionContext): string {
|
|
44
49
|
if (scope === "global") return join(notesRoot(), "global");
|
|
45
50
|
if (scope === "project") return join(notesRoot(), "project", projectKey(ctx.cwd));
|
|
46
|
-
return join(
|
|
51
|
+
return join(sessionHomesRoot(), sessionId(ctx));
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
/**
|