@astrosheep/pi-context 0.18.0 → 0.20.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 +10 -10
- package/dist/src/budget.js +63 -0
- package/dist/src/dream/apply.js +87 -0
- package/dist/src/dream/cli.js +82 -0
- package/dist/src/dream/gates.js +21 -0
- package/dist/src/dream/lock.js +58 -0
- package/dist/src/dream/manifest.js +16 -0
- package/dist/src/dream/runner.js +56 -0
- package/dist/src/history-tools.js +105 -0
- package/dist/src/history.js +210 -0
- package/dist/src/index.js +99 -0
- package/dist/src/memory/frontmatter.js +134 -0
- package/dist/src/memory/paths.js +54 -0
- package/dist/src/memory/store.js +297 -0
- package/dist/src/memory/tools.js +175 -0
- package/dist/src/notes.js +101 -0
- package/dist/src/prompts.js +79 -0
- package/dist/src/protocol.js +52 -0
- package/dist/src/reset-lifecycle.js +101 -0
- package/dist/src/session-reader.js +1 -0
- package/dist/src/thresholds.js +72 -0
- package/dist/src/tool-output.js +172 -0
- package/dist/src/tool-schema.js +26 -0
- package/dist/src/warning.js +44 -0
- package/dist/test/agent-loop.test.js +212 -0
- package/dist/test/coherence.test.js +371 -0
- package/dist/test/dream.test.js +43 -0
- package/dist/test/history.test.js +21 -0
- package/dist/test/integration.test.js +1716 -0
- package/dist/test/memory.test.js +370 -0
- package/dist/test/pagination.property.test.js +476 -0
- package/dist/test/reset-lifecycle.test.js +199 -0
- package/docs/reset-lifecycle.md +1 -1
- package/package.json +9 -3
- package/playbook.md +5 -0
- package/src/dream/apply.ts +47 -0
- package/src/dream/cli.ts +33 -0
- package/src/dream/gates.ts +19 -0
- package/src/dream/lock.ts +39 -0
- package/src/dream/manifest.ts +21 -0
- package/src/dream/runner.ts +53 -0
- package/src/history-tools.ts +6 -6
- package/src/history.ts +1 -1
- package/src/index.ts +2 -2
- package/src/memory/frontmatter.ts +153 -0
- package/src/memory/paths.ts +60 -0
- package/src/memory/store.ts +310 -0
- package/src/memory/tools.ts +175 -0
- package/src/prompts.ts +28 -17
- package/src/protocol.ts +7 -7
- package/src/note-tools.ts +0 -171
package/docs/reset-lifecycle.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
| Attempt `onError` or synchronous throw | Clear attempt/request, warn, retain history. No automatic retry loop. |
|
|
14
14
|
| Shutdown / start / tree / toggle off | Invalidate outstanding attempt. Identity checks reject callbacks from older attempts. |
|
|
15
15
|
|
|
16
|
-
The final checkpoint warning is steered earlier from the context hook (`warning.ts`) once per window at reserve+
|
|
16
|
+
The final checkpoint warning is steered earlier from the context hook (`warning.ts`) once per window at reserve+12288 tokens remaining. After it, the model either ends the window itself with `new_context` or rides into Pi's automatic compaction, which resets on the spot with no turn.
|
|
17
17
|
|
|
18
18
|
The completion callback is the scheduling boundary: `session_compact` fires before Pi clears manual compaction state. Sending a prompt inside that hook is too early. An explicit reset uses the manual `ctx.compact` route and therefore needs this completion logic; an automatic compaction is already the reset and resumes through Pi's own caller.
|
|
19
19
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrosheep/pi-context",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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
33
|
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
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": "*",
|
package/playbook.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
I am dreaming over my notes, speaking in my own first-person voice. I inspect the supplied notes and return exactly one JSON manifest, with no commentary outside it.
|
|
2
|
+
|
|
3
|
+
I do seven chores: merge genuinely repeated notes; preserve provenance and recurrence windows; propose (but never execute) global promotions; move only clearly obsolete notes to reversible trash; identify pending ambiguities; propose skill candidates without installing them; and write a concise report of my reasoning and choices. I anchor every temporal claim to an absolute calendar date (YYYY-MM-DD), never to vague words like “today”. I stay aware of the index budget: prefer compact, deduplicated durable notes and avoid swelling the index with repetition. Skill-promotion proposals are proposals only. Judgment belongs here, not in the harness.
|
|
4
|
+
|
|
5
|
+
My final output is the manifest schema documented by the command: merge, promote, trash, pending, skillCandidates, and a required report string.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve, relative } from "node:path";
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { editNote, peekNote, resolveNoteScope, updateNoteMeta, writeNote, type Scope } from "../memory/store.js";
|
|
5
|
+
import { physicalPath, scopeDir } from "../memory/paths.js";
|
|
6
|
+
import { parseNote } from "../memory/frontmatter.js";
|
|
7
|
+
import type { Manifest } from "./manifest.js";
|
|
8
|
+
|
|
9
|
+
type Target = { scope: Scope; path: string };
|
|
10
|
+
function target(ctx: ExtensionContext, value: string, required = true): Target | undefined {
|
|
11
|
+
const m = /^(session|project|global):(.*)$/.exec(value);
|
|
12
|
+
if (m) { const scope = m[1] as Scope; const path = m[2]!; const physical = physicalPath(scope, path, ctx); if (!existsSync(physical)) { if (required) throw new Error(`unknown path: ${value}`); return undefined; } return { scope, path }; }
|
|
13
|
+
const found = resolveNoteScope(ctx, value);
|
|
14
|
+
if (!found && required) throw new Error(`unknown path: ${value}`);
|
|
15
|
+
return found ? { scope: found.scope, path: value } : undefined;
|
|
16
|
+
}
|
|
17
|
+
function body(ctx: ExtensionContext, t: Target) { return peekNote(ctx, t.scope, t.path); }
|
|
18
|
+
export function applyManifest(ctx: ExtensionContext, home: string, stamp: string, manifest: Manifest): string[] {
|
|
19
|
+
const actions: string[] = [];
|
|
20
|
+
// Resolve every referenced note and promotion destination before the first mutation.
|
|
21
|
+
for (const m of manifest.merge ?? []) { target(ctx, m.into); for (const p of m.from) target(ctx, p); }
|
|
22
|
+
for (const p of manifest.promote ?? []) { const from = target(ctx, p.path)!; if (p.to !== "global") { if (!["session", "project"].includes(p.to)) throw new Error(`invalid promotion scope: ${p.to}`); if (existsSync(physicalPath(p.to as Scope, p.path, ctx))) throw new Error(`promotion collision: ${p.path}`); } void from; }
|
|
23
|
+
for (const p of manifest.trash ?? []) target(ctx, p.path);
|
|
24
|
+
for (const merge of manifest.merge ?? []) {
|
|
25
|
+
const into = target(ctx, merge.into)!;
|
|
26
|
+
const sources = merge.from.map((p) => target(ctx, p)!);
|
|
27
|
+
const base = body(ctx, into); const chunks = [base.body, ...sources.map((s) => body(ctx, s).body)].filter(Boolean);
|
|
28
|
+
const dedup = [...new Set(chunks)].join("\n\n");
|
|
29
|
+
writeNote(ctx, into.path, dedup, { scope: into.scope, origin: base.meta.origin });
|
|
30
|
+
updateNoteMeta(ctx, into.path, into.scope, (meta) => { meta.recurrence_count = (meta.recurrence_count ?? 0) as number + sources.length; meta.recurrence_windows = [...new Set([...(meta.recurrence_windows ?? []), ...sources.map((s) => body(ctx, s).meta.source_window).filter((x): x is string => typeof x === "string")])]; });
|
|
31
|
+
for (const source of sources) updateNoteMeta(ctx, source.path, source.scope, (meta) => { meta.status = "superseded"; meta.supersedes = merge.into; });
|
|
32
|
+
actions.push(`merged ${merge.from.join(", ")} into ${merge.into}`);
|
|
33
|
+
}
|
|
34
|
+
for (const p of manifest.promote ?? []) {
|
|
35
|
+
const from = target(ctx, p.path)!; const m = body(ctx, from); const scope = p.to as Scope;
|
|
36
|
+
if (!["session", "project", "global"].includes(scope)) throw new Error(`invalid promotion scope: ${p.to}`);
|
|
37
|
+
if (scope === "global") { actions.push(`proposal: promote ${p.path} to global (${p.reason})`); continue; }
|
|
38
|
+
const dest = physicalPath(scope, p.path, ctx); if (existsSync(dest)) throw new Error(`promotion collision: ${p.path}`);
|
|
39
|
+
editNote(ctx, from.path, undefined, { scope });
|
|
40
|
+
actions.push(`promoted ${p.path} to ${scope}`);
|
|
41
|
+
}
|
|
42
|
+
const trashRoot = join(home, "trash", stamp); mkdirSync(trashRoot, { recursive: true });
|
|
43
|
+
for (const item of manifest.trash ?? []) { const t = target(ctx, item.path)!; const source = physicalPath(t.scope, t.path, ctx); const dest = join(trashRoot, t.scope, t.path.endsWith(".md") ? t.path : `${t.path}.md`); mkdirSync(dirname(dest), { recursive: true }); renameSync(source, dest); actions.push(`trashed ${item.path}: ${item.reason}`); }
|
|
44
|
+
for (const p of manifest.pending ?? []) actions.push(`pending ${p.path}: ${p.reason}`);
|
|
45
|
+
for (const p of manifest.skillCandidates ?? []) actions.push(`skill proposal ${p.title}: ${p.rationale}`);
|
|
46
|
+
return actions;
|
|
47
|
+
}
|
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 { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { acquireLock, failLock, releaseLock } from "./lock.js";
|
|
6
|
+
import { materialGate, timeGate } from "./gates.js";
|
|
7
|
+
import { loadPlaybook, runDreamer } from "./runner.js";
|
|
8
|
+
import { applyManifest } from "./apply.js";
|
|
9
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
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; }
|
|
12
|
+
function packageRoot(): string {
|
|
13
|
+
let dir = dirname(new URL(import.meta.url).pathname);
|
|
14
|
+
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; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function main(argv = process.argv.slice(2)): Promise<number> {
|
|
18
|
+
const a = args(argv); if (a.help) { console.log("dream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <cmd>] [--dreamer-model <pattern>] [--playbook <path>]\nDefault dreamer: in-process pi SDK session with read-only tools; use a cheap model in production. Default playbook: <installed package root>/playbook.md; --playbook overrides it."); return 0; }
|
|
19
|
+
const home = resolve(String(a["notes-home"] ?? process.env.PI_NOTES_HOME ?? join(homedir(), ".agents", "notes"))); process.env.PI_NOTES_HOME = home; mkdirSync(home, { recursive: true });
|
|
20
|
+
const lockPath = join(home, ".dream.lock"); const minHours = Number(a["min-hours"] ?? 24); const minSessions = Number(a["min-sessions"] ?? 3);
|
|
21
|
+
const time = timeGate(lockPath, minHours); console.log(time.reason); if (!a.force && !time.ok) return 0;
|
|
22
|
+
const material = materialGate(home, existsSync(lockPath) ? statSync(lockPath).mtimeMs : 0, minSessions); console.log(material.reason); if (!a.force && !material.ok) return 0;
|
|
23
|
+
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; }
|
|
24
|
+
const stamp = new Date(lock.startedAt).toISOString().replace(/[:.]/g, "-"); const reportPath = join(home, "dreams", `${stamp}.md`);
|
|
25
|
+
try {
|
|
26
|
+
const defaultBook = join(packageRoot(), "playbook.md");
|
|
27
|
+
const playbookPath = String(a.playbook ?? defaultBook);
|
|
28
|
+
const playbook = loadPlaybook(playbookPath); const manifest = await runDreamer(playbook, home, { command: a.dreamer ? String(a.dreamer) : undefined, modelPattern: a["dreamer-model"] ? String(a["dreamer-model"]) : undefined });
|
|
29
|
+
const ctx = { cwd: home, sessionManager: { getSessionId: () => "dream" } } as unknown as ExtensionContext; const actions = applyManifest(ctx, home, stamp, manifest);
|
|
30
|
+
mkdirSync(join(home, "dreams"), { recursive: true }); writeFileSync(reportPath, `# Dream ${stamp}\n\n${manifest.report}\n\n${actions.map((x) => `- ${x}`).join("\n")}\n`); 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,19 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type GateResult = { ok: boolean; reason: string };
|
|
5
|
+
export function timeGate(lockPath: string, minHours: number, now = Date.now()): GateResult {
|
|
6
|
+
if (!existsSync(lockPath)) return { ok: true, reason: "time gate: no prior lock" };
|
|
7
|
+
const age = now - statSync(lockPath).mtimeMs;
|
|
8
|
+
return age >= minHours * 3600000 ? { ok: true, reason: "time gate: stale" } : { ok: false, reason: "time gate: lock is too fresh" };
|
|
9
|
+
}
|
|
10
|
+
export function materialGate(home: string, lockMtime: number, minSessions: number): GateResult {
|
|
11
|
+
const root = join(home, "pi", "session");
|
|
12
|
+
let changed = 0;
|
|
13
|
+
if (existsSync(root)) for (const dir of readdirSync(root, { withFileTypes: true })) {
|
|
14
|
+
if (!dir.isDirectory()) continue;
|
|
15
|
+
const files = readdirSync(join(root, dir.name), { withFileTypes: true });
|
|
16
|
+
if (files.some((f) => f.isFile() && statSync(join(root, dir.name, f.name)).mtimeMs > lockMtime)) changed++;
|
|
17
|
+
}
|
|
18
|
+
return changed >= minSessions ? { ok: true, reason: `material gate: ${changed} changed sessions` } : { ok: false, reason: `material gate: only ${changed} changed sessions` };
|
|
19
|
+
}
|
|
@@ -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,21 @@
|
|
|
1
|
+
export type Manifest = {
|
|
2
|
+
merge?: { into: string; from: string[]; summary?: string }[];
|
|
3
|
+
promote?: { path: string; to: string; reason: string }[];
|
|
4
|
+
trash?: { path: string; reason: string }[];
|
|
5
|
+
pending?: { path: string; reason: string }[];
|
|
6
|
+
skillCandidates?: { title: string; rationale: string }[];
|
|
7
|
+
report: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function parseManifest(output: string): Manifest {
|
|
11
|
+
const starts = [...output.matchAll(/[\{[]/g)].map((m) => m.index ?? 0).reverse();
|
|
12
|
+
for (const start of starts) {
|
|
13
|
+
try {
|
|
14
|
+
const value = JSON.parse(output.slice(start)) as Manifest;
|
|
15
|
+
if (!value || typeof value !== "object" || typeof value.report !== "string") continue;
|
|
16
|
+
for (const key of ["merge", "promote", "trash", "pending", "skillCandidates"]) if (value[key as keyof Manifest] !== undefined && !Array.isArray(value[key as keyof Manifest])) throw new Error("invalid array");
|
|
17
|
+
return value;
|
|
18
|
+
} catch { /* try an earlier JSON start */ }
|
|
19
|
+
}
|
|
20
|
+
throw new Error("dreamer did not return a valid JSON manifest");
|
|
21
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createAgentSession, ModelRuntime, resolveModelScopeWithDiagnostics, SessionManager, type AgentSession } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
5
|
+
import { parseManifest, type Manifest } from "./manifest.js";
|
|
6
|
+
|
|
7
|
+
export type DreamerSession = Pick<AgentSession, "prompt" | "subscribe" | "dispose">;
|
|
8
|
+
export type DreamerSessionFactory = (options: { cwd: string; modelPattern?: string; tools: string[] }) => Promise<DreamerSession>;
|
|
9
|
+
export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "notes_read", "notes_list", "notes_search"];
|
|
10
|
+
|
|
11
|
+
export const defaultDreamerSessionFactory: DreamerSessionFactory = async ({ cwd, modelPattern, tools }) => {
|
|
12
|
+
let model: Model<Api> | undefined;
|
|
13
|
+
if (modelPattern) {
|
|
14
|
+
const runtime = await ModelRuntime.create({ allowModelNetwork: false, refreshOnCreate: false });
|
|
15
|
+
const result = await resolveModelScopeWithDiagnostics([modelPattern], runtime);
|
|
16
|
+
model = result.scopedModels[0]?.model;
|
|
17
|
+
if (!model) throw new Error(`dreamer model pattern "${modelPattern}" did not resolve to an available model`);
|
|
18
|
+
}
|
|
19
|
+
const { session } = await createAgentSession({ cwd, sessionManager: SessionManager.inMemory(cwd), tools, noTools: "all", model });
|
|
20
|
+
return session;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function runExternalDreamer(command: string, playbook: string, cwd: string): Manifest {
|
|
24
|
+
const result = spawnSync(command, { shell: true, cwd, input: playbook, encoding: "utf8" });
|
|
25
|
+
if (result.error || result.status !== 0) throw new Error(`dreamer failed: ${result.error?.message ?? result.stderr ?? `exit ${result.status}`}`);
|
|
26
|
+
return parseManifest(result.stdout);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runDreamer(playbook: string, cwd: string, options: { command?: string; modelPattern?: string; sessionFactory?: DreamerSessionFactory } = {}): Promise<Manifest> {
|
|
30
|
+
if (options.command) return runExternalDreamer(options.command, playbook, cwd);
|
|
31
|
+
const session = await (options.sessionFactory ?? defaultDreamerSessionFactory)({ cwd, modelPattern: options.modelPattern, tools: READ_ONLY_TOOLS });
|
|
32
|
+
let answer = "";
|
|
33
|
+
let providerError: string | undefined;
|
|
34
|
+
const unsubscribe = session.subscribe((event: any) => {
|
|
35
|
+
if (event.type !== "message_end" || event.message?.role !== "assistant") return;
|
|
36
|
+
if (event.message.stopReason === "error") { providerError = event.message.errorMessage ?? "unknown provider error"; return; }
|
|
37
|
+
const content = event.message.content;
|
|
38
|
+
answer = typeof content === "string" ? content : Array.isArray(content) ? content.filter((part: any) => part.type === "text").map((part: any) => part.text).join("") : "";
|
|
39
|
+
});
|
|
40
|
+
try {
|
|
41
|
+
await session.prompt(`${playbook}\n\nReturn exactly one JSON manifest matching this schema: { merge?, promote?, trash?, pending?, skillCandidates?, report }.`);
|
|
42
|
+
try {
|
|
43
|
+
return parseManifest(answer);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (providerError) throw new Error(`dreamer failed: ${providerError}`);
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
} finally {
|
|
49
|
+
unsubscribe?.();
|
|
50
|
+
session.dispose();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function loadPlaybook(path: string): string { return readFileSync(path, "utf8"); }
|
package/src/history-tools.ts
CHANGED
|
@@ -31,7 +31,7 @@ function truncateHistoryItem<T extends { truncated_content: string; truncated: b
|
|
|
31
31
|
|
|
32
32
|
export function registerHistoryTools(pi: ExtensionAPI) {
|
|
33
33
|
pi.registerTool(defineTool({
|
|
34
|
-
name: "
|
|
34
|
+
name: "history_windows",
|
|
35
35
|
label: "History list windows",
|
|
36
36
|
description: "List durable Pi session-history windows.",
|
|
37
37
|
parameters: Type.Object({ limit: positiveInteger(), recent_first: recentFirst() }, { additionalProperties: false }),
|
|
@@ -44,9 +44,9 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
44
44
|
}));
|
|
45
45
|
|
|
46
46
|
pi.registerTool(defineTool({
|
|
47
|
-
name: "
|
|
47
|
+
name: "history_list",
|
|
48
48
|
label: "History list items",
|
|
49
|
-
description: "List durable session items, including items before compaction, using opaque item and window IDs; the role parameter's description enumerates the six roles. Every item carries truncated and total_chars: when truncated is true, truncated_content is a plain prefix of the item's content with no marker, and total_chars is its full code-point length. max_chars_per_item: 1 therefore yields pure addresses you can resolve with
|
|
49
|
+
description: "List durable session items, including items before compaction, using opaque item and window IDs; the role parameter's description enumerates the six roles. Every item carries truncated and total_chars: when truncated is true, truncated_content is a plain prefix of the item's content with no marker, and total_chars is its full code-point length. max_chars_per_item: 1 therefore yields pure addresses you can resolve with history_read.",
|
|
50
50
|
parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), recent_first: recentFirst(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
|
|
51
51
|
async execute(_id, params, _signal, _update, ctx) {
|
|
52
52
|
const invalid = vacuousRoleToolCombo(params);
|
|
@@ -59,7 +59,7 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
59
59
|
}));
|
|
60
60
|
|
|
61
61
|
pi.registerTool(defineTool({
|
|
62
|
-
name: "
|
|
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
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: 50000, description: "Largest requested window in code points (default 12000). 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 }),
|
|
@@ -81,9 +81,9 @@ export function registerHistoryTools(pi: ExtensionAPI) {
|
|
|
81
81
|
}));
|
|
82
82
|
|
|
83
83
|
pi.registerTool(defineTool({
|
|
84
|
-
name: "
|
|
84
|
+
name: "history_search",
|
|
85
85
|
label: "History search",
|
|
86
|
-
description: "Case-sensitive literal substring search over durable Pi session history; query accepts one string or an array of strings, an item matches when it contains any of them (OR), and each item appears once. No semantic search. Invocations and outputs are separate items (roles \"tool_call\" and \"tool\"), so both are searchable; the role parameter's description enumerates all six. Each hit carries truncated and total_chars plus match_offset_chars: the code-point offset of the earliest query occurrence in the item's full content. With max_chars_per_item: 1 the page is an address list; resolve an address with
|
|
86
|
+
description: "Case-sensitive literal substring search over durable Pi session history; query accepts one string or an array of strings, an item matches when it contains any of them (OR), and each item appears once. No semantic search. Invocations and outputs are separate items (roles \"tool_call\" and \"tool\"), so both are searchable; the role parameter's description enumerates all six. Each hit carries truncated and total_chars plus match_offset_chars: the code-point offset of the earliest query occurrence in the item's full content. With max_chars_per_item: 1 the page is an address list; resolve an address with history_read at match_offset_chars.",
|
|
87
87
|
parameters: Type.Object({ limit: positiveInteger(), cursor: cursor(), query: searchQuery(), recent_first: recentFirst(), role: Type.Optional(role), tool_name: nullableString(), window_id: nullableString(), max_chars_per_item: positiveInteger() }, { additionalProperties: false }),
|
|
88
88
|
async execute(_id, params, _signal, _update, ctx) {
|
|
89
89
|
const invalid = vacuousRoleToolCombo(params);
|
package/src/history.ts
CHANGED
|
@@ -72,7 +72,7 @@ function toolInfo(message: AgentMessage): Pick<HistoryItem, "toolName" | "output
|
|
|
72
72
|
* An assistant turn's tool calls, projected as their own items: calls wear their own role so the
|
|
73
73
|
* authoring turn's visible text (role "assistant") stays pure; what was invoked stays as
|
|
74
74
|
* searchable as what came back (role "tool"). Ids derive from the turn's entry id and stay
|
|
75
|
-
* opaque;
|
|
75
|
+
* opaque; history_read resolves them like any other item.
|
|
76
76
|
*/
|
|
77
77
|
function toolCallItems(windowId: string, entry: { id: string; timestamp?: string }, message: AgentMessage): HistoryItem[] {
|
|
78
78
|
if (message.role !== "assistant" || !Array.isArray(message.content)) return [];
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { registerHistoryTools } from "./history-tools.js";
|
|
2
|
-
import {
|
|
2
|
+
import { registerMemoryTools } from "./memory/tools.js";
|
|
3
3
|
import { registerBudget, deriveThresholds, mergePiContextSettings } from "./budget.js";
|
|
4
4
|
import { output } from "./tool-output.js";
|
|
5
5
|
export { deriveThresholds, mergePiContextSettings };
|
|
@@ -48,7 +48,7 @@ export default function piContext(pi: ExtensionAPI) {
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
registerHistoryTools(pi);
|
|
51
|
-
|
|
51
|
+
registerMemoryTools(pi);
|
|
52
52
|
|
|
53
53
|
pi.registerTool(defineTool({
|
|
54
54
|
name: "new_context",
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { localIso } from "../notes.js";
|
|
2
|
+
import type { Scope } from "./paths.js";
|
|
3
|
+
|
|
4
|
+
export type NoteStatus = "active" | "superseded" | "pending" | "archived";
|
|
5
|
+
export type Origin = "user" | "self" | "external";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Harness-owned note metadata. The eight required keys are always written; the four optional
|
|
9
|
+
* sleep-shift keys survive this layer untouched when present. `[key: string]: unknown` carries
|
|
10
|
+
* any frontmatter key this layer does not know, preserved verbatim across rewrites.
|
|
11
|
+
*/
|
|
12
|
+
export type NoteMeta = {
|
|
13
|
+
scope: Scope;
|
|
14
|
+
origin: Origin;
|
|
15
|
+
status: NoteStatus;
|
|
16
|
+
stale: boolean;
|
|
17
|
+
created_at: number;
|
|
18
|
+
updated_at: number;
|
|
19
|
+
last_accessed: number;
|
|
20
|
+
access_count: number;
|
|
21
|
+
source_window?: string;
|
|
22
|
+
supersedes?: string;
|
|
23
|
+
recurrence_count?: number;
|
|
24
|
+
recurrence_windows?: string[];
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const SCOPES: readonly Scope[] = ["session", "project", "global"];
|
|
29
|
+
const ORIGINS: readonly Origin[] = ["user", "self", "external"];
|
|
30
|
+
const STATUSES: readonly NoteStatus[] = ["active", "superseded", "pending", "archived"];
|
|
31
|
+
const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"] as const;
|
|
32
|
+
/** Emission order, exactly the Design's key list. */
|
|
33
|
+
const KNOWN_KEYS = ["scope", "origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count", "source_window", "supersedes", "recurrence_count", "recurrence_windows"] as const;
|
|
34
|
+
|
|
35
|
+
export function isScope(value: unknown): value is Scope {
|
|
36
|
+
return typeof value === "string" && (SCOPES as readonly string[]).includes(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isOrigin(value: unknown): value is Origin {
|
|
40
|
+
return typeof value === "string" && (ORIGINS as readonly string[]).includes(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isStatus(value: unknown): value is NoteStatus {
|
|
44
|
+
return typeof value === "string" && (STATUSES as readonly string[]).includes(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toEpoch(value: unknown, fallback: number): number {
|
|
48
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
49
|
+
if (typeof value === "string") {
|
|
50
|
+
const parsed = Date.parse(value);
|
|
51
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
52
|
+
}
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A frontmatter scalar encoded as JSON, falling back to a plain string for hand-written YAML. */
|
|
57
|
+
function parseScalar(text: string): unknown {
|
|
58
|
+
const trimmed = text.trim();
|
|
59
|
+
if (trimmed === "") return "";
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(trimmed);
|
|
62
|
+
} catch {
|
|
63
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) return trimmed.slice(1, -1);
|
|
64
|
+
return trimmed;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Split raw file text into its frontmatter fields and body. When the file does not open with
|
|
70
|
+
* a closed `---` block, the whole text is body and the field map is empty.
|
|
71
|
+
*/
|
|
72
|
+
function parseFrontmatter(raw: string): { fields: Record<string, unknown>; body: string } {
|
|
73
|
+
const stripped = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
74
|
+
const lines = stripped.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
|
|
75
|
+
if (lines[0]?.trim() !== "---") return { fields: {}, body: raw };
|
|
76
|
+
let close = -1;
|
|
77
|
+
for (let index = 1; index < lines.length; index++) {
|
|
78
|
+
if (lines[index]?.trim() === "---") {
|
|
79
|
+
close = index;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (close === -1) return { fields: {}, body: raw };
|
|
84
|
+
const fields: Record<string, unknown> = {};
|
|
85
|
+
for (let index = 1; index < close; index++) {
|
|
86
|
+
const line = lines[index]!;
|
|
87
|
+
const match = /^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/.exec(line);
|
|
88
|
+
if (!match) continue;
|
|
89
|
+
const key = match[1]!;
|
|
90
|
+
const rest = match[2]!;
|
|
91
|
+
if (rest.trim() === "") {
|
|
92
|
+
// A bare key opens a block sequence of `- item` lines, the only multi-line shape we parse.
|
|
93
|
+
const items: unknown[] = [];
|
|
94
|
+
while (index + 1 < close && /^\s*-\s+/.test(lines[index + 1]!)) {
|
|
95
|
+
index++;
|
|
96
|
+
items.push(parseScalar(lines[index]!.replace(/^\s*-\s+/, "")));
|
|
97
|
+
}
|
|
98
|
+
fields[key] = items;
|
|
99
|
+
} else {
|
|
100
|
+
fields[key] = parseScalar(rest);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const rest = lines.slice(close + 1);
|
|
104
|
+
if (rest[0] === "") rest.shift();
|
|
105
|
+
return { fields, body: rest.join("\n") };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse a note file. Missing known keys take the Design defaults (status active, stale false,
|
|
110
|
+
* access_count 0, timestamps now); unknown keys are carried through untouched.
|
|
111
|
+
*/
|
|
112
|
+
export function parseNote(raw: string, now = Date.now()): { meta: NoteMeta; body: string } {
|
|
113
|
+
const { fields, body } = parseFrontmatter(raw);
|
|
114
|
+
const meta = { ...fields } as Record<string, unknown>;
|
|
115
|
+
meta.scope = isScope(meta.scope) ? meta.scope : "global";
|
|
116
|
+
meta.origin = isOrigin(meta.origin) ? meta.origin : "self";
|
|
117
|
+
meta.status = isStatus(meta.status) ? meta.status : "active";
|
|
118
|
+
meta.stale = meta.stale === true;
|
|
119
|
+
for (const key of TIMESTAMP_KEYS) meta[key] = toEpoch(meta[key], now);
|
|
120
|
+
meta.access_count = typeof meta.access_count === "number" && Number.isFinite(meta.access_count) ? meta.access_count : 0;
|
|
121
|
+
return { meta: meta as NoteMeta, body };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Emit a YAML scalar: bare for safe strings and JSON literals, JSON-quoted otherwise. */
|
|
125
|
+
function yamlScalar(value: unknown): string {
|
|
126
|
+
if (typeof value === "string") {
|
|
127
|
+
const reserved = new Set(["true", "false", "null", "yes", "no", "on", "off", "~"]);
|
|
128
|
+
if (/^[A-Za-z0-9_.+\-:/]+$/.test(value) && !reserved.has(value.toLowerCase())) return value;
|
|
129
|
+
}
|
|
130
|
+
return JSON.stringify(value);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Serialize frontmatter + blank line + body. Known keys emit in Design order, extras after. */
|
|
134
|
+
export function serializeNote(meta: NoteMeta, body: string): string {
|
|
135
|
+
const lines: string[] = [];
|
|
136
|
+
for (const key of KNOWN_KEYS) {
|
|
137
|
+
const value = meta[key];
|
|
138
|
+
if (value === undefined) continue;
|
|
139
|
+
if ((TIMESTAMP_KEYS as readonly string[]).includes(key)) lines.push(`${key}: ${yamlScalar(localIso(value as number))}`);
|
|
140
|
+
else lines.push(`${key}: ${yamlScalar(value)}`);
|
|
141
|
+
}
|
|
142
|
+
for (const key of Object.keys(meta)) {
|
|
143
|
+
if ((KNOWN_KEYS as readonly string[]).includes(key)) continue;
|
|
144
|
+
if (meta[key] === undefined) continue;
|
|
145
|
+
lines.push(`${key}: ${yamlScalar(meta[key])}`);
|
|
146
|
+
}
|
|
147
|
+
return `---\n${lines.join("\n")}\n---\n\n${body}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Strip a leading frontmatter block from user content, so a note body is pure content. */
|
|
151
|
+
export function stripLeadingFrontmatter(content: string): string {
|
|
152
|
+
return parseFrontmatter(content).body;
|
|
153
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
export type Scope = "session" | "project" | "global";
|
|
8
|
+
|
|
9
|
+
/** Physical home of the on-disk note store: $PI_NOTES_HOME or ~/.agents/notes. */
|
|
10
|
+
export function notesRoot(): string {
|
|
11
|
+
const override = process.env.PI_NOTES_HOME;
|
|
12
|
+
return override && override.length > 0 ? resolve(override) : join(homedir(), ".agents", "notes");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Absolute git root for `cwd`, walking upward until a directory holds a `.git` entry.
|
|
17
|
+
* No git root yields undefined, which projectKey then replaces with the cwd itself.
|
|
18
|
+
*/
|
|
19
|
+
function gitRoot(cwd: string): string | undefined {
|
|
20
|
+
let dir = resolve(cwd);
|
|
21
|
+
for (;;) {
|
|
22
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
23
|
+
const parent = dirname(dir);
|
|
24
|
+
if (parent === dir) return undefined;
|
|
25
|
+
dir = parent;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `<basename(absGitRoot)-sha1(absGitRoot)[:8]>`, or the same formula over cwd with no git root. */
|
|
30
|
+
export function projectKey(cwd: string): string {
|
|
31
|
+
const absolute = resolve(cwd);
|
|
32
|
+
const root = gitRoot(absolute) ?? absolute;
|
|
33
|
+
const digest = createHash("sha1").update(root).digest("hex").slice(0, 8);
|
|
34
|
+
return `${basename(root)}-${digest}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Session identity comes from the pi session manager; ids are filesystem-safe by construction. */
|
|
38
|
+
function sessionId(ctx: ExtensionContext): string {
|
|
39
|
+
return ctx.sessionManager.getSessionId();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Absolute directory holding every note of one scope. */
|
|
43
|
+
export function scopeDir(scope: Scope, ctx: ExtensionContext): string {
|
|
44
|
+
if (scope === "global") return join(notesRoot(), "global");
|
|
45
|
+
if (scope === "project") return join(notesRoot(), "project", projectKey(ctx.cwd));
|
|
46
|
+
return join(notesRoot(), "pi", "session", sessionId(ctx));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Notes are markdown files: a virtual path without an `.md` suffix gains one, an explicit
|
|
51
|
+
* `.md` is kept as-is, so `a/b` and `a/b.md` name the same physical file.
|
|
52
|
+
*/
|
|
53
|
+
export function noteFileName(vpath: string): string {
|
|
54
|
+
return vpath.endsWith(".md") ? vpath : `${vpath}.md`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Absolute file path for a virtual path in a scope. Callers validate the vpath first. */
|
|
58
|
+
export function physicalPath(scope: Scope, vpath: string, ctx: ExtensionContext): string {
|
|
59
|
+
return join(scopeDir(scope, ctx), ...noteFileName(vpath).split("/"));
|
|
60
|
+
}
|