@shanepadgett/tau-agent 0.28.1 → 0.30.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 (66) hide show
  1. package/docs/context.md +14 -9
  2. package/docs/subagents.md +7 -5
  3. package/extensions/cache-diagnostics/index.ts +4 -5
  4. package/extensions/context/README.md +8 -5
  5. package/extensions/context/definitions.ts +28 -17
  6. package/extensions/context/evidence.ts +4 -3
  7. package/extensions/context/index.ts +57 -35
  8. package/extensions/context/panel.ts +10 -9
  9. package/extensions/context/projection.ts +141 -0
  10. package/extensions/context/state.ts +30 -0
  11. package/extensions/explore/README.md +1 -1
  12. package/extensions/explore/ast/read/hook.ts +5 -35
  13. package/extensions/explore/ast/read/policy.ts +0 -32
  14. package/extensions/explore/index.ts +2 -0
  15. package/extensions/explore/outline-injection.ts +151 -0
  16. package/extensions/explore/settings.ts +0 -8
  17. package/extensions/ideas/browser.ts +27 -16
  18. package/extensions/review/README.md +11 -0
  19. package/extensions/review/index.ts +135 -0
  20. package/extensions/review/model.ts +144 -0
  21. package/extensions/review/panel.ts +128 -0
  22. package/extensions/review/session.ts +106 -0
  23. package/extensions/runtime-context/README.md +1 -1
  24. package/extensions/runtime-context/index.ts +3 -66
  25. package/extensions/script-runner/README.md +7 -0
  26. package/extensions/script-runner/index.ts +275 -0
  27. package/extensions/stash/browser.ts +30 -18
  28. package/extensions/subagent/README.md +17 -4
  29. package/extensions/subagent/agents/context-sync.md +2 -2
  30. package/extensions/subagent/agents/scout.md +49 -52
  31. package/extensions/subagent/agents.ts +0 -1
  32. package/extensions/subagent/cmux-dashboard.ts +7 -3
  33. package/extensions/subagent/index.ts +105 -4
  34. package/extensions/subagent/panel.ts +124 -0
  35. package/extensions/subagent/render.ts +2 -1
  36. package/extensions/subagent/run.ts +9 -9
  37. package/extensions/subagent/runtime.ts +5 -5
  38. package/extensions/subagent/session-resource.ts +19 -127
  39. package/extensions/subagent/settings.ts +18 -0
  40. package/extensions/tau-help/help.md +12 -8
  41. package/extensions/working-memory/README.md +9 -0
  42. package/extensions/working-memory/checkpoint.ts +175 -0
  43. package/extensions/working-memory/index.ts +338 -0
  44. package/extensions/working-memory/memory.ts +266 -0
  45. package/extensions/working-memory/render.ts +178 -0
  46. package/extensions/working-memory/settings.ts +38 -0
  47. package/extensions/working-memory/state.ts +152 -0
  48. package/package.json +2 -2
  49. package/schemas/tau.schema.json +35 -55
  50. package/shared/context-messages.ts +19 -0
  51. package/shared/events.ts +5 -0
  52. package/shared/full-file-knowledge.ts +0 -1
  53. package/shared/injected-context.ts +2 -2
  54. package/shared/isolated-session.ts +151 -0
  55. package/shared/outline-injection.ts +56 -0
  56. package/extensions/context-pruning/README.md +0 -39
  57. package/extensions/context-pruning/index.ts +0 -382
  58. package/extensions/context-pruning/projection.ts +0 -60
  59. package/extensions/context-pruning/prune.ts +0 -199
  60. package/extensions/context-pruning/render.ts +0 -251
  61. package/extensions/context-pruning/settings.ts +0 -39
  62. package/extensions/subagent/agents/review.md +0 -68
  63. package/extensions/turn-budget/README.md +0 -14
  64. package/extensions/turn-budget/index.ts +0 -116
  65. package/extensions/turn-budget/settings.ts +0 -35
  66. package/shared/context-pruning-state.ts +0 -152
package/docs/context.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Context management
2
2
 
3
- Tau uses `.pi/contexts` as a reusable map of the repository. A selected context gives an agent the primary files for a work scope and points it toward related files without loading everything.
3
+ Tau uses `.pi/contexts` as a reusable map of the repository. A selected context supplies a small bootstrap projection for a work scope and points toward related files without loading everything.
4
4
 
5
5
  ## Structure
6
6
 
@@ -23,13 +23,15 @@ description = "Checkout calculation and order submission"
23
23
 
24
24
  [orchestration]
25
25
  description = "Building and submitting a checkout order"
26
- files = ["src/checkout/order.ts", "src/checkout/service.ts"]
27
- anchors = ["test/checkout/service.test.ts", "src/payments/client.ts"]
26
+ read = []
27
+ outline = ["src/checkout/order.ts", "src/checkout/service.ts"]
28
+ references = ["test/checkout/service.test.ts", "src/payments/client.ts"]
28
29
 
29
30
  [discounts]
30
31
  description = "Applying coupons and account discounts"
31
- files = ["src/checkout/discounts.ts"]
32
- anchors = ["test/checkout/discounts.test.ts"]
32
+ read = []
33
+ outline = ["src/checkout/discounts.ts"]
34
+ references = ["test/checkout/discounts.test.ts"]
33
35
  ```
34
36
 
35
37
  Saved as `.pi/contexts/commerce/checkout.toml`, these entries have the IDs `commerce/checkout/orchestration` and `commerce/checkout/discounts`.
@@ -48,12 +50,15 @@ Keep entries focused. Names such as `misc`, `shared`, and `other` hide missing b
48
50
 
49
51
  Catalog durable code, configuration, tests, standards, and long-lived documentation. Leave scratch files, working plans, interviews, generated output, and rough ideas out of the catalog.
50
52
 
51
- ## Choose files and anchors
53
+ ## Choose loading modes
52
54
 
53
- - `files` are eagerly read when the entry is selected. Include primary files that are usually required for that scope.
54
- - `anchors` are shown to the agent as unloaded navigation paths. Use them for related tests, callers, shared dependencies, or documentation that is useful only for some tasks.
55
+ - `read` supplies exact complete file contents. Reserve it for instructions, small authoritative specifications, and other files whose exact wording is routinely needed.
56
+ - `outline` supplies current structural outlines through Explore. Use it for recurring source entry points.
57
+ - `references` supplies unloaded navigation paths. This should be the default for durable code, tests, callers, shared dependencies, and secondary documentation.
55
58
 
56
- Keep the eager set small enough to read on every selection. Keep each path's loading class consistent across the catalog. If selected entries classify the same path differently, eager loading wins.
59
+ Every entry declares all three arrays, including empty arrays. Keep `read` and `outline` small because Tau rebuilds them before model calls. Keep each path's loading mode consistent across the catalog. When selected entries disagree, precedence is `read`, then `outline`, then `references`.
60
+
61
+ `/context` persists selected entry IDs as branch-local session state. It does not append file contents to conversation history. Before each model call Tau resolves those IDs against the current catalog, reads current `read` files, refreshes changed outlines, and adds one ephemeral projection. Reopening `/context` replaces the active selection. Clear all selections and confirm to remove the projection.
57
62
 
58
63
  ## Maintain the map
59
64
 
package/docs/subagents.md CHANGED
@@ -5,7 +5,7 @@ Tau's `subagent` tool delegates one focused task to an isolated child Pi session
5
5
  Each fresh call returns a thread ID. Continue that thread when feedback or follow-up work depends on the child's prior reads and reasoning:
6
6
 
7
7
  ```text
8
- subagent({ agent: "review", task: "Review configuration loading" })
8
+ subagent({ agent: "scout", task: "Trace configuration loading" })
9
9
  subagent({ thread: "thread-1", task: "Now check whether this proposed fix covers every caller" })
10
10
  ```
11
11
 
@@ -14,7 +14,7 @@ Retained threads keep their complete child conversation for five minutes after t
14
14
  If the relevant files are already known, autoread them into a fresh or retained child turn:
15
15
 
16
16
  ```text
17
- subagent({ agent: "review", task: "Review the runtime change", files: ["src/runtime.ts", "test/runtime.test.ts"] })
17
+ subagent({ agent: "scout", task: "Trace the runtime change", files: ["src/runtime.ts", "test/runtime.test.ts"] })
18
18
  ```
19
19
 
20
20
  Paths may be relative to the parent's current working directory or absolute. Tau reads current, line-numbered snapshots when the turn starts. Unreadable files produce failed context entries without stopping the child. Keep the list focused because complete snapshots consume the child's context window.
@@ -26,7 +26,7 @@ Paths may be relative to the parent's current working directory or absolute. Tau
26
26
  | **User (global)** | `~/.pi/agent/tau/agents/*.md` | You want the agent in every project |
27
27
  | **Project** | nearest trusted `.pi/tau/agents/*.md` | Repo-specific helpers |
28
28
 
29
- Precedence: **project overrides user**, which overrides Tau's built-ins (`review`, `scout`, `web-research`). Duplicate names in one scope are invalid.
29
+ Precedence: **project overrides user**, which overrides Tau's built-ins (`scout`, `web-research`). Duplicate names in one scope are invalid.
30
30
 
31
31
  ## Definition format
32
32
 
@@ -68,6 +68,9 @@ Tau assigns one display name to each fresh child and keeps it for follow-up turn
68
68
 
69
69
  ## Runtime rules
70
70
 
71
+ - Run `/agents` to enable or disable agents for the current session branch. Press Space to stage each toggle, then Enter to apply the changes. Session choices do not change Tau settings.
72
+ - Set `extensions.subagent.disabled` to an array of agent names for persistent global or project-level disabling. Agents disabled this way appear as `disabled by Tau settings` in `/agents` and cannot be enabled there.
73
+ - Disabled agents are omitted from the parent's available-agent list and cannot start or continue a retained thread.
71
74
  - Children use the parent's cwd and inherit model/thinking unless the definition overrides them.
72
75
  - Children do not receive the parent conversation.
73
76
  - Calls can include `files` to autoread line-numbered snapshots into that child turn.
@@ -82,8 +85,7 @@ Tau assigns one display name to each fresh child and keeps it for follow-up turn
82
85
 
83
86
  ## Built-ins
84
87
 
85
- - `review` — adversarial, read-only review for correctness, runtime risks, duplication, and over- or under-engineering
86
- - `scout` — tiered, AST-first local discovery of files, symbols, data flow, constraints, and unknowns without changing anything
88
+ - `scout` — substantial multi-hop local code lookup that would chew parent context; paths, declarations, imports, references, call edges; facts only. Skip small digs
87
89
  - `web-research` — `websearch`, `codesearch`, `webfetch`
88
90
  - `context-sync` — maps meaningful uncommitted work into `.pi/contexts`. Offered to the coding agent when `extensions.context.sync.enabled` and `sync.automation` are true. Manual `/context-sync` remains when sync is enabled with `automation` false. Validation can auto-run it when `validation.enabled` and `sync.enabled`
89
91
 
@@ -415,10 +415,9 @@ export default function cacheDiagnosticsExtension(pi: ExtensionAPI): void {
415
415
  event.message.stopReason !== "error" &&
416
416
  event.message.stopReason !== "aborted" &&
417
417
  (attempt.responseStatus === undefined || (attempt.responseStatus >= 200 && attempt.responseStatus < 300));
418
- const missedTokens =
419
- baselinePromoted && attempt.request.previousExactPrefix
420
- ? Math.max(0, Math.min(attempt.request.previousPromptTokens, promptTokens) - usage.cacheRead)
421
- : 0;
418
+ const missedTokens = baselinePromoted
419
+ ? Math.max(0, Math.min(attempt.request.previousPromptTokens, promptTokens) - usage.cacheRead)
420
+ : 0;
422
421
  const cacheMiss = baselinePromoted && cacheActivitySeen && missedTokens > CACHE_NOISE_FLOOR;
423
422
  const result: ResultRecord = {
424
423
  kind: "result",
@@ -472,7 +471,7 @@ export default function cacheDiagnosticsExtension(pi: ExtensionAPI): void {
472
471
  );
473
472
  pi.on("session_tree", () => addMarker("session-tree"));
474
473
  pi.on("tool_execution_end", (event) => {
475
- if (event.toolName !== "context_prune" && event.toolName !== "load_tools") return;
474
+ if (event.toolName !== "working_memory" && event.toolName !== "load_tools") return;
476
475
  return addMarker("cache-affecting-tool", { tool: event.toolName, isError: event.isError });
477
476
  });
478
477
  }
@@ -4,7 +4,7 @@ Context stores reusable repository work scopes in `.pi/contexts`. Folder names b
4
4
 
5
5
  See [Context management](../../docs/context.md) for catalog structure and taxonomy guidance.
6
6
 
7
- Use `/context` to select entries. Entry `files` are injected through Tau autoread. Entry `anchors` supply lazy navigation paths that the agent can grep or read in ranges when needed.
7
+ Use `/context` to set active entries for the current session branch. Tau stores only the selected entry IDs. Before each model call it rebuilds one ephemeral projection from current files, so repeated selections do not accumulate snapshots in conversation history. Open `/context`, press `Ctrl+C`, then confirm to clear active context.
8
8
 
9
9
  After meaningful uncommitted work (new/moved ownership, not trivial already-covered polish), the coding agent should run the `context-sync` subagent so `.pi/contexts` stays aligned. Context sync catalogs durable code and long-lived documentation. Scratch pads, working plans, interviews, rough ideas, and other temporary artifacts should stay out; add recurring transient paths to `validation.ignoreGlobs`. Humans can also run `/context-sync` or `/context-sync <nudge>` and press Escape to cancel a running sync. It walks domain → concept → entry → membership, edits only `.pi/contexts` with `patch`, and the harness verifies write scope plus catalog invariants afterward. Out-of-scope writes are restored and the run fails. Optional nudge text soft-steers judgment without skipping evidence.
10
10
 
@@ -37,12 +37,15 @@ description = "Player-owned gameplay systems"
37
37
 
38
38
  [input]
39
39
  description = "Input mapping and command handling"
40
- files = ["src/player/input.ts"]
40
+ read = []
41
+ outline = ["src/player/input.ts"]
42
+ references = []
41
43
 
42
44
  [movement]
43
45
  description = "Player locomotion and collision"
44
- files = ["src/player/movement.ts"]
45
- anchors = ["src/runtime/fetch-handler.ts"]
46
+ read = []
47
+ outline = ["src/player/movement.ts"]
48
+ references = ["src/runtime/fetch-handler.ts"]
46
49
  ```
47
50
 
48
- For example, `.pi/contexts/gameplay/player.toml` appears in the `gameplay` tab as the `Player` concept with `input` and `movement` entries. When `movement` is selected, Tau autoreads `src/player/movement.ts` and lists `src/runtime/fetch-handler.ts` as an unloaded anchor. If one selected entry marks a path as a file and another marks it as an anchor, autoread wins.
51
+ Every entry must declare all three loading arrays. `read` supplies exact complete contents, `outline` asks Explore for current structural outlines, and `references` lists unloaded paths. If selected entries classify the same path differently, precedence is `read`, then `outline`, then `references`.
@@ -12,8 +12,9 @@ export interface ContextEntry {
12
12
  conceptDescription: string;
13
13
  name: string;
14
14
  description: string;
15
- files: string[];
16
- anchors: string[];
15
+ read: string[];
16
+ outline: string[];
17
+ references: string[];
17
18
  path: string;
18
19
  }
19
20
 
@@ -37,7 +38,7 @@ const CONTEXT_IGNORED_FILENAMES = new Set([
37
38
  "uv.lock",
38
39
  "yarn.lock",
39
40
  ]);
40
- const CONTEXT_ENTRY_FIELDS = new Set(["description", "files", "anchors"]);
41
+ const CONTEXT_ENTRY_FIELDS = new Set(["description", "read", "outline", "references"]);
41
42
 
42
43
  export function isContextEligiblePath(path: string, ignoreGlobs: readonly string[] = []): boolean {
43
44
  return (
@@ -45,6 +46,8 @@ export function isContextEligiblePath(path: string, ignoreGlobs: readonly string
45
46
  !CONTEXT_IGNORED_FILENAMES.has(basename(path)) &&
46
47
  !ignoreGlobs.some((glob) => matchGlob(glob, path)) &&
47
48
  path !== ".pi/tau/ideas.jsonl" &&
49
+ path !== ".pi/tau/reviews" &&
50
+ !path.startsWith(".pi/tau/reviews/") &&
48
51
  path !== ".working" &&
49
52
  !path.startsWith(".working/") &&
50
53
  path !== ".pi/contexts" &&
@@ -99,8 +102,8 @@ function sortedUnique(values: readonly string[]): string[] {
99
102
  return [...new Set(values)].sort((a, b) => a.localeCompare(b));
100
103
  }
101
104
 
102
- export function contextEntryPaths(entry: Pick<ContextEntry, "files" | "anchors">): string[] {
103
- return sortedUnique([...entry.files, ...entry.anchors]);
105
+ export function contextEntryPaths(entry: Pick<ContextEntry, "read" | "outline" | "references">): string[] {
106
+ return sortedUnique([...entry.read, ...entry.outline, ...entry.references]);
104
107
  }
105
108
 
106
109
  export async function loadContextEntries(root: string): Promise<ContextEntry[]> {
@@ -128,22 +131,29 @@ export async function loadContextEntries(root: string): Promise<ContextEntry[]>
128
131
  const record = value as Record<string, unknown>;
129
132
  const unknownField = Object.keys(record).find((field) => !CONTEXT_ENTRY_FIELDS.has(field));
130
133
  if (unknownField) throw new Error(`Invalid context entry field: ${path} [${name}] ${unknownField}`);
131
- const anchors = record.anchors ?? [];
132
134
  if (
133
135
  typeof record.description !== "string" ||
134
136
  !record.description.trim() ||
135
- !Array.isArray(record.files) ||
136
- record.files.some((item) => typeof item !== "string") ||
137
- !Array.isArray(anchors) ||
138
- anchors.some((item) => typeof item !== "string") ||
139
- (record.files.length === 0 && anchors.length === 0)
137
+ !Array.isArray(record.read) ||
138
+ record.read.some((item) => typeof item !== "string") ||
139
+ !Array.isArray(record.outline) ||
140
+ record.outline.some((item) => typeof item !== "string") ||
141
+ !Array.isArray(record.references) ||
142
+ record.references.some((item) => typeof item !== "string") ||
143
+ (record.read.length === 0 && record.outline.length === 0 && record.references.length === 0)
140
144
  )
141
145
  throw new Error(`Invalid context entry: ${path} [${name}]`);
142
146
  const entry = validSlug(name, "Context entry");
143
- const entryFiles = sortedUnique((record.files as string[]).map((item) => normalizeProjectPath(root, item)));
144
- const entryAnchors = sortedUnique((anchors as string[]).map((item) => normalizeProjectPath(root, item)));
145
- const overlap = entryFiles.find((item) => entryAnchors.includes(item));
146
- if (overlap) throw new Error(`Context path cannot be both file and anchor: ${path} [${name}] ${overlap}`);
147
+ const entryRead = sortedUnique((record.read as string[]).map((item) => normalizeProjectPath(root, item)));
148
+ const entryOutline = sortedUnique(
149
+ (record.outline as string[]).map((item) => normalizeProjectPath(root, item)),
150
+ );
151
+ const entryReferences = sortedUnique(
152
+ (record.references as string[]).map((item) => normalizeProjectPath(root, item)),
153
+ );
154
+ const classified = [...entryRead, ...entryOutline, ...entryReferences];
155
+ const overlap = classified.find((item, index) => classified.indexOf(item) !== index);
156
+ if (overlap) throw new Error(`Context path has multiple loading modes: ${path} [${name}] ${overlap}`);
147
157
  result.push({
148
158
  id: `${tab}/${concept}/${entry}`,
149
159
  tab,
@@ -152,8 +162,9 @@ export async function loadContextEntries(root: string): Promise<ContextEntry[]>
152
162
  conceptDescription,
153
163
  name: entry,
154
164
  description: record.description.trim(),
155
- files: entryFiles,
156
- anchors: entryAnchors,
165
+ read: entryRead,
166
+ outline: entryOutline,
167
+ references: entryReferences,
157
168
  path,
158
169
  });
159
170
  }
@@ -299,7 +299,7 @@ function formatOverview(evidence: SyncEvidence): string {
299
299
  "2. Concept (toml): reuse, new, split, or merge?",
300
300
  "3. Entry (section): update, new, split, delete, or move?",
301
301
  "4. Bloat: any touched scope now a junk drawer?",
302
- "5. Membership: files/anchors under winners only. Preserve existing read vs anchor class.",
302
+ "5. Membership: read/outline/references under winners only. Preserve existing loading mode.",
303
303
  "",
304
304
  "Edit only files under .pi/contexts/. Use patch. Do not use bash.",
305
305
  "Every eligible changed non-deleted file must belong to at least one entry. Remove every stale catalog path.",
@@ -346,8 +346,9 @@ function formatCatalog(evidence: SyncEvidence): string {
346
346
  conceptDescription: entry.conceptDescription,
347
347
  entry: entry.name,
348
348
  description: entry.description,
349
- files: entry.files,
350
- anchors: entry.anchors,
349
+ read: entry.read,
350
+ outline: entry.outline,
351
+ references: entry.references,
351
352
  missing: contextEntryPaths(entry).filter((path) => evidence.missingPaths.has(path)),
352
353
  path: entry.path,
353
354
  })),
@@ -1,15 +1,20 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { join } from "node:path";
3
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
3
  import { getKeybindings } from "@earendil-works/pi-tui";
5
- import { emitTauEvent } from "../../shared/events.ts";
6
4
  import { createGitRunner, loadRepoStatus } from "../../shared/git.ts";
7
- import { createInjectedContext } from "../../shared/injected-context.ts";
8
5
  import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
9
6
  import { ContextPanel, ContextSyncStatusPanel } from "./panel.ts";
10
7
  import { findProjectRoot, loadContextEntries, type ContextEntry } from "./definitions.ts";
11
8
  import { hideContextSyncEvidenceTool, registerContextSyncEvidenceTool } from "./evidence.ts";
9
+ import {
10
+ buildContextProjection,
11
+ contextProjectionKey,
12
+ contextProjectionMessage,
13
+ removeLegacyContextMessages,
14
+ selectContextEntries,
15
+ } from "./projection.ts";
12
16
  import contextSettings from "./settings.ts";
17
+ import { CONTEXT_SELECTION_TYPE, createContextSelectionState, replayContextSelection } from "./state.ts";
13
18
  import { runContextSync } from "./sync.ts";
14
19
  import { formatContextValidationFailure, validateContextCatalog } from "./validation.ts";
15
20
 
@@ -17,6 +22,8 @@ export default function contextExtension(pi: ExtensionAPI): void {
17
22
  let settings = contextSettings.defaults;
18
23
  let lastValidationFailure: string | undefined;
19
24
  let syncCommandRegistered = false;
25
+ let lifecycleGeneration = 0;
26
+ let projectionCache: { key: string; content: string } | undefined;
20
27
 
21
28
  registerContextSyncEvidenceTool(pi);
22
29
 
@@ -79,7 +86,7 @@ export default function contextExtension(pi: ExtensionAPI): void {
79
86
  };
80
87
 
81
88
  pi.registerCommand("context", {
82
- description: "Select repository context entries and inject their files",
89
+ description: "Select active repository context entries",
83
90
  handler: async (_args, ctx) => {
84
91
  if (ctx.mode !== "tui" || !ctx.isProjectTrusted()) {
85
92
  ctx.ui.notify("/context requires a trusted TUI project", "warning");
@@ -92,44 +99,21 @@ export default function contextExtension(pi: ExtensionAPI): void {
92
99
  ctx.ui.notify(`No context entries found in ${join(root, ".pi", "contexts")}`, "warning");
93
100
  return;
94
101
  }
102
+ const active = replayContextSelection(ctx.sessionManager.getBranch()).entryIds;
95
103
  const selected = await ctx.ui.custom<ContextEntry[] | undefined>(
96
- (tui, theme, _keys, done) => new ContextPanel(tui, theme, entries, done),
104
+ (tui, theme, _keys, done) => new ContextPanel(tui, theme, entries, active, done),
97
105
  {
98
106
  overlay: true,
99
107
  overlayOptions: { anchor: "top-center", width: "70%", minWidth: 64, maxHeight: "80%", margin: 2 },
100
108
  },
101
109
  );
102
- if (!selected?.length) return;
103
- const files = [...new Set(selected.flatMap((entry) => entry.files))].sort();
104
- const fileSet = new Set(files);
105
- const anchors = [...new Set(selected.flatMap((entry) => entry.anchors))]
106
- .filter((path) => !fileSet.has(path))
107
- .sort();
108
- pi.sendMessage(
109
- createInjectedContext(
110
- [
111
- "Selected repository context:",
112
- ...selected.map((entry) => `- ${entry.id}: ${entry.description}`),
113
- "",
114
- "Eager snapshots supplied through autoread:",
115
- ...(files.length ? files.map((path) => `- ${path}`) : ["(none)"]),
116
- "",
117
- "Lazy navigation anchors whose contents have not been loaded:",
118
- ...(anchors.length ? anchors.map((path) => `- ${path}`) : ["(none)"]),
119
- "",
120
- "Treat eager snapshots as authoritative current project context. Do not reread them or search for coverage around them. Inspect only the anchors needed for the request, using grep or bounded reads. Explore elsewhere only when the request or concrete evidence requires missing information.",
121
- ].join("\n"),
122
- { source: "context", title: "Project context" },
123
- ),
110
+ if (selected === undefined) return;
111
+ pi.appendEntry(CONTEXT_SELECTION_TYPE, createContextSelectionState(selected.map((entry) => entry.id)));
112
+ projectionCache = undefined;
113
+ ctx.ui.notify(
114
+ selected.length ? `${selected.length} context entries active` : "Active context cleared",
115
+ "info",
124
116
  );
125
- if (files.length)
126
- emitTauEvent(pi, "tau:autoread.requested", {
127
- source: "context",
128
- title: "Project context",
129
- cwd: root,
130
- batchId: randomUUID(),
131
- files: files.map((path) => ({ path })),
132
- });
133
117
  },
134
118
  });
135
119
 
@@ -137,8 +121,34 @@ export default function contextExtension(pi: ExtensionAPI): void {
137
121
  if (settings.sync.enabled) registerContextSyncCommand();
138
122
 
139
123
  pi.on("session_start", async (_event, ctx) => {
124
+ lifecycleGeneration += 1;
125
+ projectionCache = undefined;
140
126
  await refreshSettings(ctx);
141
127
  });
128
+ pi.on("context", async (event, ctx) => {
129
+ const messages = removeLegacyContextMessages(event.messages);
130
+ if (!ctx.isProjectTrusted()) return { messages };
131
+ const entryIds = replayContextSelection(ctx.sessionManager.getBranch()).entryIds;
132
+ if (entryIds.length === 0) return { messages };
133
+ const generation = lifecycleGeneration;
134
+ const root = await findProjectRoot(ctx.cwd);
135
+ const selection = selectContextEntries(await loadContextEntries(root), entryIds);
136
+ const key = await contextProjectionKey(root, selection, ctx.signal, () => generation === lifecycleGeneration);
137
+ if (generation !== lifecycleGeneration) return { messages };
138
+ if (projectionCache?.key !== key) {
139
+ projectionCache = {
140
+ key,
141
+ content: await buildContextProjection(
142
+ pi,
143
+ root,
144
+ selection,
145
+ ctx.signal,
146
+ () => generation === lifecycleGeneration,
147
+ ),
148
+ };
149
+ }
150
+ return { messages: [contextProjectionMessage(projectionCache.content), ...messages] };
151
+ });
142
152
  pi.on("agent_start", async (_event, ctx) => {
143
153
  await refreshSettings(ctx);
144
154
  });
@@ -202,6 +212,18 @@ export default function contextExtension(pi: ExtensionAPI): void {
202
212
  ctx.ui.notify(`Context validation failed: ${error instanceof Error ? error.message : String(error)}`, "error");
203
213
  }
204
214
  });
215
+ pi.on("session_tree", () => {
216
+ lifecycleGeneration += 1;
217
+ projectionCache = undefined;
218
+ });
219
+ pi.on("session_compact", () => {
220
+ lifecycleGeneration += 1;
221
+ projectionCache = undefined;
222
+ });
223
+ pi.on("session_shutdown", () => {
224
+ lifecycleGeneration += 1;
225
+ projectionCache = undefined;
226
+ });
205
227
  }
206
228
 
207
229
  function showContextSyncWidget(ctx: ExtensionContext): { update(status: string): void; clear(): void } {
@@ -21,6 +21,7 @@ export class ContextPanel implements Component {
21
21
  tui: TUI,
22
22
  theme: Theme,
23
23
  entries: readonly ContextEntry[],
24
+ activeEntryIds: readonly string[],
24
25
  done: (result: ContextEntry[] | undefined) => void,
25
26
  ) {
26
27
  const firstEntry = entries[0];
@@ -67,6 +68,8 @@ export class ContextPanel implements Component {
67
68
  border: "box",
68
69
  };
69
70
  this.panel = new ToolPanel(theme, this.config);
71
+ for (const [tab, list] of this.lists)
72
+ list.setSelectedIds(activeEntryIds.filter((id) => id.startsWith(`${tab}/`)));
70
73
  }
71
74
 
72
75
  handleInput(data: string): void {
@@ -141,10 +144,7 @@ export class ContextPanel implements Component {
141
144
  ...(range ? [truncateToWidth(range, width, "…")] : []),
142
145
  ...visible.map(({ kind, path }) =>
143
146
  truncateToWidth(
144
- this.theme.fg(
145
- kind === "read" ? "muted" : "dim",
146
- `${kind === "read" ? "read" : "anchor"} • ${path}`,
147
- ),
147
+ this.theme.fg(kind === "reference" ? "dim" : "muted", `${kind} • ${path}`),
148
148
  width,
149
149
  "…",
150
150
  ),
@@ -154,10 +154,11 @@ export class ContextPanel implements Component {
154
154
  invalidate: () => this.tabs.invalidate(),
155
155
  };
156
156
  }
157
- private currentPaths(): Array<{ kind: "read" | "anchor"; path: string }> {
157
+ private currentPaths(): Array<{ kind: "read" | "outline" | "reference"; path: string }> {
158
158
  return [
159
- ...this.current.files.map((path) => ({ kind: "read" as const, path })),
160
- ...this.current.anchors.map((path) => ({ kind: "anchor" as const, path })),
159
+ ...this.current.read.map((path) => ({ kind: "read" as const, path })),
160
+ ...this.current.outline.map((path) => ({ kind: "outline" as const, path })),
161
+ ...this.current.references.map((path) => ({ kind: "reference" as const, path })),
161
162
  ];
162
163
  }
163
164
  private pathPageSize(tabLines = this.tabs.render(this.tui.terminal.columns).length): number {
@@ -167,7 +168,7 @@ export class ContextPanel implements Component {
167
168
  return this.currentPaths().length > pageSize ? Math.max(1, pageSize - 1) : pageSize;
168
169
  }
169
170
  private secondary(): string {
170
- return `${[...this.selected.values()].reduce((sum, items) => sum + items.length, 0)} selected · ${this.current.files.length} read · ${this.current.anchors.length} anchors`;
171
+ return `${[...this.selected.values()].reduce((sum, items) => sum + items.length, 0)} selected · ${this.current.read.length} read · ${this.current.outline.length} outline · ${this.current.references.length} references`;
171
172
  }
172
173
  private hints() {
173
174
  const list = this.activeList();
@@ -177,7 +178,7 @@ export class ContextPanel implements Component {
177
178
  ...this.tabs.getKeyHints(),
178
179
  ...(this.currentPaths().length > this.pathPageSize() ? [rawHint("option+↑/↓", "scroll paths")] : []),
179
180
  rawHint("ctrl+c", "clear all"),
180
- bindingHint("tui.select.confirm", "inject"),
181
+ bindingHint("tui.select.confirm", "activate"),
181
182
  bindingHint("tui.select.cancel", "cancel"),
182
183
  ];
183
184
  }
@@ -0,0 +1,141 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import type { ContextEvent, ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ CONTEXT_PROJECTION_TYPE,
8
+ isContextProjectionMessage,
9
+ isLegacyContextMessage,
10
+ } from "../../shared/context-messages.ts";
11
+ import { requestOutlineInjections } from "../../shared/outline-injection.ts";
12
+ import type { ContextEntry } from "./definitions.ts";
13
+
14
+ type ContextMessage = ContextEvent["messages"][number];
15
+
16
+ export { CONTEXT_PROJECTION_TYPE } from "../../shared/context-messages.ts";
17
+
18
+ export interface SelectedContext {
19
+ entries: ContextEntry[];
20
+ missingEntryIds: string[];
21
+ read: string[];
22
+ outline: string[];
23
+ references: string[];
24
+ }
25
+
26
+ export function selectContextEntries(entries: readonly ContextEntry[], entryIds: readonly string[]): SelectedContext {
27
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
28
+ const selected = entryIds.flatMap((id) => {
29
+ const entry = byId.get(id);
30
+ return entry ? [entry] : [];
31
+ });
32
+ const read = new Set(selected.flatMap((entry) => entry.read));
33
+ const outline = new Set(selected.flatMap((entry) => entry.outline).filter((path) => !read.has(path)));
34
+ const references = new Set(
35
+ selected.flatMap((entry) => entry.references).filter((path) => !read.has(path) && !outline.has(path)),
36
+ );
37
+ return {
38
+ entries: selected,
39
+ missingEntryIds: entryIds.filter((id) => !byId.has(id)),
40
+ read: [...read].sort((left, right) => left.localeCompare(right)),
41
+ outline: [...outline].sort((left, right) => left.localeCompare(right)),
42
+ references: [...references].sort((left, right) => left.localeCompare(right)),
43
+ };
44
+ }
45
+
46
+ export async function contextProjectionKey(
47
+ root: string,
48
+ selection: SelectedContext,
49
+ signal: AbortSignal | undefined,
50
+ isLifecycleCurrent: () => boolean,
51
+ ): Promise<string> {
52
+ const paths = [...selection.read, ...selection.outline];
53
+ const files: Array<[string, string]> = [];
54
+ for (const path of paths) {
55
+ signal?.throwIfAborted();
56
+ if (!isLifecycleCurrent()) throw new Error("Context projection crossed a session lifecycle boundary");
57
+ try {
58
+ const hash = createHash("sha256");
59
+ for await (const chunk of createReadStream(resolve(root, path), { signal })) {
60
+ if (!isLifecycleCurrent()) throw new Error("Context projection crossed a session lifecycle boundary");
61
+ hash.update(chunk);
62
+ }
63
+ files.push([path, hash.digest("hex")]);
64
+ } catch (error) {
65
+ if (signal?.aborted || !isLifecycleCurrent()) throw error;
66
+ files.push([path, "missing"]);
67
+ }
68
+ }
69
+ return JSON.stringify({
70
+ entries: selection.entries.map((entry) => [entry.id, entry.description]),
71
+ missingEntryIds: selection.missingEntryIds,
72
+ read: selection.read,
73
+ outline: selection.outline,
74
+ references: selection.references,
75
+ files,
76
+ });
77
+ }
78
+
79
+ export async function buildContextProjection(
80
+ pi: Pick<ExtensionAPI, "events">,
81
+ root: string,
82
+ selection: SelectedContext,
83
+ signal: AbortSignal | undefined,
84
+ isLifecycleCurrent: () => boolean,
85
+ ): Promise<string> {
86
+ const warnings = selection.missingEntryIds.map((id) => `${id}: selected entry no longer exists`);
87
+ const reads: Array<{ path: string; content: string }> = [];
88
+ for (const path of selection.read) {
89
+ signal?.throwIfAborted();
90
+ if (!isLifecycleCurrent()) throw new Error("Context projection crossed a session lifecycle boundary");
91
+ try {
92
+ reads.push({ path, content: await readFile(resolve(root, path), "utf8") });
93
+ } catch (error) {
94
+ warnings.push(`${path}: ${error instanceof Error ? error.message : String(error)}`);
95
+ }
96
+ }
97
+ const outlined = await requestOutlineInjections(pi, {
98
+ cwd: root,
99
+ batchId: randomUUID(),
100
+ paths: selection.outline,
101
+ signal,
102
+ isLifecycleCurrent,
103
+ });
104
+ warnings.push(...outlined.warnings);
105
+ if (!isLifecycleCurrent()) throw new Error("Context projection crossed a session lifecycle boundary");
106
+ return [
107
+ "Active repository context (ephemeral; rebuilt from the current branch selection before each model call):",
108
+ ...selection.entries.map((entry) => `- ${entry.id}: ${entry.description}`),
109
+ ...(selection.missingEntryIds.length ? selection.missingEntryIds.map((id) => `- ${id}: unavailable`) : []),
110
+ "",
111
+ "Complete current file contents:",
112
+ ...(reads.length
113
+ ? reads.flatMap(({ path, content }) => [`<read path=${JSON.stringify(path)}>`, content, "</read>"])
114
+ : ["(none)"]),
115
+ "",
116
+ "Current structural outlines:",
117
+ ...(outlined.messages.length
118
+ ? outlined.messages.flatMap((message) => ["<outline>", message.content, "</outline>"])
119
+ : ["(none)"]),
120
+ "",
121
+ "Unloaded references:",
122
+ ...(selection.references.length ? selection.references.map((path) => `- ${path}`) : ["(none)"]),
123
+ ...(warnings.length ? ["", "Projection warnings:", ...warnings.map((warning) => `- ${warning}`)] : []),
124
+ "",
125
+ "Treat complete reads and outlines as current snapshots. Inspect unloaded references only when the request or concrete evidence requires them. Explore elsewhere only for missing information.",
126
+ ].join("\n");
127
+ }
128
+
129
+ export function contextProjectionMessage(content: string): ContextMessage {
130
+ return {
131
+ role: "custom",
132
+ customType: CONTEXT_PROJECTION_TYPE,
133
+ content,
134
+ display: false,
135
+ timestamp: Date.now(),
136
+ };
137
+ }
138
+
139
+ export function removeLegacyContextMessages(messages: readonly ContextMessage[]): ContextMessage[] {
140
+ return messages.filter((message) => !isContextProjectionMessage(message) && !isLegacyContextMessage(message));
141
+ }
@@ -0,0 +1,30 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const CONTEXT_SELECTION_TYPE = "tau.context.selection";
4
+
5
+ export interface ContextSelectionStateV1 {
6
+ v: 1;
7
+ entryIds: string[];
8
+ }
9
+
10
+ export function createContextSelectionState(entryIds: readonly string[]): ContextSelectionStateV1 {
11
+ return { v: 1, entryIds: [...new Set(entryIds)].sort((left, right) => left.localeCompare(right)) };
12
+ }
13
+
14
+ export function replayContextSelection(branch: readonly SessionEntry[]): ContextSelectionStateV1 {
15
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
16
+ const entry = branch[index];
17
+ if (entry?.type !== "custom" || entry.customType !== CONTEXT_SELECTION_TYPE) continue;
18
+ const state = parseContextSelectionState(entry.data);
19
+ if (state) return state;
20
+ }
21
+ return createContextSelectionState([]);
22
+ }
23
+
24
+ function parseContextSelectionState(value: unknown): ContextSelectionStateV1 | undefined {
25
+ if (!value || typeof value !== "object") return undefined;
26
+ const record = value as Record<string, unknown>;
27
+ if (record.v !== 1 || !Array.isArray(record.entryIds) || record.entryIds.some((id) => typeof id !== "string"))
28
+ return undefined;
29
+ return createContextSelectionState(record.entryIds as string[]);
30
+ }