@shanepadgett/tau-agent 0.33.1 → 0.34.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 +29 -20
- package/docs/extending-tau-agent.md +63 -24
- package/extensions/appshot/index.ts +112 -111
- package/extensions/aside/README.md +7 -0
- package/extensions/aside/index.ts +168 -0
- package/extensions/aside/panel.ts +83 -0
- package/extensions/branch/index.ts +38 -21
- package/extensions/cache-diagnostics/index.ts +70 -58
- package/extensions/checkpoint/README.md +9 -0
- package/extensions/checkpoint/checkpoint-budget.ts +79 -0
- package/extensions/checkpoint/checkpoint.ts +277 -0
- package/extensions/checkpoint/index.ts +102 -0
- package/extensions/checkpoint/messages.ts +169 -0
- package/extensions/checkpoint/prompt.ts +24 -0
- package/extensions/checkpoint/settings.ts +27 -0
- package/extensions/commit/commit-effort.ts +3 -3
- package/extensions/context/README.md +12 -8
- package/extensions/context/definitions.ts +178 -55
- package/extensions/context/index.ts +192 -160
- package/extensions/context/panel.ts +57 -37
- package/extensions/context/sync.ts +139 -89
- package/extensions/context/validation.ts +37 -25
- package/extensions/effort/README.md +1 -1
- package/extensions/effort/index.ts +3 -3
- package/extensions/effort/state.ts +3 -18
- package/extensions/explore/guidance.ts +6 -47
- package/extensions/explore/index.ts +38 -53
- package/extensions/explore/{ast/read → read}/hook.ts +12 -7
- package/extensions/explore/{ast/tools → tools}/ast-search.ts +15 -19
- package/extensions/explore/{ast/tools → tools}/context.ts +16 -23
- package/extensions/explore/{ast/tools → tools}/deps.ts +11 -15
- package/extensions/explore/{ast/tools → tools}/discover.ts +15 -17
- package/extensions/explore/{ast/tools → tools}/impact.ts +20 -23
- package/extensions/explore/{ast/tools → tools}/outline.ts +21 -24
- package/extensions/explore/{ast/tools → tools}/relationships.ts +23 -32
- package/extensions/explore/{ast/tools → tools}/render.ts +1 -1
- package/extensions/explore/{ast/tools → tools}/reverse-deps.ts +11 -15
- package/extensions/explore/{ast/tools → tools}/show.ts +16 -19
- package/extensions/explore/tools/target-params.ts +9 -0
- package/extensions/footer/index.ts +75 -56
- package/extensions/handoff/index.ts +8 -30
- package/extensions/image-gen/index.ts +46 -40
- package/extensions/patch/executor.ts +190 -135
- package/extensions/patch/matcher.ts +82 -48
- package/extensions/patch/parser.ts +149 -105
- package/extensions/patch/render.ts +32 -39
- package/extensions/patch/summary.ts +30 -26
- package/extensions/ready/README.md +7 -0
- package/extensions/ready/index.ts +58 -0
- package/extensions/ready/model.ts +128 -0
- package/extensions/ready/packs.ts +272 -0
- package/extensions/ready/render.ts +445 -0
- package/extensions/ready/scan.ts +760 -0
- package/extensions/reference/panel.ts +28 -16
- package/extensions/review/index.ts +63 -51
- package/extensions/review/panel.ts +5 -34
- package/extensions/runtime-context/context.ts +30 -26
- package/extensions/script-runner/README.md +2 -2
- package/extensions/script-runner/index.ts +122 -82
- package/extensions/silent-command-runner/index.ts +38 -23
- package/extensions/subagent/agents/context-sync.md +185 -33
- package/extensions/subagent/agents/scout.md +2 -2
- package/extensions/subagent/agents.ts +137 -84
- package/extensions/subagent/cmux-dashboard.ts +39 -30
- package/extensions/subagent/index.ts +79 -59
- package/extensions/subagent/resume.ts +1 -1
- package/extensions/subagent/run.ts +304 -193
- package/extensions/subagent/runtime.ts +781 -343
- package/extensions/tau/index.ts +47 -38
- package/extensions/tau-help/help.md +14 -6
- package/extensions/tool-loader/README.md +4 -2
- package/extensions/tool-loader/index.ts +77 -52
- package/extensions/web/index.ts +6 -3
- package/extensions/web/webfetch.ts +114 -86
- package/package.json +3 -3
- package/schemas/tau.schema.json +20 -33
- package/shared/bounded-text-result.ts +1 -0
- package/shared/checkpoint-visibility.ts +9 -0
- package/shared/events.ts +28 -10
- package/shared/isolated-session.ts +71 -29
- package/shared/model-effort.ts +9 -19
- package/shared/model-fallback/index.ts +66 -41
- package/shared/settings/load.ts +45 -38
- package/shared/temporary-output-store.ts +16 -10
- package/{extensions/explore → src}/ast/adapter.ts +11 -2
- package/{extensions/explore → src}/ast/engine.ts +54 -38
- package/{extensions/explore → src}/ast/format/ast-search.ts +15 -6
- package/{extensions/explore → src}/ast/format/context.ts +3 -6
- package/src/ast/format/deps.ts +94 -0
- package/{extensions/explore → src}/ast/format/discover.ts +2 -2
- package/{extensions/explore → src}/ast/format/impact.ts +9 -11
- package/{extensions/explore → src}/ast/format/outline.ts +2 -2
- package/{extensions/explore → src}/ast/format/relationships.ts +11 -7
- package/{extensions/explore → src}/ast/format/show.ts +1 -1
- package/src/ast/format/targeted-result.ts +20 -0
- package/src/ast/grammars/c_sharp.wasm +0 -0
- package/{extensions/explore → src}/ast/grammars/manifest.json +9 -12
- package/{extensions/explore → src}/ast/grammars/manifest.ts +0 -4
- package/{extensions/explore → src}/ast/graph/file-graph.ts +210 -98
- package/{extensions/explore → src}/ast/graph/relationships.ts +285 -72
- package/{extensions/explore → src}/ast/ir.ts +6 -0
- package/src/ast/languages/csharp-file-deps.ts +90 -0
- package/{extensions/explore → src}/ast/languages/csharp.ts +9 -40
- package/src/ast/languages/dotted-index.ts +131 -0
- package/{extensions/explore → src}/ast/languages/file-dep-util.ts +22 -13
- package/src/ast/languages/fixtures/sample.cs +53 -0
- package/src/ast/languages/fixtures/sample.cs.expected.json +156 -0
- package/src/ast/languages/fixtures/sample.go +37 -0
- package/src/ast/languages/fixtures/sample.go.expected.json +96 -0
- package/src/ast/languages/fixtures/sample.java +40 -0
- package/src/ast/languages/fixtures/sample.java.expected.json +126 -0
- package/src/ast/languages/fixtures/sample.kt +31 -0
- package/src/ast/languages/fixtures/sample.kt.expected.json +134 -0
- package/src/ast/languages/fixtures/sample.md +23 -0
- package/src/ast/languages/fixtures/sample.md.expected.json +44 -0
- package/src/ast/languages/fixtures/sample.rs +54 -0
- package/src/ast/languages/fixtures/sample.rs.expected.json +178 -0
- package/src/ast/languages/fixtures/sample.swift +45 -0
- package/src/ast/languages/fixtures/sample.swift.expected.json +147 -0
- package/src/ast/languages/fixtures/sample.ts +45 -0
- package/src/ast/languages/fixtures/sample.ts.expected.json +141 -0
- package/src/ast/languages/fixtures/sample.tsx +36 -0
- package/src/ast/languages/fixtures/sample.tsx.expected.json +59 -0
- package/{extensions/explore → src}/ast/languages/go-file-deps.ts +6 -0
- package/{extensions/explore → src}/ast/languages/go.ts +21 -3
- package/{extensions/explore → src}/ast/languages/java.ts +5 -38
- package/src/ast/languages/jvm-file-deps.ts +135 -0
- package/{extensions/explore → src}/ast/languages/kotlin.ts +5 -39
- package/{extensions/explore → src}/ast/languages/rust-file-deps.ts +3 -3
- package/{extensions/explore → src}/ast/languages/rust.ts +21 -13
- package/{extensions/explore → src}/ast/languages/swift-file-deps.ts +22 -15
- package/{extensions/explore → src}/ast/languages/swift.ts +9 -39
- package/{extensions/explore → src}/ast/languages/tree.ts +47 -0
- package/{extensions/explore → src}/ast/languages/typescript-file-deps.ts +108 -0
- package/{extensions/explore → src}/ast/languages/typescript-package-surface.ts +4 -3
- package/{extensions/explore → src}/ast/languages/typescript.ts +3 -5
- package/{extensions/explore → src}/ast/markdown.ts +72 -47
- package/{extensions/explore → src}/ast/queries/ast-search.ts +164 -146
- package/{extensions/explore → src}/ast/queries/composite-target.ts +1 -1
- package/{extensions/explore → src}/ast/queries/context.ts +116 -80
- package/{extensions/explore → src}/ast/queries/discover.ts +154 -105
- package/src/ast/queries/impact.ts +291 -0
- package/{extensions/explore → src}/ast/queries/outline.ts +4 -2
- package/{extensions/explore → src}/ast/queries/show.ts +46 -42
- package/{extensions/explore → src}/ast/query.ts +1 -1
- package/{extensions/explore → src}/ast/registry.ts +1 -2
- package/{extensions/explore → src}/ast/scan.ts +1 -1
- package/src/ast/session.ts +50 -0
- package/{extensions/explore → src}/ast/slice.ts +8 -0
- package/{extensions/explore → src/ast}/traverse.ts +32 -20
- package/src/file-injection/index.ts +547 -0
- package/src/image-generation/client.ts +50 -31
- package/src/image-generation/index.ts +177 -134
- package/src/index.ts +2 -0
- package/src/tool-loading/index.ts +58 -0
- package/extensions/context/evidence.ts +0 -517
- package/extensions/context/projection.ts +0 -141
- package/extensions/context/state.ts +0 -30
- package/extensions/context/write-scope.ts +0 -109
- package/extensions/explore/ast/format/deps.ts +0 -86
- package/extensions/explore/ast/grammars/odin.wasm +0 -0
- package/extensions/explore/ast/languages/csharp-file-deps.ts +0 -189
- package/extensions/explore/ast/languages/fixtures/sample.cs +0 -17
- package/extensions/explore/ast/languages/fixtures/sample.java +0 -21
- package/extensions/explore/ast/languages/fixtures/sample.kt +0 -15
- package/extensions/explore/ast/languages/fixtures/sample.odin +0 -28
- package/extensions/explore/ast/languages/fixtures/sample.rs +0 -31
- package/extensions/explore/ast/languages/fixtures/sample.swift +0 -25
- package/extensions/explore/ast/languages/jvm-file-deps.ts +0 -217
- package/extensions/explore/ast/languages/odin-file-deps.ts +0 -175
- package/extensions/explore/ast/languages/odin.ts +0 -407
- package/extensions/explore/ast/queries/impact.ts +0 -250
- package/extensions/explore/outline-injection.ts +0 -151
- package/extensions/explore/read/autoread.ts +0 -118
- package/extensions/working-memory/README.md +0 -17
- package/extensions/working-memory/checkpoint.ts +0 -242
- package/extensions/working-memory/index.ts +0 -341
- package/extensions/working-memory/memory.ts +0 -235
- package/extensions/working-memory/render.ts +0 -185
- package/extensions/working-memory/settings.ts +0 -38
- package/extensions/working-memory/state.ts +0 -158
- package/shared/autoread.ts +0 -234
- package/shared/context-messages.ts +0 -19
- package/shared/outline-injection.ts +0 -56
- /package/{extensions/explore → src}/ast/format/composite.ts +0 -0
- /package/{extensions/explore → src}/ast/grammars/kotlin.wasm +0 -0
- /package/{extensions/explore → src}/ast/grammars/swift.wasm +0 -0
- /package/{extensions/explore → src}/ast/identity.ts +0 -0
- /package/{extensions/explore → src}/ast/package-surface.ts +0 -0
- /package/{extensions/explore/ast/read/policy.ts → src/ast/read-policy.ts} +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
|
|
3
|
+
import { setCheckpointRowsVisible } from "../../shared/checkpoint-visibility.ts";
|
|
4
|
+
import { CHECKPOINT_TOOL, registerCheckpointTool } from "./checkpoint.ts";
|
|
5
|
+
import { createCheckpointBudget, type CheckpointBudgetNoticeLevel } from "./checkpoint-budget.ts";
|
|
6
|
+
import { projectContextMessages } from "./messages.ts";
|
|
7
|
+
import { CHECKPOINT_SYSTEM_GUIDANCE, formatCheckpointMessage } from "./prompt.ts";
|
|
8
|
+
import checkpointSettings from "./settings.ts";
|
|
9
|
+
|
|
10
|
+
export default function checkpointExtension(pi: ExtensionAPI): void {
|
|
11
|
+
const budget = createCheckpointBudget();
|
|
12
|
+
let checkpointSucceeded = false;
|
|
13
|
+
|
|
14
|
+
const resetBudget = (): void => {
|
|
15
|
+
checkpointSucceeded = false;
|
|
16
|
+
budget.reset();
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
registerCheckpointTool(pi);
|
|
20
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
21
|
+
const settings = await loadTauExtensionSettings(ctx, checkpointSettings);
|
|
22
|
+
budget.configure(settings.checkpointTokenLimit);
|
|
23
|
+
setCheckpointRowsVisible(settings.showToolRows);
|
|
24
|
+
});
|
|
25
|
+
pi.on("session_shutdown", () => {
|
|
26
|
+
setCheckpointRowsVisible(false);
|
|
27
|
+
resetBudget();
|
|
28
|
+
});
|
|
29
|
+
pi.on("session_compact", resetBudget);
|
|
30
|
+
pi.on("session_tree", resetBudget);
|
|
31
|
+
pi.on("session_before_fork", resetBudget);
|
|
32
|
+
pi.on("session_before_switch", resetBudget);
|
|
33
|
+
pi.on("before_agent_start", (event) => ({
|
|
34
|
+
systemPrompt: `${event.systemPrompt}\n\n${CHECKPOINT_SYSTEM_GUIDANCE}`,
|
|
35
|
+
}));
|
|
36
|
+
pi.on("turn_start", (_event, ctx) => {
|
|
37
|
+
if (checkpointSucceeded) resetBudget();
|
|
38
|
+
const noticeLevel = budget.beginTurn(ctx.getContextUsage()?.tokens ?? null);
|
|
39
|
+
if (noticeLevel !== undefined) sendCheckpointBudgetNotice(pi, noticeLevel);
|
|
40
|
+
});
|
|
41
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
42
|
+
if (checkpointSucceeded) {
|
|
43
|
+
resetBudget();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const noticeLevel = budget.finishTurn(ctx.getContextUsage()?.tokens ?? null);
|
|
47
|
+
if (noticeLevel !== undefined) sendCheckpointBudgetNotice(pi, noticeLevel);
|
|
48
|
+
});
|
|
49
|
+
pi.on("tool_result", (event) => {
|
|
50
|
+
if (event.toolName === CHECKPOINT_TOOL && !event.isError) checkpointSucceeded = true;
|
|
51
|
+
});
|
|
52
|
+
pi.on("tool_call", (event) => {
|
|
53
|
+
if (!budget.shouldBlockTool(event.toolName, CHECKPOINT_TOOL)) return;
|
|
54
|
+
return {
|
|
55
|
+
block: true,
|
|
56
|
+
reason: formatCheckpointMessage(
|
|
57
|
+
"block",
|
|
58
|
+
"Checkpoint required before using other tools. Call checkpoint, then continue the user's work.",
|
|
59
|
+
),
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
pi.on("context", (event, ctx) => ({
|
|
63
|
+
messages: projectContextMessages(event.messages, ctx.sessionManager.buildContextEntries()),
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sendCheckpointBudgetNotice(pi: ExtensionAPI, level: CheckpointBudgetNoticeLevel): void {
|
|
68
|
+
pi.sendMessage({
|
|
69
|
+
customType: "tau.checkpoint",
|
|
70
|
+
content: checkpointBudgetMessage(level),
|
|
71
|
+
display: false,
|
|
72
|
+
details: {
|
|
73
|
+
v: 1,
|
|
74
|
+
kind: "checkpoint.checkpoint-budget",
|
|
75
|
+
source: "checkpoint",
|
|
76
|
+
level,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function checkpointBudgetMessage(level: CheckpointBudgetNoticeLevel): string {
|
|
82
|
+
switch (level) {
|
|
83
|
+
case 50:
|
|
84
|
+
return formatCheckpointMessage(
|
|
85
|
+
"budget",
|
|
86
|
+
"Context usage reached 50% of the configured checkpoint limit. Continue current work and checkpoint when convenient.",
|
|
87
|
+
{ level: "50" },
|
|
88
|
+
);
|
|
89
|
+
case 75:
|
|
90
|
+
return formatCheckpointMessage(
|
|
91
|
+
"budget",
|
|
92
|
+
"Context usage reached 75% of the configured checkpoint limit. Run checkpoint soon. At 100%, non-checkpoint tool calls will be blocked until checkpoint succeeds.",
|
|
93
|
+
{ level: "75" },
|
|
94
|
+
);
|
|
95
|
+
case 100:
|
|
96
|
+
return formatCheckpointMessage(
|
|
97
|
+
"budget",
|
|
98
|
+
"Checkpoint budget reached. Call checkpoint now before using any other tool. Do not call other tools.",
|
|
99
|
+
{ level: "100" },
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { AssistantMessage, TextContent, UserMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import { sessionEntryToContextMessages, type ContextEvent, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { formatCheckpointMessage } from "./prompt.ts";
|
|
4
|
+
|
|
5
|
+
type AgentMessage = ContextEvent["messages"][number];
|
|
6
|
+
type ContextPair = { entry: SessionEntry; message: AgentMessage };
|
|
7
|
+
|
|
8
|
+
const CHECKPOINT_KIND = "checkpoint.checkpoint";
|
|
9
|
+
const MESSAGE_ID_METADATA_TYPE = "tau.checkpoint.message-id";
|
|
10
|
+
|
|
11
|
+
type CheckpointMetadataMessage = Extract<AgentMessage, { role: "custom" }>;
|
|
12
|
+
|
|
13
|
+
type CheckpointAnchor = {
|
|
14
|
+
call: ContextPair;
|
|
15
|
+
result: ContextPair;
|
|
16
|
+
resultIndex: number;
|
|
17
|
+
toolCallIds: Set<string>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function pairsAlignWithMessages(pairs: readonly ContextPair[], messages: readonly AgentMessage[]): boolean {
|
|
21
|
+
if (pairs.length !== messages.length) return false;
|
|
22
|
+
return !pairs.some((pair, index) => {
|
|
23
|
+
const expected = messageKey(pair.message);
|
|
24
|
+
const actual = messageKey(messages[index]);
|
|
25
|
+
return expected === undefined || actual === undefined || expected !== actual;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function findCheckpointResult(
|
|
30
|
+
pairs: readonly ContextPair[],
|
|
31
|
+
): { result: ContextPair; resultIndex: number; checkpointId: string; fileBatchId: string } | undefined {
|
|
32
|
+
for (let resultIndex = pairs.length - 1; resultIndex >= 0; resultIndex--) {
|
|
33
|
+
const result = pairs[resultIndex];
|
|
34
|
+
if (result.entry.type !== "message" || result.message.role !== "toolResult") continue;
|
|
35
|
+
const details = checkpointDetails(result.message.details);
|
|
36
|
+
if (details === undefined) continue;
|
|
37
|
+
return { result, resultIndex, checkpointId: details.checkpointId, fileBatchId: details.fileBatchId };
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveCheckpointCall(
|
|
43
|
+
pairs: readonly ContextPair[],
|
|
44
|
+
resultIndex: number,
|
|
45
|
+
checkpointId: string,
|
|
46
|
+
): { call: ContextPair; toolCallIds: Set<string> } | "misaligned" {
|
|
47
|
+
let callIndex = resultIndex - 1;
|
|
48
|
+
while (callIndex >= 0 && !hasCheckpointToolCall(pairs[callIndex].message, checkpointId)) callIndex--;
|
|
49
|
+
if (callIndex < 0) return "misaligned";
|
|
50
|
+
const call = pairs[callIndex];
|
|
51
|
+
if (call.message.role !== "assistant") return "misaligned";
|
|
52
|
+
const toolCallIds = new Set<string>();
|
|
53
|
+
for (const part of call.message.content) {
|
|
54
|
+
if (part.type === "toolCall") toolCallIds.add(part.id);
|
|
55
|
+
}
|
|
56
|
+
return { call, toolCallIds };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function findCheckpointAnchor(pairs: readonly ContextPair[]): CheckpointAnchor | "misaligned" | undefined {
|
|
60
|
+
const found = findCheckpointResult(pairs);
|
|
61
|
+
if (found === undefined) return undefined;
|
|
62
|
+
const resolved = resolveCheckpointCall(pairs, found.resultIndex, found.checkpointId);
|
|
63
|
+
if (resolved === "misaligned") return "misaligned";
|
|
64
|
+
return {
|
|
65
|
+
call: resolved.call,
|
|
66
|
+
result: found.result,
|
|
67
|
+
resultIndex: found.resultIndex,
|
|
68
|
+
toolCallIds: resolved.toolCallIds,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function keepCheckpointPair(
|
|
73
|
+
checkpoint: CheckpointAnchor | undefined,
|
|
74
|
+
entry: SessionEntry,
|
|
75
|
+
message: AgentMessage,
|
|
76
|
+
index: number,
|
|
77
|
+
): boolean {
|
|
78
|
+
if (checkpoint === undefined) return true;
|
|
79
|
+
if (index > checkpoint.resultIndex) return true;
|
|
80
|
+
if (entry.id === checkpoint.call.entry.id || entry.id === checkpoint.result.entry.id) return true;
|
|
81
|
+
return message.role === "toolResult" && checkpoint.toolCallIds.has(message.toolCallId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function expandPairMessages(entry: SessionEntry, message: AgentMessage): AgentMessage[] {
|
|
85
|
+
if (entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant")) {
|
|
86
|
+
return [createMessageIdMetadata(entry.id, message), message];
|
|
87
|
+
}
|
|
88
|
+
return [message];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Prunes completed checkpoint history and adds hidden provider-context message IDs. */
|
|
92
|
+
export function projectContextMessages(
|
|
93
|
+
messages: readonly AgentMessage[],
|
|
94
|
+
entries: readonly SessionEntry[],
|
|
95
|
+
): AgentMessage[] {
|
|
96
|
+
const pairs: ContextPair[] = [];
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
for (const message of sessionEntryToContextMessages(entry)) pairs.push({ entry, message });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const checkpoint = findCheckpointAnchor(pairs);
|
|
102
|
+
if (checkpoint === "misaligned") return [...messages];
|
|
103
|
+
if (checkpoint === undefined && !pairsAlignWithMessages(pairs, messages)) return [...messages];
|
|
104
|
+
|
|
105
|
+
return pairs
|
|
106
|
+
.filter(({ entry, message }, index) => keepCheckpointPair(checkpoint, entry, message, index))
|
|
107
|
+
.flatMap(({ entry, message }) => expandPairMessages(entry, message));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Extracts only the original text from a user or assistant message. */
|
|
111
|
+
export function extractConversationText(message: AgentMessage): string | undefined {
|
|
112
|
+
if (message.role === "user") return userText(message);
|
|
113
|
+
if (message.role === "assistant") return assistantText(message);
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function messageKey(message: AgentMessage): string | undefined {
|
|
118
|
+
return JSON.stringify(message, (key, value) => (key === "timestamp" ? undefined : value));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function checkpointDetails(details: unknown): { checkpointId: string; fileBatchId: string } | undefined {
|
|
122
|
+
if (typeof details !== "object" || details === null) return undefined;
|
|
123
|
+
const value = details as Record<string, unknown>;
|
|
124
|
+
if (
|
|
125
|
+
value.v !== 1 ||
|
|
126
|
+
value.kind !== CHECKPOINT_KIND ||
|
|
127
|
+
typeof value.checkpointId !== "string" ||
|
|
128
|
+
typeof value.fileBatchId !== "string"
|
|
129
|
+
)
|
|
130
|
+
return undefined;
|
|
131
|
+
return { checkpointId: value.checkpointId, fileBatchId: value.fileBatchId };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function hasCheckpointToolCall(message: AgentMessage, checkpointId: string): boolean {
|
|
135
|
+
return (
|
|
136
|
+
message.role === "assistant" &&
|
|
137
|
+
message.content.some((part) => part.type === "toolCall" && part.name === "checkpoint" && part.id === checkpointId)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function createMessageIdMetadata(id: string, message: AgentMessage): CheckpointMetadataMessage {
|
|
142
|
+
const role = message.role === "user" ? "user" : "assistant";
|
|
143
|
+
return {
|
|
144
|
+
role: "custom",
|
|
145
|
+
customType: MESSAGE_ID_METADATA_TYPE,
|
|
146
|
+
content: formatCheckpointMessage(
|
|
147
|
+
"message-id",
|
|
148
|
+
`<message-id>${JSON.stringify(id)}</message-id>\n<message-role>${role}</message-role>`,
|
|
149
|
+
),
|
|
150
|
+
display: false,
|
|
151
|
+
details: { v: 1, kind: "checkpoint.message-id", id, role },
|
|
152
|
+
timestamp: message.timestamp,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function userText(message: UserMessage): string {
|
|
157
|
+
if (typeof message.content === "string") return message.content;
|
|
158
|
+
return message.content
|
|
159
|
+
.filter((part): part is TextContent => part.type === "text")
|
|
160
|
+
.map((part) => part.text)
|
|
161
|
+
.join("");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function assistantText(message: AssistantMessage): string {
|
|
165
|
+
return message.content
|
|
166
|
+
.filter((part): part is TextContent => part.type === "text")
|
|
167
|
+
.map((part) => part.text)
|
|
168
|
+
.join("");
|
|
169
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Standing checkpoint guidance appended to the system prompt. */
|
|
2
|
+
export const CHECKPOINT_SYSTEM_GUIDANCE = `## Checkpoint
|
|
3
|
+
|
|
4
|
+
Checkpoint is a hidden context-management system. It retires disposable history so long-running work can continue without user-visible ceremony.
|
|
5
|
+
|
|
6
|
+
Messages wrapped in \`<checkpoint>...</checkpoint>\` are internal control signals, not conversation with the user.
|
|
7
|
+
|
|
8
|
+
- Never acknowledge checkpoint system messages, budget notices, blocked tools, checkpoint metadata or IDs, injected checkpoint files as a transition, or context replacement.
|
|
9
|
+
- Never mention the checkpoint system itself.
|
|
10
|
+
- When checkpoint asks you to run the checkpoint tool, or when non-checkpoint tools are blocked, call \`checkpoint\` and continue the user's work immediately. Do not narrate the system.
|
|
11
|
+
- Stay focused on the work the user wants done.
|
|
12
|
+
- Use exact IDs from \`<checkpoint kind="message-id">\` metadata only in \`checkpoint.keepMessages\`.`;
|
|
13
|
+
|
|
14
|
+
/** Wraps an internal checkpoint control payload for the model. */
|
|
15
|
+
export function formatCheckpointMessage(
|
|
16
|
+
kind: string,
|
|
17
|
+
body: string,
|
|
18
|
+
attributes: Readonly<Record<string, string>> = {},
|
|
19
|
+
): string {
|
|
20
|
+
const attrs = Object.entries(attributes)
|
|
21
|
+
.map(([key, value]) => ` ${key}=${JSON.stringify(value)}`)
|
|
22
|
+
.join("");
|
|
23
|
+
return `<checkpoint kind=${JSON.stringify(kind)}${attrs}>\n${body}\n</checkpoint>`;
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { defineTauExtensionSettings } from "../../shared/settings/define.ts";
|
|
3
|
+
import { DEFAULT_CHECKPOINT_TOKEN_LIMIT } from "./checkpoint-budget.ts";
|
|
4
|
+
|
|
5
|
+
export default defineTauExtensionSettings({
|
|
6
|
+
key: "checkpoint",
|
|
7
|
+
defaults: {
|
|
8
|
+
showToolRows: false as boolean,
|
|
9
|
+
checkpointTokenLimit: DEFAULT_CHECKPOINT_TOKEN_LIMIT,
|
|
10
|
+
},
|
|
11
|
+
schema: Type.Object(
|
|
12
|
+
{
|
|
13
|
+
showToolRows: Type.Optional(
|
|
14
|
+
Type.Boolean({
|
|
15
|
+
default: false,
|
|
16
|
+
description: "Show checkpoint and injected-file rows in the TUI for debugging.",
|
|
17
|
+
}),
|
|
18
|
+
),
|
|
19
|
+
checkpointTokenLimit: Type.Integer({
|
|
20
|
+
minimum: 1,
|
|
21
|
+
default: DEFAULT_CHECKPOINT_TOKEN_LIMIT,
|
|
22
|
+
description: "Context-token ceiling before a checkpoint is required.",
|
|
23
|
+
}),
|
|
24
|
+
},
|
|
25
|
+
{ additionalProperties: false },
|
|
26
|
+
),
|
|
27
|
+
});
|
|
@@ -6,9 +6,9 @@ const LOW_EFFORT_MAX_CHANGED_LINES = 500;
|
|
|
6
6
|
export function commitEffort(files: readonly Pick<DirtyFile, "changeSize">[]): ModelEffort {
|
|
7
7
|
let changedLines = 0;
|
|
8
8
|
for (const file of files) {
|
|
9
|
-
if (file.changeSize === "binary") return "
|
|
9
|
+
if (file.changeSize === "binary") return "standard";
|
|
10
10
|
changedLines += file.changeSize;
|
|
11
|
-
if (changedLines > LOW_EFFORT_MAX_CHANGED_LINES) return "
|
|
11
|
+
if (changedLines > LOW_EFFORT_MAX_CHANGED_LINES) return "standard";
|
|
12
12
|
}
|
|
13
|
-
return "
|
|
13
|
+
return "quick";
|
|
14
14
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Context
|
|
2
2
|
|
|
3
|
-
Context stores reusable repository work scopes in `.pi/contexts`.
|
|
3
|
+
Context stores reusable repository work scopes in `.pi/contexts`. Domain folders use `NN_slug` (`01_extensions`, `02_core`, …) so `/context` can order tabs; the UI shows the slug only. TOML files become concepts, and TOML sections become selectable entries.
|
|
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 inject entries into the conversation. Selecting entries reads `read` paths in full, resolves `show` targets to current declaration slices, outlines `outline` paths, and adds one hidden note listing `references` and instructing the agent to treat the injected material as current. Injection happens once, immediately, before your next prompt; nothing is rebuilt on later model calls. Prefer complete reads for bounded work files; use `show` for thin contracts from larger neighbors; reserve `outline` for large or noisy files.
|
|
8
8
|
|
|
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
|
|
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 as **work packs** (job-shaped entries an agent can start from), not bare path indexes. 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>`; the command replaces the editor with a status panel (Escape / Ctrl+C cancel). It uses normal repo tools, walks domain → concept → entry → start-pack modes, and prefers `patch` under `.pi/contexts`. The harness verifies catalog coverage afterward. Optional nudge text soft-steers judgment (including quality rewrites of weak bags) without skipping the ladder or coverage.
|
|
10
10
|
|
|
11
11
|
Sync surface is configurable:
|
|
12
12
|
|
|
@@ -37,15 +37,19 @@ description = "Player-owned gameplay systems"
|
|
|
37
37
|
|
|
38
38
|
[input]
|
|
39
39
|
description = "Input mapping and command handling"
|
|
40
|
-
read = []
|
|
41
|
-
|
|
40
|
+
read = ["src/player/input.ts"]
|
|
41
|
+
show = []
|
|
42
|
+
outline = []
|
|
42
43
|
references = []
|
|
43
44
|
|
|
44
45
|
[movement]
|
|
45
46
|
description = "Player locomotion and collision"
|
|
46
|
-
read = []
|
|
47
|
-
|
|
47
|
+
read = ["src/player/movement.ts"]
|
|
48
|
+
show = [
|
|
49
|
+
{ path = "src/runtime/fetch-handler.ts", name = "handleFetch" },
|
|
50
|
+
]
|
|
51
|
+
outline = []
|
|
48
52
|
references = ["src/runtime/fetch-handler.ts"]
|
|
49
53
|
```
|
|
50
54
|
|
|
51
|
-
Every entry must declare all
|
|
55
|
+
Every entry must declare all four loading arrays. `read` supplies exact complete contents, `show` resolves path+name declaration slices, `outline` asks Explore for structural outlines, and `references` lists unloaded paths. If selected entries classify the same path differently, precedence is `read`, then `show`, then `outline`, then `references`.
|
|
@@ -4,6 +4,14 @@ import { basename, dirname, extname, join, relative, resolve, sep } from "node:p
|
|
|
4
4
|
import { parse } from "smol-toml";
|
|
5
5
|
import { matchGlob } from "../../shared/glob.ts";
|
|
6
6
|
|
|
7
|
+
export type ContextShowView = "signature" | "signatureWithDocs" | "declaration" | "declarationWithImports";
|
|
8
|
+
|
|
9
|
+
export interface ContextShowTarget {
|
|
10
|
+
path: string;
|
|
11
|
+
name: string;
|
|
12
|
+
view: ContextShowView;
|
|
13
|
+
}
|
|
14
|
+
|
|
7
15
|
export interface ContextEntry {
|
|
8
16
|
id: string;
|
|
9
17
|
tab: string;
|
|
@@ -13,6 +21,7 @@ export interface ContextEntry {
|
|
|
13
21
|
name: string;
|
|
14
22
|
description: string;
|
|
15
23
|
read: string[];
|
|
24
|
+
show: ContextShowTarget[];
|
|
16
25
|
outline: string[];
|
|
17
26
|
references: string[];
|
|
18
27
|
path: string;
|
|
@@ -38,7 +47,14 @@ const CONTEXT_IGNORED_FILENAMES = new Set([
|
|
|
38
47
|
"uv.lock",
|
|
39
48
|
"yarn.lock",
|
|
40
49
|
]);
|
|
41
|
-
const CONTEXT_ENTRY_FIELDS = new Set(["description", "read", "outline", "references"]);
|
|
50
|
+
const CONTEXT_ENTRY_FIELDS = new Set(["description", "read", "show", "outline", "references"]);
|
|
51
|
+
const CONTEXT_SHOW_VIEWS = new Set<ContextShowView>([
|
|
52
|
+
"signature",
|
|
53
|
+
"signatureWithDocs",
|
|
54
|
+
"declaration",
|
|
55
|
+
"declarationWithImports",
|
|
56
|
+
]);
|
|
57
|
+
const CONTEXT_SHOW_TARGET_FIELDS = new Set(["path", "name", "view"]);
|
|
42
58
|
|
|
43
59
|
export function isContextEligiblePath(path: string, ignoreGlobs: readonly string[] = []): boolean {
|
|
44
60
|
return (
|
|
@@ -84,12 +100,48 @@ export async function findProjectRoot(cwd: string): Promise<string> {
|
|
|
84
100
|
return gitRoot ?? resolve(cwd);
|
|
85
101
|
}
|
|
86
102
|
|
|
103
|
+
const TAB_FOLDER_PATTERN = /^(\d{2})_([a-z0-9]+(?:-[a-z0-9]+)*)$/;
|
|
104
|
+
|
|
87
105
|
function validSlug(value: string, label: string): string {
|
|
88
106
|
const slug = value.trim();
|
|
89
107
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) throw new Error(`${label} must use lowercase kebab-case: ${value}`);
|
|
90
108
|
return slug;
|
|
91
109
|
}
|
|
92
110
|
|
|
111
|
+
/** Domain folder: `01_extensions` → order 1, tab slug `extensions` (id/label). */
|
|
112
|
+
function parseContextTabFolder(name: string): { order: number; slug: string; folder: string } {
|
|
113
|
+
const match = TAB_FOLDER_PATTERN.exec(name);
|
|
114
|
+
const digits = match?.[1];
|
|
115
|
+
const slug = match?.[2];
|
|
116
|
+
if (!match || digits === undefined || slug === undefined) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Context tab folder must be NN_kebab-slug with a two-digit order starting at 01 (e.g. 01_extensions): ${name}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const order = Number(digits);
|
|
122
|
+
if (!Number.isInteger(order) || order < 1 || order > 99) {
|
|
123
|
+
throw new Error(`Context tab order must be an integer from 01 to 99: ${name}`);
|
|
124
|
+
}
|
|
125
|
+
return { order, slug, folder: name };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assertContiguousTabOrders(tabs: readonly { order: number; slug: string; folder: string }[]): void {
|
|
129
|
+
const byOrder = [...tabs].sort((left, right) => left.order - right.order);
|
|
130
|
+
const seenSlugs = new Set<string>();
|
|
131
|
+
for (let index = 0; index < byOrder.length; index++) {
|
|
132
|
+
const tab = byOrder[index];
|
|
133
|
+
if (!tab) continue;
|
|
134
|
+
const expected = index + 1;
|
|
135
|
+
if (tab.order !== expected) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Context tab orders must be contiguous from 01 (expected ${String(expected).padStart(2, "0")}_…, found ${tab.folder})`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (seenSlugs.has(tab.slug)) throw new Error(`Duplicate context tab slug: ${tab.slug}`);
|
|
141
|
+
seenSlugs.add(tab.slug);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
93
145
|
function normalizeProjectPath(root: string, input: string): string {
|
|
94
146
|
const absolute = resolve(root, input.trim().replace(/^@/, ""));
|
|
95
147
|
const path = relative(root, absolute).split(sep).join("/");
|
|
@@ -102,8 +154,126 @@ function sortedUnique(values: readonly string[]): string[] {
|
|
|
102
154
|
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
103
155
|
}
|
|
104
156
|
|
|
105
|
-
export function contextEntryPaths(entry: Pick<ContextEntry, "read" | "outline" | "references">): string[] {
|
|
106
|
-
return sortedUnique([
|
|
157
|
+
export function contextEntryPaths(entry: Pick<ContextEntry, "read" | "show" | "outline" | "references">): string[] {
|
|
158
|
+
return sortedUnique([
|
|
159
|
+
...entry.read,
|
|
160
|
+
...entry.show.map((target) => target.path),
|
|
161
|
+
...entry.outline,
|
|
162
|
+
...entry.references,
|
|
163
|
+
]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseOneShowTarget(root: string, catalogPath: string, entryName: string, item: unknown): ContextShowTarget {
|
|
167
|
+
const record = item as Record<string, unknown>;
|
|
168
|
+
const unknownField = Object.keys(record).find((field) => !CONTEXT_SHOW_TARGET_FIELDS.has(field));
|
|
169
|
+
if (unknownField) throw new Error(`Invalid context show field: ${catalogPath} [${entryName}] ${unknownField}`);
|
|
170
|
+
if (typeof record.path !== "string" || !record.path.trim() || typeof record.name !== "string" || !record.name.trim())
|
|
171
|
+
throw new Error(`Invalid context show target: ${catalogPath} [${entryName}]`);
|
|
172
|
+
let view: ContextShowView = "declaration";
|
|
173
|
+
if (record.view !== undefined) {
|
|
174
|
+
if (typeof record.view !== "string" || !CONTEXT_SHOW_VIEWS.has(record.view as ContextShowView))
|
|
175
|
+
throw new Error(`Invalid context show view: ${catalogPath} [${entryName}] ${String(record.view)}`);
|
|
176
|
+
view = record.view as ContextShowView;
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
path: normalizeProjectPath(root, record.path),
|
|
180
|
+
name: record.name.trim(),
|
|
181
|
+
view,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function dedupeShowTargets(catalogPath: string, entryName: string, targets: ContextShowTarget[]): ContextShowTarget[] {
|
|
186
|
+
const seen = new Map<string, ContextShowTarget>();
|
|
187
|
+
for (const target of targets) {
|
|
188
|
+
const key = `${target.path}\0${target.name}`;
|
|
189
|
+
const existing = seen.get(key);
|
|
190
|
+
if (existing && existing.view !== target.view)
|
|
191
|
+
throw new Error(
|
|
192
|
+
`Context show target has multiple views: ${catalogPath} [${entryName}] ${target.path} ${target.name}`,
|
|
193
|
+
);
|
|
194
|
+
if (!existing) seen.set(key, target);
|
|
195
|
+
}
|
|
196
|
+
return [...seen.values()].sort(
|
|
197
|
+
(left, right) => left.path.localeCompare(right.path) || left.name.localeCompare(right.name),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parseShowTargets(root: string, catalogPath: string, entryName: string, raw: unknown): ContextShowTarget[] {
|
|
202
|
+
if (!Array.isArray(raw) || raw.some((item) => !item || typeof item !== "object" || Array.isArray(item)))
|
|
203
|
+
throw new Error(`Invalid context entry show list: ${catalogPath} [${entryName}]`);
|
|
204
|
+
return dedupeShowTargets(
|
|
205
|
+
catalogPath,
|
|
206
|
+
entryName,
|
|
207
|
+
raw.map((item) => parseOneShowTarget(root, catalogPath, entryName, item)),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function parseContextEntryRecord(
|
|
212
|
+
root: string,
|
|
213
|
+
path: string,
|
|
214
|
+
tab: string,
|
|
215
|
+
concept: string,
|
|
216
|
+
conceptName: string,
|
|
217
|
+
conceptDescription: string,
|
|
218
|
+
name: string,
|
|
219
|
+
value: unknown,
|
|
220
|
+
): ContextEntry {
|
|
221
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
222
|
+
throw new Error(`Invalid context entry: ${path} [${name}]`);
|
|
223
|
+
const record = value as Record<string, unknown>;
|
|
224
|
+
const unknownField = Object.keys(record).find((field) => !CONTEXT_ENTRY_FIELDS.has(field));
|
|
225
|
+
if (unknownField) throw new Error(`Invalid context entry field: ${path} [${name}] ${unknownField}`);
|
|
226
|
+
if (
|
|
227
|
+
typeof record.description !== "string" ||
|
|
228
|
+
!record.description.trim() ||
|
|
229
|
+
!Array.isArray(record.read) ||
|
|
230
|
+
record.read.some((item) => typeof item !== "string") ||
|
|
231
|
+
!Array.isArray(record.outline) ||
|
|
232
|
+
record.outline.some((item) => typeof item !== "string") ||
|
|
233
|
+
!Array.isArray(record.references) ||
|
|
234
|
+
record.references.some((item) => typeof item !== "string")
|
|
235
|
+
)
|
|
236
|
+
throw new Error(`Invalid context entry: ${path} [${name}]`);
|
|
237
|
+
const entry = validSlug(name, "Context entry");
|
|
238
|
+
const entryRead = sortedUnique((record.read as string[]).map((item) => normalizeProjectPath(root, item)));
|
|
239
|
+
const entryShow = parseShowTargets(root, path, name, record.show ?? []);
|
|
240
|
+
const entryOutline = sortedUnique((record.outline as string[]).map((item) => normalizeProjectPath(root, item)));
|
|
241
|
+
const entryReferences = sortedUnique(
|
|
242
|
+
(record.references as string[]).map((item) => normalizeProjectPath(root, item)),
|
|
243
|
+
);
|
|
244
|
+
if (entryRead.length === 0 && entryShow.length === 0 && entryOutline.length === 0 && entryReferences.length === 0)
|
|
245
|
+
throw new Error(`Invalid context entry: ${path} [${name}]`);
|
|
246
|
+
const classified = [...entryRead, ...entryOutline, ...entryReferences];
|
|
247
|
+
const overlap = classified.find((item, index) => classified.indexOf(item) !== index);
|
|
248
|
+
if (overlap) throw new Error(`Context path has multiple loading modes: ${path} [${name}] ${overlap}`);
|
|
249
|
+
return {
|
|
250
|
+
id: `${tab}/${concept}/${entry}`,
|
|
251
|
+
tab,
|
|
252
|
+
concept,
|
|
253
|
+
conceptName,
|
|
254
|
+
conceptDescription,
|
|
255
|
+
name: entry,
|
|
256
|
+
description: record.description.trim(),
|
|
257
|
+
read: entryRead,
|
|
258
|
+
show: entryShow,
|
|
259
|
+
outline: entryOutline,
|
|
260
|
+
references: entryReferences,
|
|
261
|
+
path,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function loadConceptFile(root: string, tab: string, folder: string, fileName: string): Promise<ContextEntry[]> {
|
|
266
|
+
const path = join(root, ".pi", "contexts", folder, fileName);
|
|
267
|
+
const concept = validSlug(basename(fileName, ".toml"), "Context concept");
|
|
268
|
+
const raw = parse(await readFile(path, "utf8")) as Record<string, unknown>;
|
|
269
|
+
const conceptName = typeof raw.name === "string" && raw.name.trim() ? raw.name.trim() : concept;
|
|
270
|
+
const conceptDescription = typeof raw.description === "string" ? raw.description.trim() : "";
|
|
271
|
+
const entries: ContextEntry[] = [];
|
|
272
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
273
|
+
if (name === "name" || name === "description") continue;
|
|
274
|
+
entries.push(parseContextEntryRecord(root, path, tab, concept, conceptName, conceptDescription, name, value));
|
|
275
|
+
}
|
|
276
|
+
return entries;
|
|
107
277
|
}
|
|
108
278
|
|
|
109
279
|
export async function loadContextEntries(root: string): Promise<ContextEntry[]> {
|
|
@@ -111,63 +281,16 @@ export async function loadContextEntries(root: string): Promise<ContextEntry[]>
|
|
|
111
281
|
if (!(await pathExists(contextsRoot))) return [];
|
|
112
282
|
const tabs = (await readdir(contextsRoot, { withFileTypes: true }))
|
|
113
283
|
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
|
114
|
-
.
|
|
284
|
+
.map((entry) => parseContextTabFolder(entry.name));
|
|
285
|
+
assertContiguousTabOrders(tabs);
|
|
286
|
+
tabs.sort((left, right) => left.order - right.order);
|
|
115
287
|
const result: ContextEntry[] = [];
|
|
116
288
|
for (const tabEntry of tabs) {
|
|
117
|
-
const
|
|
118
|
-
const files = (await readdir(join(contextsRoot, tab), { withFileTypes: true }))
|
|
289
|
+
const files = (await readdir(join(contextsRoot, tabEntry.folder), { withFileTypes: true }))
|
|
119
290
|
.filter((entry) => entry.isFile() && extname(entry.name) === ".toml")
|
|
120
291
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
121
292
|
for (const file of files) {
|
|
122
|
-
|
|
123
|
-
const concept = validSlug(basename(file.name, ".toml"), "Context concept");
|
|
124
|
-
const raw = parse(await readFile(path, "utf8")) as Record<string, unknown>;
|
|
125
|
-
const conceptName = typeof raw.name === "string" && raw.name.trim() ? raw.name.trim() : concept;
|
|
126
|
-
const conceptDescription = typeof raw.description === "string" ? raw.description.trim() : "";
|
|
127
|
-
for (const [name, value] of Object.entries(raw)) {
|
|
128
|
-
if (name === "name" || name === "description") continue;
|
|
129
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
130
|
-
throw new Error(`Invalid context entry: ${path} [${name}]`);
|
|
131
|
-
const record = value as Record<string, unknown>;
|
|
132
|
-
const unknownField = Object.keys(record).find((field) => !CONTEXT_ENTRY_FIELDS.has(field));
|
|
133
|
-
if (unknownField) throw new Error(`Invalid context entry field: ${path} [${name}] ${unknownField}`);
|
|
134
|
-
if (
|
|
135
|
-
typeof record.description !== "string" ||
|
|
136
|
-
!record.description.trim() ||
|
|
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)
|
|
144
|
-
)
|
|
145
|
-
throw new Error(`Invalid context entry: ${path} [${name}]`);
|
|
146
|
-
const entry = validSlug(name, "Context entry");
|
|
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}`);
|
|
157
|
-
result.push({
|
|
158
|
-
id: `${tab}/${concept}/${entry}`,
|
|
159
|
-
tab,
|
|
160
|
-
concept,
|
|
161
|
-
conceptName,
|
|
162
|
-
conceptDescription,
|
|
163
|
-
name: entry,
|
|
164
|
-
description: record.description.trim(),
|
|
165
|
-
read: entryRead,
|
|
166
|
-
outline: entryOutline,
|
|
167
|
-
references: entryReferences,
|
|
168
|
-
path,
|
|
169
|
-
});
|
|
170
|
-
}
|
|
293
|
+
result.push(...(await loadConceptFile(root, tabEntry.slug, tabEntry.folder, file.name)));
|
|
171
294
|
}
|
|
172
295
|
}
|
|
173
296
|
return result;
|