@shanepadgett/tau-agent 0.29.0 → 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.
- package/docs/context.md +14 -9
- package/docs/subagents.md +7 -5
- package/extensions/cache-diagnostics/index.ts +3 -4
- package/extensions/context/README.md +8 -5
- package/extensions/context/definitions.ts +28 -17
- package/extensions/context/evidence.ts +4 -3
- package/extensions/context/index.ts +57 -35
- package/extensions/context/panel.ts +10 -9
- package/extensions/context/projection.ts +141 -0
- package/extensions/context/state.ts +30 -0
- package/extensions/explore/ast/read/hook.ts +3 -1
- package/extensions/ideas/browser.ts +27 -16
- package/extensions/review/README.md +11 -0
- package/extensions/review/index.ts +135 -0
- package/extensions/review/model.ts +144 -0
- package/extensions/review/panel.ts +128 -0
- package/extensions/review/session.ts +106 -0
- package/extensions/script-runner/README.md +7 -0
- package/extensions/script-runner/index.ts +275 -0
- package/extensions/stash/browser.ts +30 -18
- package/extensions/subagent/README.md +17 -4
- package/extensions/subagent/agents/context-sync.md +2 -2
- package/extensions/subagent/agents/scout.md +48 -61
- package/extensions/subagent/agents.ts +0 -1
- package/extensions/subagent/cmux-dashboard.ts +7 -3
- package/extensions/subagent/index.ts +105 -4
- package/extensions/subagent/panel.ts +124 -0
- package/extensions/subagent/render.ts +2 -1
- package/extensions/subagent/run.ts +9 -9
- package/extensions/subagent/runtime.ts +5 -5
- package/extensions/subagent/session-resource.ts +19 -127
- package/extensions/subagent/settings.ts +18 -0
- package/extensions/tau-help/help.md +10 -6
- package/extensions/working-memory/index.ts +21 -3
- package/extensions/working-memory/memory.ts +23 -5
- package/package.json +2 -2
- package/schemas/tau.schema.json +9 -23
- package/shared/context-messages.ts +19 -0
- package/shared/injected-context.ts +2 -2
- package/shared/isolated-session.ts +151 -0
- package/extensions/subagent/agents/review.md +0 -75
- package/extensions/turn-budget/README.md +0 -14
- package/extensions/turn-budget/index.ts +0 -116
- package/extensions/turn-budget/settings.ts +0 -35
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
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
53
|
+
## Choose loading modes
|
|
52
54
|
|
|
53
|
-
- `
|
|
54
|
-
- `
|
|
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
|
|
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: "
|
|
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: "
|
|
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 (`
|
|
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
|
-
- `
|
|
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
|
-
|
|
420
|
-
|
|
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",
|
|
@@ -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
|
|
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
|
-
|
|
40
|
+
read = []
|
|
41
|
+
outline = ["src/player/input.ts"]
|
|
42
|
+
references = []
|
|
41
43
|
|
|
42
44
|
[movement]
|
|
43
45
|
description = "Player locomotion and collision"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
read = []
|
|
47
|
+
outline = ["src/player/movement.ts"]
|
|
48
|
+
references = ["src/runtime/fetch-handler.ts"]
|
|
46
49
|
```
|
|
47
50
|
|
|
48
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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", "
|
|
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, "
|
|
103
|
-
return sortedUnique([...entry.
|
|
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.
|
|
136
|
-
record.
|
|
137
|
-
!Array.isArray(
|
|
138
|
-
|
|
139
|
-
(record.
|
|
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
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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:
|
|
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
|
-
|
|
350
|
-
|
|
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
|
|
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 (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
.
|
|
107
|
-
|
|
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" | "
|
|
157
|
+
private currentPaths(): Array<{ kind: "read" | "outline" | "reference"; path: string }> {
|
|
158
158
|
return [
|
|
159
|
-
...this.current.
|
|
160
|
-
...this.current.
|
|
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.
|
|
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", "
|
|
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
|
+
}
|