@astrosheep/pi-context 0.21.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -1
- package/dist/src/dream/cli.js +117 -22
- package/dist/src/dream/doctor.js +138 -0
- package/dist/src/dream/gates.js +11 -7
- package/dist/src/dream/git.js +55 -12
- package/dist/src/dream/lock.js +78 -37
- package/dist/src/dream/runner.js +12 -2
- package/dist/src/notes/address.js +4 -4
- package/dist/src/notes/frontmatter.js +2 -2
- package/dist/src/notes/paths.js +2 -2
- package/dist/src/notes/store.js +2 -2
- package/dist/src/notes/tools.js +1 -1
- package/dist/src/prompts.js +18 -11
- package/dist/src/protocol.js +7 -6
- package/dist/src/thresholds.js +29 -2
- package/dist/test/doctor.test.js +44 -0
- package/dist/test/dream.test.js +320 -35
- package/dist/test/integration.test.js +35 -25
- package/dist/test/notes.test.js +45 -45
- package/package.json +1 -1
- package/playbook.md +4 -4
- package/src/dream/cli.ts +101 -14
- package/src/dream/doctor.ts +97 -0
- package/src/dream/gates.ts +12 -6
- package/src/dream/git.ts +59 -13
- package/src/dream/lock.ts +67 -24
- package/src/dream/runner.ts +12 -3
- package/src/notes/address.ts +4 -4
- package/src/notes/frontmatter.ts +2 -2
- package/src/notes/paths.ts +2 -2
- package/src/notes/store.ts +2 -2
- package/src/notes/tools.ts +1 -1
- package/src/prompts.ts +19 -11
- package/src/protocol.ts +7 -6
- package/src/thresholds.ts +34 -5
package/src/dream/git.ts
CHANGED
|
@@ -1,27 +1,73 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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.
|
|
6
|
+
* Outcome of one audit commit. `ok: true` always carries a real `commit` snapshot;
|
|
7
|
+
* `empty: true` only means no files changed (an empty baseline commit or no commit was
|
|
8
|
+
* needed). `ok: false` means the audit layer could not guarantee a snapshot.
|
|
11
9
|
*/
|
|
12
|
-
export
|
|
10
|
+
export type AuditResult =
|
|
11
|
+
| { ok: true; commit: string; empty: boolean }
|
|
12
|
+
| { ok: false; error: string };
|
|
13
|
+
|
|
14
|
+
/** Lock runtime artifacts are not notes and must not appear in snapshots or `git status`. */
|
|
15
|
+
const RUNTIME_IGNORE = ".dream.lock*";
|
|
16
|
+
|
|
17
|
+
/** Add the runtime-artifact pattern to the repository's local exclude, once. */
|
|
18
|
+
function ensureRuntimeIgnored(home: string): void {
|
|
19
|
+
try {
|
|
20
|
+
const exclude = join(home, ".git", "info", "exclude");
|
|
21
|
+
const current = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
|
|
22
|
+
if (current.split(/\r?\n/).includes(RUNTIME_IGNORE)) return;
|
|
23
|
+
mkdirSync(dirname(exclude), { recursive: true });
|
|
24
|
+
const prefix = current.length > 0 && !current.endsWith("\n") ? `${current}\n` : current;
|
|
25
|
+
writeFileSync(exclude, `${prefix}${RUNTIME_IGNORE}\n`);
|
|
26
|
+
} catch { /* best effort: an unignored lock only adds noise to the audit */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Git audit layer for a dream run: one commit before (baseline) and one after (dream),
|
|
31
|
+
* so the human gate reviews `git show` instead of trusting a report, and rollback is
|
|
32
|
+
* `git revert`. The caller decides how loud a failure is; this function only reports it.
|
|
33
|
+
* A clean tree on an established repository commits nothing; a repository with no HEAD
|
|
34
|
+
* gets an empty baseline commit, because an audit run with no snapshot is not a success.
|
|
35
|
+
*/
|
|
36
|
+
export function gitCommit(home: string, message: string): AuditResult {
|
|
13
37
|
try {
|
|
14
38
|
if (!existsSync(join(home, ".git"))) {
|
|
15
39
|
execFileSync("git", ["init", "-q"], { cwd: home, stdio: "ignore" });
|
|
16
40
|
}
|
|
41
|
+
ensureRuntimeIgnored(home);
|
|
17
42
|
execFileSync("git", ["add", "-A"], { cwd: home, stdio: "ignore" });
|
|
43
|
+
let clean = false;
|
|
18
44
|
try {
|
|
19
45
|
execFileSync("git", ["diff", "--cached", "--quiet"], { cwd: home, stdio: "ignore" });
|
|
20
|
-
|
|
21
|
-
} catch {
|
|
22
|
-
|
|
23
|
-
|
|
46
|
+
clean = true; // clean tree — no empty commit on an established repository
|
|
47
|
+
} catch { clean = false; }
|
|
48
|
+
const before = headCommit(home);
|
|
49
|
+
if (!clean) {
|
|
50
|
+
execFileSync("git", ["commit", "-q", "-m", message], { cwd: home, stdio: "ignore" });
|
|
51
|
+
console.log(`git: committed "${message}"`);
|
|
52
|
+
} else if (before === undefined) {
|
|
53
|
+
// A newly initialized repository has no snapshot at all; give the audit one.
|
|
54
|
+
execFileSync("git", ["commit", "-q", "--allow-empty", "-m", message], { cwd: home, stdio: "ignore" });
|
|
55
|
+
console.log(`git: committed "${message}"`);
|
|
56
|
+
}
|
|
57
|
+
const commit = headCommit(home);
|
|
58
|
+
if (commit === undefined) return { ok: false, error: "audit commit produced no snapshot (no HEAD)" };
|
|
59
|
+
return { ok: true, commit, empty: clean };
|
|
24
60
|
} catch (error) {
|
|
25
|
-
|
|
61
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
62
|
+
console.log(`git audit layer failed: ${reason}`);
|
|
63
|
+
return { ok: false, error: reason };
|
|
26
64
|
}
|
|
27
65
|
}
|
|
66
|
+
|
|
67
|
+
/** HEAD sha, or undefined when the repository has no commit yet. */
|
|
68
|
+
function headCommit(home: string): string | undefined {
|
|
69
|
+
try {
|
|
70
|
+
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: home, encoding: "utf8" }).trim();
|
|
71
|
+
return head.length > 0 ? head : undefined;
|
|
72
|
+
} catch { return undefined; }
|
|
73
|
+
}
|
package/src/dream/lock.ts
CHANGED
|
@@ -1,39 +1,82 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
3
|
|
|
3
|
-
export type LockState = {
|
|
4
|
-
|
|
4
|
+
export type LockState = {
|
|
5
|
+
path: string;
|
|
6
|
+
held: boolean;
|
|
7
|
+
reason?: string;
|
|
8
|
+
startedAt: number;
|
|
9
|
+
/** mtime of the last-run sidecar before this run took the lock; failLock restores it. */
|
|
10
|
+
priorStampMtime?: number;
|
|
11
|
+
/** Random identity written into the lock file; cleanup only removes the lock it wrote. */
|
|
12
|
+
token?: string;
|
|
13
|
+
};
|
|
5
14
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The scheduler's last-run timestamp lives in a sidecar beside the lock, never in the
|
|
17
|
+
* lock file itself: acquiring, releasing or cleaning up the lock touches only the PID
|
|
18
|
+
* marker, so lock lifecycle does not destroy the timestamp the time gate reads.
|
|
19
|
+
*/
|
|
20
|
+
export function lastRunPath(lockPath: string): string {
|
|
21
|
+
return `${lockPath}.last-run`;
|
|
9
22
|
}
|
|
10
23
|
|
|
24
|
+
function readText(path: string): string | undefined {
|
|
25
|
+
try { return readFileSync(path, "utf8"); } catch { return undefined; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stampMtime(stampPath: string): number | undefined {
|
|
29
|
+
try { return statSync(stampPath).mtimeMs; } catch { return undefined; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Cleanup ownership: only the exact marker this run wrote may be removed. The token is
|
|
34
|
+
* diagnostic and guards cleanup; it never grants permission to take an existing lock.
|
|
35
|
+
*/
|
|
36
|
+
function ownsLock(lock: LockState): boolean {
|
|
37
|
+
if (!lock.held || !lock.token) return false;
|
|
38
|
+
return readText(lock.path)?.trim() === `${process.pid} ${lock.token}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Acquire the dream lock with Git-style exclusive existence locking: one O_CREAT|O_EXCL
|
|
43
|
+
* creation. An existing path refuses acquisition regardless of its contents, PID, or age,
|
|
44
|
+
* and is never read for permission, replaced, or removed. There is no automatic stale
|
|
45
|
+
* recovery; a crash-left lock is human cleanup after confirming no dream is running.
|
|
46
|
+
*/
|
|
11
47
|
export function acquireLock(path: string): LockState {
|
|
12
48
|
const now = Date.now();
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 }; }
|
|
49
|
+
const priorStampMtime = stampMtime(lastRunPath(path));
|
|
50
|
+
const token = randomUUID();
|
|
51
|
+
try {
|
|
52
|
+
writeFileSync(path, `${process.pid} ${token}`, { flag: "wx" });
|
|
53
|
+
} catch {
|
|
54
|
+
return { path, held: false, reason: "lock gate: lock already exists", startedAt: now };
|
|
21
55
|
}
|
|
22
|
-
|
|
23
|
-
|
|
56
|
+
// Only a held lock advances the scheduler timestamp.
|
|
57
|
+
try { writeFileSync(lastRunPath(path), new Date(now).toISOString()); } catch { /* advisory */ }
|
|
58
|
+
return { path, held: true, startedAt: now, priorStampMtime, token };
|
|
24
59
|
}
|
|
25
60
|
|
|
61
|
+
/** Release only the lock this run acquired. Idempotent: repeated cleanup does nothing. */
|
|
26
62
|
export function releaseLock(lock: LockState): void {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
export function restoreMtime(path: string, mtimeMs: number): void {
|
|
32
|
-
try { utimesSync(path, new Date(), new Date(mtimeMs)); } catch { /* advisory */ }
|
|
63
|
+
if (!lock.held) return;
|
|
64
|
+
if (ownsLock(lock)) { try { unlinkSync(lock.path); } catch { /* best effort */ } }
|
|
65
|
+
lock.held = false;
|
|
33
66
|
}
|
|
34
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A failed run must not advance the scheduler: restore the previous timestamp, or remove
|
|
70
|
+
* the one this run wrote when there was none. Only this run's own marker is removed, and
|
|
71
|
+
* the state is marked released so a later cleanup attempt is harmless.
|
|
72
|
+
*/
|
|
35
73
|
export function failLock(lock: LockState): void {
|
|
36
74
|
if (!lock.held) return;
|
|
37
|
-
if (
|
|
38
|
-
|
|
75
|
+
if (ownsLock(lock)) {
|
|
76
|
+
const stampPath = lastRunPath(lock.path);
|
|
77
|
+
if (lock.priorStampMtime === undefined) { try { unlinkSync(stampPath); } catch { /* best effort */ } }
|
|
78
|
+
else { try { utimesSync(stampPath, new Date(), new Date(lock.priorStampMtime)); } catch { /* best effort */ } }
|
|
79
|
+
try { unlinkSync(lock.path); } catch { /* best effort */ }
|
|
80
|
+
}
|
|
81
|
+
lock.held = false;
|
|
39
82
|
}
|
package/src/dream/runner.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { Api, Model } from "@earendil-works/pi-ai";
|
|
|
6
6
|
import { contentText } from "../history.js";
|
|
7
7
|
|
|
8
8
|
export type DreamWrite = { tool: "write" | "edit"; path: string };
|
|
9
|
-
export type DreamResult = { report: string; writes: DreamWrite[] };
|
|
9
|
+
export type DreamResult = { report: string; writes: DreamWrite[]; error?: string };
|
|
10
10
|
export type DreamerSession = Pick<AgentSession, "prompt" | "subscribe" | "dispose">;
|
|
11
11
|
export type DreamerSessionFactory = (options: { cwd: string; modelPattern?: string; tools: string[] }) => Promise<DreamerSession>;
|
|
12
12
|
export const DREAMER_TOOLS = ["read", "grep", "find", "ls", "write", "edit"];
|
|
@@ -85,6 +85,11 @@ export const defaultDreamerSessionFactory: DreamerSessionFactory = async ({ cwd,
|
|
|
85
85
|
return session;
|
|
86
86
|
};
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Run one dream turn. A dreamer failure is returned as `error` together with the partial
|
|
90
|
+
* writes observed so far, so the caller can record partial state instead of losing it;
|
|
91
|
+
* only a failure to even start the session throws.
|
|
92
|
+
*/
|
|
88
93
|
export async function runDreamer(playbook: string, cwd: string, options: { modelPattern?: string; sessionFactory?: DreamerSessionFactory } = {}): Promise<DreamResult> {
|
|
89
94
|
const session = await (options.sessionFactory ?? defaultDreamerSessionFactory)({ cwd, modelPattern: options.modelPattern, tools: DREAMER_TOOLS });
|
|
90
95
|
let answer = "";
|
|
@@ -99,8 +104,12 @@ export async function runDreamer(playbook: string, cwd: string, options: { model
|
|
|
99
104
|
answer = contentText(event.message.content);
|
|
100
105
|
});
|
|
101
106
|
try {
|
|
102
|
-
|
|
103
|
-
|
|
107
|
+
try {
|
|
108
|
+
await session.prompt(playbook);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
return { report: answer, writes, error: error instanceof Error ? error.message : String(error) };
|
|
111
|
+
}
|
|
112
|
+
if (providerError) return { report: answer, writes, error: `dreamer failed: ${providerError}` };
|
|
104
113
|
return { report: answer, writes };
|
|
105
114
|
} finally {
|
|
106
115
|
unsubscribe?.();
|
package/src/notes/address.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Scope } from "./paths.js";
|
|
|
3
3
|
|
|
4
4
|
export type NoteAddress = { scope: Scope; path: string };
|
|
5
5
|
|
|
6
|
-
const ADDRESS_FORMS = "legal prefixes are @project/ and @
|
|
6
|
+
const ADDRESS_FORMS = "legal prefixes are @project/ and @personal/; bare names are the session home";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Decode the one public note address into its physical home and virtual path. This is a
|
|
@@ -16,9 +16,9 @@ export function assertAddress(value: unknown): NoteAddress {
|
|
|
16
16
|
if (value.startsWith("@project/")) {
|
|
17
17
|
scope = "project";
|
|
18
18
|
path = value.slice("@project/".length);
|
|
19
|
-
} else if (value.startsWith("@
|
|
20
|
-
scope = "
|
|
21
|
-
path = value.slice("@
|
|
19
|
+
} else if (value.startsWith("@personal/")) {
|
|
20
|
+
scope = "personal";
|
|
21
|
+
path = value.slice("@personal/".length);
|
|
22
22
|
} else if (value.startsWith("@")) {
|
|
23
23
|
throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
|
|
24
24
|
}
|
package/src/notes/frontmatter.ts
CHANGED
|
@@ -25,7 +25,7 @@ export type NoteMeta = {
|
|
|
25
25
|
[key: string]: unknown;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
const SCOPES: readonly Scope[] = ["session", "project", "
|
|
28
|
+
const SCOPES: readonly Scope[] = ["session", "project", "personal"];
|
|
29
29
|
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;
|
|
@@ -112,7 +112,7 @@ function parseFrontmatter(raw: string): { fields: Record<string, unknown>; body:
|
|
|
112
112
|
export function parseNote(raw: string, now = Date.now()): { meta: NoteMeta; body: string } {
|
|
113
113
|
const { fields, body } = parseFrontmatter(raw);
|
|
114
114
|
const meta = { ...fields } as Record<string, unknown>;
|
|
115
|
-
meta.scope = isScope(meta.scope) ? meta.scope : "
|
|
115
|
+
meta.scope = isScope(meta.scope) ? meta.scope : "personal";
|
|
116
116
|
meta.origin = isOrigin(meta.origin) ? meta.origin : "self";
|
|
117
117
|
meta.status = isStatus(meta.status) ? meta.status : "active";
|
|
118
118
|
meta.stale = meta.stale === true;
|
package/src/notes/paths.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
|
-
export type Scope = "session" | "project" | "
|
|
7
|
+
export type Scope = "session" | "project" | "personal";
|
|
8
8
|
|
|
9
9
|
/** Physical home of the on-disk note store: $PI_NOTES_HOME or ~/.agents/notes. */
|
|
10
10
|
export function notesRoot(): string {
|
|
@@ -46,7 +46,7 @@ function sessionId(ctx: ExtensionContext): string {
|
|
|
46
46
|
|
|
47
47
|
/** Absolute directory holding every note of one scope. */
|
|
48
48
|
export function scopeDir(scope: Scope, ctx: ExtensionContext): string {
|
|
49
|
-
if (scope === "
|
|
49
|
+
if (scope === "personal") return join(notesRoot(), "personal");
|
|
50
50
|
if (scope === "project") return join(notesRoot(), "project", projectKey(ctx.cwd));
|
|
51
51
|
return join(sessionHomesRoot(), sessionId(ctx));
|
|
52
52
|
}
|
package/src/notes/store.ts
CHANGED
|
@@ -32,10 +32,10 @@ export type NoteRow = { address: string; scope: Scope; path: string; meta: NoteM
|
|
|
32
32
|
export type NoteMatch = { line: number; text: string; offsetChars: number };
|
|
33
33
|
export type NoteSearchRow = { address: string; scope: Scope; path: string; meta: NoteMeta; matches: NoteMatch[] };
|
|
34
34
|
|
|
35
|
-
const SCOPE_ORDER: readonly Scope[] = ["session", "project", "
|
|
35
|
+
const SCOPE_ORDER: readonly Scope[] = ["session", "project", "personal"];
|
|
36
36
|
|
|
37
37
|
function assertScope(value: unknown): Scope {
|
|
38
|
-
if (!isScope(value)) throw new NoteError("invalid_scope", `scope must be one of session, project,
|
|
38
|
+
if (!isScope(value)) throw new NoteError("invalid_scope", `scope must be one of session, project, personal (got ${JSON.stringify(value)})`);
|
|
39
39
|
return value;
|
|
40
40
|
}
|
|
41
41
|
|
package/src/notes/tools.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from
|
|
|
10
10
|
const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
|
|
11
11
|
description: "Where the note's content came from. user: written or dictated by the human. self: written by you, the agent (default). external: anything else — third-party text, tool output, fetched material.",
|
|
12
12
|
}));
|
|
13
|
-
const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project's home, and `@
|
|
13
|
+
const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project's home, and `@personal/<vpath>` for the human's cross-project home. `@` means leaving home. Any other `@` prefix, or `@` inside a vpath, is a hard error: legal prefixes are `@project/` and `@personal/`; bare names are the session home. There is no cross-home fallback. Paths reject `..`, absolute paths, and backslashes.";
|
|
14
14
|
|
|
15
15
|
function wireMeta(meta: NoteMeta): Record<string, unknown> {
|
|
16
16
|
return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
|
package/src/prompts.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { historyFromSession } from "./history.js";
|
|
3
|
-
import { localIso } from "./notes/model.js";
|
|
4
3
|
import { listNotes } from "./notes/store.js";
|
|
5
|
-
import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG,
|
|
4
|
+
import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, POCKET_PERSONAL_LIMIT, POCKET_PROJECT_LIMIT, POCKET_SESSION_LIMIT, RESET_SUMMARY, PROTOCOL_BLOCK, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG } from "./protocol.js";
|
|
6
5
|
|
|
7
6
|
/** Codex-style <context_window> identity block: agent name and first/current/previous window ids only. */
|
|
8
7
|
function identityBlock(agentName: string, firstWindowId: string, currentWindowId: string, previousWindowId?: string): string {
|
|
@@ -15,36 +14,45 @@ function identityBlock(agentName: string, firstWindowId: string, currentWindowId
|
|
|
15
14
|
return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
|
|
16
15
|
}
|
|
17
16
|
|
|
17
|
+
function relativeTime(timestamp: number, now: number): string {
|
|
18
|
+
const seconds = Math.trunc((timestamp - now) / 1000);
|
|
19
|
+
const [unit, size] = ([["d", 86400], ["h", 3600], ["m", 60], ["s", 1]] as const)
|
|
20
|
+
.find(([unit, size]) => Math.abs(seconds) >= size || unit === "s")!;
|
|
21
|
+
const amount = `${Math.abs(Math.trunc(seconds / size))}${unit}`;
|
|
22
|
+
return seconds > 0 ? `in ${amount}` : `${amount} ago`;
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
/**
|
|
19
|
-
* Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the
|
|
26
|
+
* Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the personal and
|
|
20
27
|
* project homes are both injected, broadest first; stale maps are skipped per home, and the
|
|
21
28
|
* session home is never peeked — a session MAP.md is an ordinary note. The pocket then lists
|
|
22
29
|
* recent fresh notes under per-home quotas (POCKET_SESSION_LIMIT / POCKET_PROJECT_LIMIT /
|
|
23
|
-
*
|
|
24
|
-
* each: address, line count, UTF-8 byte count,
|
|
30
|
+
* POCKET_PERSONAL_LIMIT), most-recently-updated first within each home, one metadata line
|
|
31
|
+
* each: address, line count, UTF-8 byte count, relative update time at window open. Bodies never render
|
|
25
32
|
* in the pocket; stale notes are excluded; MAP.md itself never takes a pocket seat.
|
|
26
33
|
*/
|
|
27
34
|
function notesIndex(ctx: ExtensionContext): string {
|
|
28
35
|
const sections: string[] = [];
|
|
29
36
|
// Map residency ("地图在场"): scope-native maps, both fresh ones injected broadest-first.
|
|
30
37
|
// A session MAP.md is an ordinary note, never resident; stale maps skip independently.
|
|
31
|
-
for (const scope of ["
|
|
38
|
+
for (const scope of ["personal", "project"] as const) {
|
|
32
39
|
const toc = listNotes(ctx, { scope }).find((row) => row.path === "MAP.md");
|
|
33
40
|
if (toc && !toc.meta.stale) {
|
|
34
41
|
if (toc.body.length > 0) sections.push(toc.body);
|
|
35
42
|
}
|
|
36
43
|
}
|
|
37
44
|
// listNotes is most-recently-updated first within each home. Per-home quotas keep session
|
|
38
|
-
// churn from evicting project or
|
|
45
|
+
// churn from evicting project or personal notes; maps never take pocket seats.
|
|
39
46
|
const recentNotes = [
|
|
40
47
|
...listNotes(ctx, { scope: "session" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_SESSION_LIMIT),
|
|
41
48
|
...listNotes(ctx, { scope: "project" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PROJECT_LIMIT),
|
|
42
|
-
...listNotes(ctx, { scope: "
|
|
49
|
+
...listNotes(ctx, { scope: "personal" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PERSONAL_LIMIT),
|
|
43
50
|
];
|
|
44
51
|
if (recentNotes.length > 0) {
|
|
45
|
-
const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by home, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from this project, ${
|
|
52
|
+
const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by home, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from this project, ${POCKET_PERSONAL_LIMIT} from personal). A note's content never appears here, so its name has to say what the note is about:`];
|
|
53
|
+
const now = Date.now();
|
|
46
54
|
for (const row of recentNotes) {
|
|
47
|
-
lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${
|
|
55
|
+
lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updated_at, now)})`);
|
|
48
56
|
}
|
|
49
57
|
sections.push(lines.join("\n"));
|
|
50
58
|
}
|
|
@@ -52,7 +60,7 @@ function notesIndex(ctx: ExtensionContext): string {
|
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
function notesHomeBlock(): string {
|
|
55
|
-
return "Notes_* addresses have three homes: bare <vpath> is this session, @project/<vpath> is this project, and @
|
|
63
|
+
return "Notes_* addresses have three homes: bare <vpath> is this session, @project/<vpath> is this project, and @personal/<vpath> is the human's cross-project home. @ means leaving home; there is no cross-home fallback. Any other note is a plain file — use the file tools.";
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
/**
|
package/src/protocol.ts
CHANGED
|
@@ -9,7 +9,7 @@ export const RESET_V2 = "reset-v2";
|
|
|
9
9
|
export const MAX_NOTE_BYTES = 1_000_000;
|
|
10
10
|
export const POCKET_SESSION_LIMIT = 5;
|
|
11
11
|
export const POCKET_PROJECT_LIMIT = 2;
|
|
12
|
-
export const
|
|
12
|
+
export const POCKET_PERSONAL_LIMIT = 2;
|
|
13
13
|
// Write-time cap on a virtual note path. Deliberately NOT enforced by assertVirtualPath:
|
|
14
14
|
// notesFromSession replays already-persisted operations, which must keep loading sessions
|
|
15
15
|
// that contain a longer legacy path. Reads and replay stay un-capped.
|
|
@@ -21,6 +21,8 @@ export const CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG = "</context_window_protocol>";
|
|
|
21
21
|
export const GUIDANCE_OPEN_TAG = "<context_window_guidance>";
|
|
22
22
|
export const GUIDANCE_CLOSE_TAG = "</context_window_guidance>";
|
|
23
23
|
export const PI_CONTEXT_SETTINGS_KEY = "pi-context";
|
|
24
|
+
/** Nested under "pi-context": the default dreamer model pattern, overridden by CLI --dreamer. */
|
|
25
|
+
export const PI_CONTEXT_DREAMER_KEY = "dreamer";
|
|
24
26
|
export const DEFAULT_RESERVE_TOKENS = 16_384;
|
|
25
27
|
export const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
|
|
26
28
|
/**
|
|
@@ -49,11 +51,10 @@ Use get_context_remaining to see how much of the window is left. When it runs ou
|
|
|
49
51
|
|
|
50
52
|
If <context_window> lists a Previous context window id, a reset just happened and the old conversation is not included. Read your note checkpoint first, then recover details through history_*: history_read directly when you know the window and item IDs, history_list or history_search to find them when you don't.
|
|
51
53
|
|
|
52
|
-
Your notes live in three homes: this session (bare names), this
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
@
|
|
56
|
-
@global notes travel with you. Every window. Every conversation. Every trip. So before you drop anything in there, ask yourself: does this deserve to stare you in the face every single time you talk to the human? No? Then keep your weird junk OUT.
|
|
54
|
+
Your notes live in three homes: this session (bare names), this project (@project/<vpath>), the human across projects (@personal/<vpath>). @ means leaving home — and homes don't visit each other: there is no cross-home fallback.
|
|
55
|
+
Session notes belong to this trip — the goal, the progress, the loose ends. The next window of THIS trip wakes to them; once the trip is over, nobody does.
|
|
56
|
+
@project notes hold facts about this project — architecture, conventions, workflows, deployment and environment details — for whoever works here next.
|
|
57
|
+
@personal notes hold the human's durable preferences and standing rules, plus lessons that apply across projects. Duration does not make a note personal; its stated scope must already be broader than the project or conversation at hand. When the intended scope is unclear, keep the note in the narrowest stated scope rather than widening it.
|
|
57
58
|
${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
|
|
58
59
|
|
|
59
60
|
export const WARNING_PROMPT =
|
package/src/thresholds.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { SettingsManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS } from "./protocol.js";
|
|
2
|
+
import { PI_CONTEXT_SETTINGS_KEY, PI_CONTEXT_DREAMER_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS } from "./protocol.js";
|
|
3
3
|
|
|
4
4
|
export type ResolvedThresholds = { reminder: number; reserve: number; warning: number };
|
|
5
|
-
type
|
|
5
|
+
type PiContextSettings = { reminderMarginTokens?: unknown; dreamer?: unknown };
|
|
6
6
|
|
|
7
7
|
function isSettingsObject(value: unknown): value is Record<string, unknown> {
|
|
8
8
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -16,9 +16,9 @@ function piContextSettings(settings: unknown): Record<string, unknown> {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/** Merge the global and project "pi-context" objects per key; project wins, mirroring Pi's deep merge. */
|
|
19
|
-
export function mergePiContextSettings(globalSettings: unknown, projectSettings: unknown):
|
|
19
|
+
export function mergePiContextSettings(globalSettings: unknown, projectSettings: unknown): PiContextSettings {
|
|
20
20
|
const merged = { ...piContextSettings(globalSettings), ...piContextSettings(projectSettings) };
|
|
21
|
-
return { reminderMarginTokens: merged.reminderMarginTokens };
|
|
21
|
+
return { reminderMarginTokens: merged.reminderMarginTokens, dreamer: merged.dreamer };
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/** A margin is usable only as a positive integer; anything else is ignored. */
|
|
@@ -33,7 +33,7 @@ function validMargin(raw: unknown): number | undefined {
|
|
|
33
33
|
* An invalid margin degrades to the default and reports one warning. Pi's automatic
|
|
34
34
|
* threshold/overflow compaction itself resets immediately, with no model turn.
|
|
35
35
|
*/
|
|
36
|
-
export function deriveThresholds(reserveTokens: number, margins:
|
|
36
|
+
export function deriveThresholds(reserveTokens: number, margins: PiContextSettings): { thresholds: ResolvedThresholds; warnings: string[] } {
|
|
37
37
|
const warnings: string[] = [];
|
|
38
38
|
const reminderKey = `${PI_CONTEXT_SETTINGS_KEY}.reminderMarginTokens`;
|
|
39
39
|
let reminderMargin: number;
|
|
@@ -48,6 +48,35 @@ export function deriveThresholds(reserveTokens: number, margins: PiContextMargin
|
|
|
48
48
|
return { thresholds: { reminder: reserveTokens + reminderMargin, reserve: reserveTokens, warning: reserveTokens + WARNING_RUNWAY_TOKENS }, warnings };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
export type DreamerSetting = { pattern?: string; warnings: string[] };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `pi-context.dreamer` is a non-empty model pattern. Anything else present is ignored
|
|
55
|
+
* with one warning; absent means no configured pattern, so the automatic model applies.
|
|
56
|
+
*/
|
|
57
|
+
export function deriveDreamer(settings: PiContextSettings): DreamerSetting {
|
|
58
|
+
const raw = settings.dreamer;
|
|
59
|
+
if (raw === undefined) return { warnings: [] };
|
|
60
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
61
|
+
return { warnings: [`pi-context: ${PI_CONTEXT_SETTINGS_KEY}.${PI_CONTEXT_DREAMER_KEY} must be a non-empty string; ignoring it.`] };
|
|
62
|
+
}
|
|
63
|
+
return { pattern: raw.trim(), warnings: [] };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the configurable dreamer model from Pi settings for a CLI invocation: global
|
|
68
|
+
* `~/.pi/agent/settings.json` merged with the project's `.pi/settings.json`, project
|
|
69
|
+
* values winning per key. A settings read failure degrades to no pattern with one warning.
|
|
70
|
+
*/
|
|
71
|
+
export function readDreamerSettings(cwd = process.cwd()): DreamerSetting {
|
|
72
|
+
try {
|
|
73
|
+
const settingsManager = SettingsManager.create(cwd, undefined, { projectTrusted: true });
|
|
74
|
+
return deriveDreamer(mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()));
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return { warnings: [`pi-context: could not read settings; using the automatic dreamer model (${String(error)}).`] };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
51
80
|
let cached: ResolvedThresholds | undefined;
|
|
52
81
|
|
|
53
82
|
/**
|