pi-rebrief 0.0.0 → 0.1.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.
Files changed (4) hide show
  1. package/README.md +34 -19
  2. package/format.ts +117 -0
  3. package/index.ts +107 -92
  4. package/package.json +3 -1
package/README.md CHANGED
@@ -1,14 +1,17 @@
1
1
  # pi-rebrief
2
2
 
3
- [![Known Vulnerabilities](https://snyk.io/test/github/asakin/pi-rebrief/badge.svg)](https://snyk.io/test/github/asakin/pi-rebrief)
3
+ ![npm](https://img.shields.io/npm/v/pi-rebrief) ![CI](https://github.com/asakin/pi-rebrief/actions/workflows/ci.yml/badge.svg) ![license](https://img.shields.io/npm/l/pi-rebrief) ![Known Vulnerabilities](https://snyk.io/test/github/asakin/pi-rebrief/badge.svg)
4
4
 
5
- A [Pi](https://pi.dev) extension that adds a `/rebrief` command. It reloads only the
6
- project context files, including `AGENTS.md` and `CLAUDE.md`, into a running session, and
7
- tells you which files changed. It loads them with the same function Pi uses, so it picks up
8
- whatever files your Pi version discovers.
5
+ A [Pi](https://pi.dev) extension that adds a `/rebrief` command. It reloads the project
6
+ context files, including `AGENTS.md` and `CLAUDE.md`, into the running session and tells you
7
+ which files have changed.
9
8
 
10
- Pi's own `/reload` also re-reads context files, but along with everything else: settings,
11
- extensions, MCP servers, skills and themes. `/rebrief` leaves all of that running.
9
+ It also flags in the status bar when those files change on disk, for example after a `git pull`
10
+ or a branch switch.
11
+
12
+ `/reload` refreshes the whole session: settings, extensions, MCP servers, skills, themes, and
13
+ context files. `/rebrief` is for when only the context files changed, including while a turn
14
+ is in progress.
12
15
 
13
16
  ## Install
14
17
 
@@ -16,30 +19,42 @@ extensions, MCP servers, skills and themes. `/rebrief` leaves all of that runnin
16
19
  pi install npm:pi-rebrief
17
20
  ```
18
21
 
19
- Then run `/reload` in an active session, or try it for a single session without installing:
20
-
21
- ```bash
22
- pi -e npm:pi-rebrief
23
- ```
22
+ Restart or `/reload` your session to use.
24
23
 
25
24
  ## Usage
26
25
 
27
- Edit a context file, then run:
26
+ When a context file changes on disk, the status bar says so:
28
27
 
28
+ ```text
29
+ ● pi-rebrief: AGENTS.md changed on disk since it was loaded. Run /rebrief to load the new version.
29
30
  ```
31
+
32
+ Nothing is reloaded until you run:
33
+
34
+ ```text
30
35
  /rebrief
31
36
  ```
32
37
 
33
- The next turn sees the new instructions, and so does every turn after it. `/rebrief` reports
38
+ Your next message runs with the new instructions, and so does everything after it. `/rebrief` reports
34
39
  what it found, for example:
35
40
 
36
- - `Reloaded context files: changed AGENTS.md, added sub/AGENTS.md.`
37
- - `Context files unchanged (2 context files).`
41
+ ```text
42
+ pi-rebrief: reloaded context files.
43
+ changed: AGENTS.md
44
+ added: sub/AGENTS.md
45
+ They apply from your next message.
46
+ ```
47
+
48
+ If you run it while a turn is in progress, the rest of that turn will use the new
49
+ instructions.
38
50
 
39
- If Pi was started with `--no-context-files`, `/rebrief` does nothing.
51
+ ## Examples
40
52
 
41
- Pair it with `/tree` to rewind a couple of turns, tighten an instruction in `AGENTS.md`,
42
- run `/rebrief`, and continue from before the derail.
53
+ - Pair it with `/tree` to retry an earlier prompt under new instructions.
54
+ - The agent derails. Rewind with `/tree` to the turn before it did, tighten the instruction
55
+ it missed in `AGENTS.md`, run `/rebrief`, and continue from there.
56
+ - You `git pull` mid-task and a teammate's commit changed `AGENTS.md`. The status bar flags
57
+ it; run `/rebrief` and keep going in the same session.
43
58
 
44
59
  ## License
45
60
 
package/format.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, relative, sep } from "node:path";
3
+
4
+ type ContextFile = { path: string; content: string };
5
+
6
+ // Every message starts with this so a user who forgot the extension knows where it comes from
7
+ const PREFIX = "pi-rebrief:";
8
+
9
+ // How many file names to show before falling back to "and N more"
10
+ const MAX_LISTED = 3;
11
+
12
+ // Show a path relative to cwd when inside it or in a parent folder, else home-relative, else absolute
13
+ function displayPath(path: string, cwd: string, home: string): string {
14
+ const rel = relative(cwd, path);
15
+ if (rel && !isAbsolute(rel) && (!rel.startsWith("..") || /^(\.\.[/\\])+[^/\\]+$/.test(rel))) {
16
+ return rel;
17
+ }
18
+ if (home && path.startsWith(home + sep)) {
19
+ return `~${path.slice(home.length)}`;
20
+ }
21
+ return path;
22
+ }
23
+
24
+ interface Changes {
25
+ changed: string[];
26
+ added: string[];
27
+ removed: string[];
28
+ }
29
+
30
+ function diffFiles(previous: readonly ContextFile[], fresh: readonly ContextFile[], cwd: string, home: string): Changes {
31
+ const before = new Map(previous.map((file) => [file.path, file.content]));
32
+ const after = new Set(fresh.map((file) => file.path));
33
+ const changes: Changes = { changed: [], added: [], removed: [] };
34
+ for (const file of fresh) {
35
+ const old = before.get(file.path);
36
+ if (old === undefined) {
37
+ changes.added.push(displayPath(file.path, cwd, home));
38
+ } else if (old !== file.content) {
39
+ changes.changed.push(displayPath(file.path, cwd, home));
40
+ }
41
+ }
42
+ for (const file of previous) {
43
+ if (!after.has(file.path)) {
44
+ changes.removed.push(displayPath(file.path, cwd, home));
45
+ }
46
+ }
47
+ return changes;
48
+ }
49
+
50
+ function fileCount(count: number): string {
51
+ return `${count} file${count === 1 ? "" : "s"}`;
52
+ }
53
+
54
+ // The /rebrief summary: one line per kind of change, capped at MAX_LISTED names
55
+ export function describeChanges(
56
+ previous: readonly ContextFile[],
57
+ fresh: readonly ContextFile[],
58
+ cwd: string,
59
+ home: string = homedir(),
60
+ ): string[] {
61
+ const changes = diffFiles(previous, fresh, cwd, home);
62
+ const lines: string[] = [];
63
+ let remaining = MAX_LISTED;
64
+ for (const kind of ["changed", "added", "removed"] as const) {
65
+ const shown = changes[kind].slice(0, remaining);
66
+ if (shown.length > 0) {
67
+ lines.push(` ${`${kind}:`.padEnd(8)} ${shown.join(", ")}`);
68
+ remaining -= shown.length;
69
+ }
70
+ }
71
+ const total = changes.changed.length + changes.added.length + changes.removed.length;
72
+ if (total === 0) {
73
+ lines.push(" order changed");
74
+ } else if (total > MAX_LISTED) {
75
+ lines.push(` and ${total - MAX_LISTED} more`);
76
+ }
77
+ return lines;
78
+ }
79
+
80
+ export function formatReloadedNotice(
81
+ previous: readonly ContextFile[],
82
+ files: readonly ContextFile[],
83
+ cwd: string,
84
+ running: boolean,
85
+ ): string {
86
+ const when = running
87
+ ? "They apply to the current turn, starting with the agent's next step."
88
+ : "They apply from your next message.";
89
+ return [`${PREFIX} reloaded context files.`, ...describeChanges(previous, files, cwd), when].join("\n");
90
+ }
91
+
92
+ export function formatUnchangedNotice(count: number): string {
93
+ return `${PREFIX} context files are unchanged (${fileCount(count)}). Nothing to reload.`;
94
+ }
95
+
96
+ export function formatDisabledNotice(): string {
97
+ return `${PREFIX} Pi was started with --no-context-files, so there are no context files to reload.`;
98
+ }
99
+
100
+ // Status bar text when the files on disk differ from what the session loaded
101
+ export function formatStaleStatus(
102
+ loaded: readonly ContextFile[],
103
+ onDisk: readonly ContextFile[],
104
+ cwd: string,
105
+ home: string = homedir(),
106
+ ): string {
107
+ const changes = diffFiles(loaded, onDisk, cwd, home);
108
+ const names = [...changes.changed, ...changes.added, ...changes.removed];
109
+ if (names.length === 1) {
110
+ return `● ${PREFIX} ${names[0]} changed on disk since it was loaded. Run /rebrief to load the new version.`;
111
+ }
112
+ const listed = names.slice(0, MAX_LISTED).join(", ");
113
+ const more = names.length > MAX_LISTED ? `, and ${names.length - MAX_LISTED} more` : "";
114
+ const which = names.length > 0 ? ` (${listed}${more})` : "";
115
+ const count = names.length > 0 ? `${names.length} context files` : "context files";
116
+ return `● ${PREFIX} ${count} changed on disk since they were loaded${which}. Run /rebrief to load them.`;
117
+ }
package/index.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import type {
2
2
  ExtensionAPI,
3
3
  ExtensionCommandContext,
4
+ ExtensionUIContext,
4
5
  } from "@earendil-works/pi-coding-agent";
5
6
  import { getAgentDir, loadProjectContextFiles } from "@earendil-works/pi-coding-agent";
6
- import { homedir } from "node:os";
7
- import { isAbsolute, relative, sep } from "node:path";
7
+ import { getCurrentSystemMessage } from "@earendil-works/pi-ai";
8
+ import { formatDisabledNotice, formatReloadedNotice, formatStaleStatus, formatUnchangedNotice } from "./format.ts";
9
+
10
+ export { describeChanges, formatStaleStatus } from "./format.ts";
8
11
 
9
12
  export interface ContextFile {
10
13
  path: string;
@@ -12,142 +15,154 @@ export interface ContextFile {
12
15
  }
13
16
 
14
17
  export interface RebriefDeps {
15
- getAgentDir?: () => string;
16
18
  loadProjectContextFiles?: (options: { cwd: string; agentDir: string }) => ContextFile[];
17
19
  contextFilesDisabled?: () => boolean;
20
+ startTimer?: (tick: () => void, ms: number) => () => void;
18
21
  }
19
22
 
23
+ const STATUS_KEY = "pi-rebrief";
24
+ const POLL_MS = 3000; // How often to check for context file changes
25
+
20
26
  // Check if Pi was started with --no-context-files (-nc); extensions can't read built-in flags
21
27
  export function contextFilesDisabledByArgv(argv: readonly string[]): boolean {
22
28
  return argv.includes("--no-context-files") || argv.includes("-nc");
23
29
  }
24
30
 
25
- // Check if two sets of context files are the same
26
31
  export function sameContextFiles(a: readonly ContextFile[], b: readonly ContextFile[]): boolean {
27
- if (a.length !== b.length) return false;
32
+ if (a.length !== b.length) {
33
+ return false;
34
+ }
28
35
  for (let i = 0; i < a.length; i++) {
29
- const left = a[i]!;
30
- const right = b[i]!;
31
- if (left.path !== right.path || left.content !== right.content) return false;
36
+ if (a[i]!.path !== b[i]!.path || a[i]!.content !== b[i]!.content) {
37
+ return false;
38
+ }
32
39
  }
33
40
  return true;
34
41
  }
35
42
 
36
- // Show a path relative to cwd when inside it, else home-relative, else absolute
37
- function displayPath(path: string, cwd: string, home: string): string {
38
- const rel = relative(cwd, path);
39
- if (rel && !rel.startsWith("..") && !isAbsolute(rel)) return rel;
40
- if (home && path.startsWith(home + sep)) return `~${path.slice(home.length)}`;
41
- return path;
42
- }
43
-
44
- const MAX_LISTED = 3;
45
-
46
- // Summarize which context files changed, were added, or were removed
47
- export function describeChanges(
48
- previous: readonly ContextFile[],
49
- fresh: readonly ContextFile[],
50
- cwd: string,
51
- home: string = homedir(),
52
- ): string {
53
- const before = new Map(previous.map((file) => [file.path, file.content]));
54
- const after = new Set(fresh.map((file) => file.path));
55
- const entries: string[] = [];
56
- for (const file of fresh) {
57
- const old = before.get(file.path);
58
- if (old === undefined) entries.push(`added ${displayPath(file.path, cwd, home)}`);
59
- else if (old !== file.content) entries.push(`changed ${displayPath(file.path, cwd, home)}`);
43
+ // Render the project_context prompt section the way Pi does (Pi does not export its renderer)
44
+ export function renderProjectContext(files: readonly ContextFile[]): string | null {
45
+ if (files.length === 0) {
46
+ return null;
60
47
  }
61
- for (const file of previous) {
62
- if (!after.has(file.path)) entries.push(`removed ${displayPath(file.path, cwd, home)}`);
63
- }
64
- if (entries.length === 0) return "order changed";
65
-
66
- // "changed a, b" instead of "changed a, changed b"
67
- const merged: string[] = [];
68
- let lastVerb = "";
69
- for (const entry of entries.slice(0, MAX_LISTED)) {
70
- const [verb] = entry.split(" ", 1);
71
- merged.push(verb === lastVerb ? entry.slice(verb!.length + 1) : entry);
72
- lastVerb = verb!;
73
- }
74
- const more = entries.length - MAX_LISTED;
75
- return more > 0 ? `${merged.join(", ")}, and ${more} more` : merged.join(", ");
48
+ const body = [
49
+ "Project-specific instructions and guidelines:",
50
+ ...files.map(({ path, content }) => `<project_instructions path="${path}">\n${content}\n</project_instructions>`),
51
+ ].join("\n\n");
52
+ return `<project_context>\n${body}\n</project_context>`;
76
53
  }
77
54
 
78
- // Format the number of context files into a human-readable string
79
- function fileCountLabel(count: number): string {
80
- return `${count} context file${count === 1 ? "" : "s"}`;
55
+ function startInterval(tick: () => void, ms: number): () => void {
56
+ const timer = setInterval(tick, ms);
57
+ timer.unref?.();
58
+ return () => clearInterval(timer);
81
59
  }
82
60
 
83
- // Clone the context files
84
- function cloneContextFiles(files: readonly ContextFile[]): ContextFile[] {
85
- return files.map((file) => ({ path: file.path, content: file.content }));
86
- }
87
-
88
- // Create the rebrief extension
89
61
  export function createRebriefExtension(deps: RebriefDeps = {}) {
90
- const resolveAgentDir = deps.getAgentDir ?? getAgentDir;
91
62
  const loadContextFiles = deps.loadProjectContextFiles ?? loadProjectContextFiles;
92
63
  const contextFilesDisabled = deps.contextFilesDisabled ?? (() => contextFilesDisabledByArgv(process.argv));
64
+ const startTimer = deps.startTimer ?? startInterval;
93
65
 
94
66
  return function piRebrief(pi: ExtensionAPI) {
95
- let rebriefed: { cwd: string; files: ContextFile[] } | undefined;
67
+ // The context files the session has
68
+ let files: ContextFile[] = [];
69
+ // The user ran /rebrief: stamp `files` onto every run
70
+ let pinned = false;
71
+ // A /rebrief issued while a run was in progress; anchored after the newest message of the next request
72
+ let pending: { anchor?: number; at: number } | undefined;
73
+ let stopTimer: (() => void) | undefined;
74
+ let status: string | undefined;
75
+
76
+ const load = (cwd: string) => loadContextFiles({ cwd, agentDir: getAgentDir() });
77
+
78
+ const showStatus = (ui: ExtensionUIContext | undefined, text: string | undefined) => {
79
+ const next = text === undefined ? undefined : ui?.theme.fg("warning", text);
80
+ if (!ui || next === status) {
81
+ return;
82
+ }
83
+ status = next;
84
+ ui.setStatus(STATUS_KEY, next);
85
+ };
86
+
87
+ pi.on("session_start", (_event, ctx) => {
88
+ pinned = false;
89
+ pending = undefined;
90
+ status = undefined;
91
+ stopTimer?.();
92
+ stopTimer = undefined;
93
+ if (contextFilesDisabled()) {
94
+ return;
95
+ }
96
+ const ui = ctx.hasUI ? ctx.ui : undefined;
97
+ files = load(ctx.cwd);
98
+ stopTimer = startTimer(() => {
99
+ const onDisk = load(ctx.cwd);
100
+ showStatus(ui, sameContextFiles(files, onDisk) ? undefined : formatStaleStatus(files, onDisk, ctx.cwd));
101
+ }, POLL_MS);
102
+ });
96
103
 
97
- pi.on("session_start", () => {
98
- rebriefed = undefined;
104
+ pi.on("session_shutdown", () => {
105
+ stopTimer?.();
106
+ stopTimer = undefined;
99
107
  });
100
108
 
101
109
  pi.registerCommand("rebrief", {
102
110
  description: "Reload only context files (lighter than /reload)",
103
111
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
104
- if (contextFilesDisabled()) {
112
+ const notify = (msg: string, level: "info" | "warning") => {
105
113
  if (ctx.hasUI) {
106
- ctx.ui.notify("Context files are disabled (--no-context-files) — nothing to rebrief.", "warning");
114
+ ctx.ui.notify(msg, level);
107
115
  }
108
- return;
109
- }
116
+ };
110
117
 
111
- let freshFiles: ContextFile[];
112
- try {
113
- freshFiles = loadContextFiles({
114
- cwd: ctx.cwd,
115
- agentDir: resolveAgentDir(),
116
- });
117
- } catch (err) {
118
- if (ctx.hasUI) {
119
- const detail = err instanceof Error ? err.message : String(err);
120
- ctx.ui.notify(`Failed to reload context files: ${detail}`, "error");
121
- }
118
+ if (contextFilesDisabled()) {
119
+ notify(formatDisabledNotice(), "warning");
122
120
  return;
123
121
  }
124
122
 
125
- const baseOptions = ctx.getSystemPromptOptions?.();
126
- const previousFiles =
127
- rebriefed?.cwd === ctx.cwd
128
- ? rebriefed.files
129
- : (baseOptions?.contextFiles ?? []);
130
- const changed = !sameContextFiles(previousFiles, freshFiles);
131
-
132
- rebriefed = { cwd: ctx.cwd, files: cloneContextFiles(freshFiles) };
133
- if (baseOptions) {
134
- baseOptions.contextFiles = cloneContextFiles(freshFiles);
123
+ const previous = files;
124
+ files = load(ctx.cwd);
125
+ pinned = true;
126
+ const changed = !sameContextFiles(previous, files);
127
+ const running = !ctx.isIdle();
128
+ if (changed && running) {
129
+ pending = { at: Date.now() };
135
130
  }
136
131
 
137
- if (!ctx.hasUI) return;
132
+ showStatus(ctx.hasUI ? ctx.ui : undefined, undefined);
138
133
 
139
134
  if (!changed) {
140
- ctx.ui.notify(`Context files unchanged (${fileCountLabel(freshFiles.length)}).`, "info");
135
+ notify(formatUnchangedNotice(files.length), "info");
141
136
  return;
142
137
  }
143
-
144
- ctx.ui.notify(`Reloaded context files: ${describeChanges(previousFiles, freshFiles, ctx.cwd)}.`, "info");
138
+ notify(formatReloadedNotice(previous, files, ctx.cwd, running), "info");
145
139
  },
146
140
  });
147
141
 
148
- pi.on("before_agent_start", async (event) => {
149
- if (!rebriefed || rebriefed.cwd !== event.systemPromptOptions.cwd) return;
150
- event.systemPromptOptions.contextFiles = cloneContextFiles(rebriefed.files);
142
+ pi.on("before_agent_start", (event) => {
143
+ pending = undefined;
144
+ if (pinned) {
145
+ event.systemPromptOptions.contextFiles = files.map((file) => ({ ...file }));
146
+ } else {
147
+ files = event.systemPromptOptions.contextFiles ?? [];
148
+ }
149
+ });
150
+
151
+ // Mid-run /rebrief: add a request-local section update until the next run starts
152
+ pi.on("context_with_system", (event) => {
153
+ if (!pending) {
154
+ return;
155
+ }
156
+ const messages = event.messages;
157
+ const desired = renderProjectContext(files);
158
+ if ((getCurrentSystemMessage(messages)?.sections?.project_context ?? null) === desired) {
159
+ return;
160
+ }
161
+ pending.anchor ??= messages.at(-1)?.timestamp;
162
+ const update = { role: "system" as const, content: "", sections: { project_context: desired }, timestamp: pending.at };
163
+ const anchor = messages.findIndex((m) => m.timestamp === pending!.anchor);
164
+ const at = anchor === -1 ? messages.length : anchor + 1;
165
+ return { messages: [...messages.slice(0, at), update, ...messages.slice(at)] };
151
166
  });
152
167
  };
153
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-rebrief",
3
- "version": "0.0.0",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "description": "Reload only the project context files, including AGENTS.md and CLAUDE.md, into a running Pi session: no extension restart or MCP reconnect like /reload, plus a report of what changed.",
6
6
  "keywords": [
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "files": [
28
28
  "index.ts",
29
+ "format.ts",
29
30
  "README.md",
30
31
  "LICENSE"
31
32
  ],
@@ -39,6 +40,7 @@
39
40
  "test": "node --test"
40
41
  },
41
42
  "peerDependencies": {
43
+ "@earendil-works/pi-ai": "*",
42
44
  "@earendil-works/pi-coding-agent": "*"
43
45
  },
44
46
  "devDependencies": {