takomi 2.1.36 → 2.1.37
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/.pi/README.md +6 -3
- package/.pi/extensions/oauth-router/commands.ts +36 -35
- package/.pi/extensions/oauth-router/index.ts +8 -8
- package/.pi/extensions/takomi-context-manager/diagnostics-tools.ts +40 -7
- package/.pi/extensions/takomi-context-manager/diagnostics.ts +534 -55
- package/.pi/extensions/takomi-context-manager/index.ts +14 -2
- package/.pi/extensions/takomi-context-manager/model-policy-gate.ts +16 -4
- package/.pi/extensions/takomi-context-manager/policy-tools.ts +7 -0
- package/.pi/extensions/takomi-context-manager/prerequisite-gates.ts +2 -0
- package/.pi/extensions/takomi-context-manager/session-state.ts +160 -0
- package/.pi/extensions/takomi-context-manager/skill-registry.ts +120 -0
- package/.pi/extensions/takomi-context-manager/skill-tools.ts +26 -3
- package/.pi/extensions/takomi-context-manager/state.ts +11 -1
- package/.pi/extensions/takomi-context-manager/types.ts +9 -1
- package/.pi/extensions/takomi-runtime/command-text.ts +2 -1
- package/.pi/extensions/takomi-runtime/commands.ts +13 -1
- package/.pi/extensions/takomi-subagents/index.ts +30 -3
- package/.pi/extensions/takomi-subagents/native-render.ts +3 -1
- package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +18 -3
- package/.pi/extensions/takomi-subagents/tool-runner.ts +38 -3
- package/README.md +3 -0
- package/assets/.agent/skills/exam-creator-skill/SKILL.md +182 -0
- package/assets/.agent/skills/exam-creator-skill/references/model-routing-policy.md +48 -0
- package/assets/.agent/skills/exam-creator-skill/references/workflow-rulebook.md +115 -0
- package/package.json +2 -2
- package/src/cli.js +38 -1
- package/src/doctor.js +7 -1
- package/src/pi-harness.js +96 -0
|
@@ -6,6 +6,7 @@ import type { ContextManagerState } from "./state";
|
|
|
6
6
|
import { recordBlocked } from "./state";
|
|
7
7
|
import { resolveTakomiRoutingPolicy } from "../takomi-runtime/routing-policy";
|
|
8
8
|
import { approvedModelEquivalent, isTakomiModelApproved } from "../takomi-runtime/model-routing-defaults";
|
|
9
|
+
import { persistReportSnapshot } from "./session-state";
|
|
9
10
|
|
|
10
11
|
type Settings = {
|
|
11
12
|
takomi?: { modelRoutingPolicyFile?: string };
|
|
@@ -183,19 +184,19 @@ export function installModelPolicyGate(pi: ExtensionAPI, state: ContextManagerSt
|
|
|
183
184
|
if (approved.length === 0) return;
|
|
184
185
|
|
|
185
186
|
const refs = collectRequestedModelRefs(event.input);
|
|
186
|
-
const corrections: string
|
|
187
|
+
const corrections: Array<{ from: string; to: string; recovery?: string }> = [];
|
|
187
188
|
for (const ref of refs) {
|
|
188
189
|
if (isTakomiModelApproved(ref.value, approved)) continue;
|
|
189
190
|
const equivalent = approvedModelEquivalent(ref.value, approved);
|
|
190
191
|
if (equivalent) {
|
|
191
192
|
setModelRef(ref, equivalent);
|
|
192
|
-
corrections.push(
|
|
193
|
+
corrections.push({ from: ref.value, to: equivalent });
|
|
193
194
|
continue;
|
|
194
195
|
}
|
|
195
196
|
const recovery = await askForInvalidModelRecovery(ctx, ref.value, approved);
|
|
196
197
|
if (recovery.action === "retry") {
|
|
197
198
|
setModelRef(ref, recovery.model);
|
|
198
|
-
corrections.push(
|
|
199
|
+
corrections.push({ from: ref.value, to: recovery.model, recovery: "user selected recovery" });
|
|
199
200
|
continue;
|
|
200
201
|
}
|
|
201
202
|
const reason = [
|
|
@@ -204,12 +205,23 @@ export function installModelPolicyGate(pi: ExtensionAPI, state: ContextManagerSt
|
|
|
204
205
|
"Human selected stop. The agent turn has been aborted; wait for the user's next prompt.",
|
|
205
206
|
].join("\n");
|
|
206
207
|
recordBlocked(state, event.toolName, reason);
|
|
208
|
+
persistReportSnapshot(pi, state, "model-policy-gate-block");
|
|
207
209
|
ctx.abort?.();
|
|
208
210
|
return { block: true, reason };
|
|
209
211
|
}
|
|
210
212
|
|
|
211
213
|
if (corrections.length > 0) {
|
|
212
|
-
|
|
214
|
+
const timestamp = new Date().toISOString();
|
|
215
|
+
state.report.timestamp = timestamp;
|
|
216
|
+
state.report.modelRoutingCorrections.push(...corrections.map((correction) => ({
|
|
217
|
+
toolName: event.toolName,
|
|
218
|
+
from: correction.from,
|
|
219
|
+
to: correction.to,
|
|
220
|
+
recovery: correction.recovery,
|
|
221
|
+
timestamp,
|
|
222
|
+
})));
|
|
223
|
+
persistReportSnapshot(pi, state, "model-policy-correction");
|
|
224
|
+
ctx.ui.notify(`Takomi context manager corrected subagent model routing:\n- ${corrections.map((correction) => `${correction.from} -> ${correction.to}${correction.recovery ? ` (${correction.recovery})` : ""}`).join("\n- ")}\n\nBe careful to follow /takomi routing policy next time.`, "warning");
|
|
213
225
|
}
|
|
214
226
|
});
|
|
215
227
|
|
|
@@ -3,6 +3,7 @@ import { Type } from "typebox";
|
|
|
3
3
|
import type { ContextManagerState } from "./state";
|
|
4
4
|
import { syncReportLedger } from "./state";
|
|
5
5
|
import { renderPolicies, renderPolicyManifest } from "./policy-registry";
|
|
6
|
+
import { persistReportSnapshot, restoreReportFromSession } from "./session-state";
|
|
6
7
|
|
|
7
8
|
export function registerPolicyTools(pi: ExtensionAPI, state: ContextManagerState): void {
|
|
8
9
|
pi.registerTool({
|
|
@@ -12,8 +13,11 @@ export function registerPolicyTools(pi: ExtensionAPI, state: ContextManagerState
|
|
|
12
13
|
promptSnippet: "Show available context policy pack descriptions",
|
|
13
14
|
parameters: Type.Object({ policies: Type.Optional(Type.Array(Type.String({ description: "Policy name to inspect" }))) }),
|
|
14
15
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
16
|
+
restoreReportFromSession(state, ctx);
|
|
17
|
+
state.report.timestamp = new Date().toISOString();
|
|
15
18
|
state.report.cwd = ctx.cwd;
|
|
16
19
|
state.report.toolCalls.policyManifest += 1;
|
|
20
|
+
persistReportSnapshot(pi, state, "policy_manifest");
|
|
17
21
|
return { content: [{ type: "text", text: renderPolicyManifest(state.policies, params.policies ?? []) }], details: { requested: params.policies ?? [...state.policies.keys()] } };
|
|
18
22
|
},
|
|
19
23
|
});
|
|
@@ -25,10 +29,13 @@ export function registerPolicyTools(pi: ExtensionAPI, state: ContextManagerState
|
|
|
25
29
|
promptSnippet: "Load policy packs required before sensitive tool calls",
|
|
26
30
|
parameters: Type.Object({ policies: Type.Array(Type.String({ description: "Policy pack name to load" })) }),
|
|
27
31
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
32
|
+
restoreReportFromSession(state, ctx);
|
|
33
|
+
state.report.timestamp = new Date().toISOString();
|
|
28
34
|
state.report.cwd = ctx.cwd;
|
|
29
35
|
state.report.toolCalls.policyLoad += 1;
|
|
30
36
|
const text = renderPolicies(state.policies, state.loadedPolicies, params.policies);
|
|
31
37
|
syncReportLedger(state);
|
|
38
|
+
persistReportSnapshot(pi, state, "policy_load");
|
|
32
39
|
return { content: [{ type: "text", text }], details: { requested: params.policies, loadedPolicies: [...state.loadedPolicies].sort() } };
|
|
33
40
|
},
|
|
34
41
|
});
|
|
@@ -3,6 +3,7 @@ import type { ContextManagerConfig } from "./types";
|
|
|
3
3
|
import type { ContextManagerState } from "./state";
|
|
4
4
|
import { recordBlocked, syncReportLedger } from "./state";
|
|
5
5
|
import { renderPolicies } from "./policy-registry";
|
|
6
|
+
import { persistReportSnapshot } from "./session-state";
|
|
6
7
|
|
|
7
8
|
function renderPolicyGateBlock(toolName: string, missing: string[], policyText: string): string {
|
|
8
9
|
return [
|
|
@@ -33,6 +34,7 @@ export function installPrerequisiteGates(pi: ExtensionAPI, state: ContextManager
|
|
|
33
34
|
syncReportLedger(state);
|
|
34
35
|
const reason = renderPolicyGateBlock(event.toolName, missing, policyText);
|
|
35
36
|
recordBlocked(state, event.toolName, reason);
|
|
37
|
+
persistReportSnapshot(pi, state, "prerequisite-gate-block");
|
|
36
38
|
return { block: true, reason };
|
|
37
39
|
}
|
|
38
40
|
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ContextManagerState } from "./state";
|
|
3
|
+
import type { ContextReport } from "./types";
|
|
4
|
+
|
|
5
|
+
const SNAPSHOT_TYPE = "takomi-context-manager-report";
|
|
6
|
+
const TOOL_NAMES = ["skill_index", "skill_manifest", "skill_load", "policy_manifest", "policy_load", "context_report"] as const;
|
|
7
|
+
type ToolName = typeof TOOL_NAMES[number];
|
|
8
|
+
|
|
9
|
+
type RestoreStats = {
|
|
10
|
+
snapshotCount: number;
|
|
11
|
+
toolResultCount: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
15
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function uniqueSorted(values: Iterable<string>): string[] {
|
|
19
|
+
return [...new Set([...values].filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function mergeReports(base: ContextReport, restored: ContextReport | undefined): ContextReport {
|
|
23
|
+
if (!restored) return base;
|
|
24
|
+
return {
|
|
25
|
+
...base,
|
|
26
|
+
...restored,
|
|
27
|
+
toolCalls: { ...base.toolCalls, ...restored.toolCalls },
|
|
28
|
+
promptRewrite: { ...base.promptRewrite, ...restored.promptRewrite },
|
|
29
|
+
sessionRestore: { ...base.sessionRestore, ...restored.sessionRestore },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function maxToolCalls(a: ContextReport["toolCalls"], b: ContextReport["toolCalls"]): ContextReport["toolCalls"] {
|
|
34
|
+
return {
|
|
35
|
+
skillIndex: Math.max(a.skillIndex, b.skillIndex),
|
|
36
|
+
skillManifest: Math.max(a.skillManifest, b.skillManifest),
|
|
37
|
+
skillLoad: Math.max(a.skillLoad, b.skillLoad),
|
|
38
|
+
policyManifest: Math.max(a.policyManifest, b.policyManifest),
|
|
39
|
+
policyLoad: Math.max(a.policyLoad, b.policyLoad),
|
|
40
|
+
contextReport: Math.max(a.contextReport, b.contextReport),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function incrementTool(calls: ContextReport["toolCalls"], toolName: ToolName): void {
|
|
45
|
+
if (toolName === "skill_index") calls.skillIndex += 1;
|
|
46
|
+
if (toolName === "skill_manifest") calls.skillManifest += 1;
|
|
47
|
+
if (toolName === "skill_load") calls.skillLoad += 1;
|
|
48
|
+
if (toolName === "policy_manifest") calls.policyManifest += 1;
|
|
49
|
+
if (toolName === "policy_load") calls.policyLoad += 1;
|
|
50
|
+
if (toolName === "context_report") calls.contextReport += 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getEntryData(entry: unknown): unknown {
|
|
54
|
+
const record = asRecord(entry);
|
|
55
|
+
if (record.type === "custom" && record.customType === SNAPSHOT_TYPE) return record.data;
|
|
56
|
+
const message = asRecord(record.message);
|
|
57
|
+
if (message.role === "custom" && message.customType === SNAPSHOT_TYPE) return message.details ?? message.content;
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getToolResult(entry: unknown): { toolName?: string; details?: Record<string, unknown> } | undefined {
|
|
62
|
+
const record = asRecord(entry);
|
|
63
|
+
if (record.type !== "message") return undefined;
|
|
64
|
+
const message = asRecord(record.message);
|
|
65
|
+
if (message.role !== "toolResult") return undefined;
|
|
66
|
+
return { toolName: typeof message.toolName === "string" ? message.toolName : undefined, details: asRecord(message.details) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getEntries(ctx: unknown): unknown[] {
|
|
70
|
+
const manager = asRecord(ctx).sessionManager as { getEntries?: () => unknown[]; getBranch?: () => unknown[] } | undefined;
|
|
71
|
+
try {
|
|
72
|
+
return manager?.getEntries?.() ?? manager?.getBranch?.() ?? [];
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extractSnapshot(data: unknown): ContextReport | undefined {
|
|
79
|
+
const record = asRecord(data);
|
|
80
|
+
const report = asRecord(record.report);
|
|
81
|
+
if (!report || typeof report.timestamp !== "string") return undefined;
|
|
82
|
+
return report as ContextReport;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function restoreReportFromSession(state: ContextManagerState, ctx: unknown): RestoreStats {
|
|
86
|
+
const entries = getEntries(ctx);
|
|
87
|
+
let latestSnapshot: ContextReport | undefined;
|
|
88
|
+
let snapshotCount = 0;
|
|
89
|
+
let toolResultCount = 0;
|
|
90
|
+
const countedToolCalls: ContextReport["toolCalls"] = { skillIndex: 0, skillManifest: 0, skillLoad: 0, policyManifest: 0, policyLoad: 0, contextReport: 0 };
|
|
91
|
+
const loadedSkills = new Set(state.report.loadedByTool);
|
|
92
|
+
const loadedPolicies = new Set(state.report.loadedPolicies);
|
|
93
|
+
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
const snapshot = extractSnapshot(getEntryData(entry));
|
|
96
|
+
if (snapshot) {
|
|
97
|
+
snapshotCount += 1;
|
|
98
|
+
if (!latestSnapshot || snapshot.timestamp >= latestSnapshot.timestamp) latestSnapshot = snapshot;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const toolResult = getToolResult(entry);
|
|
102
|
+
if (!toolResult?.toolName || !(TOOL_NAMES as readonly string[]).includes(toolResult.toolName)) continue;
|
|
103
|
+
toolResultCount += 1;
|
|
104
|
+
incrementTool(countedToolCalls, toolResult.toolName as ToolName);
|
|
105
|
+
if (toolResult.toolName === "skill_load" && typeof toolResult.details?.skill === "string") loadedSkills.add(toolResult.details.skill);
|
|
106
|
+
if (toolResult.toolName === "policy_load" && Array.isArray(toolResult.details?.loadedPolicies)) {
|
|
107
|
+
for (const policy of toolResult.details.loadedPolicies) if (typeof policy === "string") loadedPolicies.add(policy);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
state.report = mergeReports(state.report, latestSnapshot);
|
|
112
|
+
state.report.loadedByTool ??= [];
|
|
113
|
+
state.report.loadedPolicies ??= [];
|
|
114
|
+
state.report.blockedActions ??= [];
|
|
115
|
+
state.report.modelRoutingCorrections ??= [];
|
|
116
|
+
state.report.duplicateExtensionWarnings ??= [];
|
|
117
|
+
for (const skill of state.report.loadedByTool) loadedSkills.add(skill);
|
|
118
|
+
for (const policy of state.report.loadedPolicies) loadedPolicies.add(policy);
|
|
119
|
+
state.report.toolCalls = maxToolCalls(state.report.toolCalls, countedToolCalls);
|
|
120
|
+
state.report.loadedByTool = uniqueSorted(loadedSkills);
|
|
121
|
+
state.report.loadedPolicies = uniqueSorted(loadedPolicies);
|
|
122
|
+
state.loadedPolicies = new Set(state.report.loadedPolicies);
|
|
123
|
+
state.report.sessionRestore = {
|
|
124
|
+
attempted: true,
|
|
125
|
+
restored: Boolean(latestSnapshot) || toolResultCount > 0,
|
|
126
|
+
snapshotCount,
|
|
127
|
+
toolResultCount,
|
|
128
|
+
note: latestSnapshot
|
|
129
|
+
? "Restored from Takomi context-manager snapshots and Pi tool-result history in the current session file."
|
|
130
|
+
: toolResultCount > 0
|
|
131
|
+
? "Restored from Pi tool-result history in the current session file; no context-manager snapshot was found."
|
|
132
|
+
: "No prior context-manager snapshots or tool results were found in the current session file.",
|
|
133
|
+
};
|
|
134
|
+
return { snapshotCount, toolResultCount };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function compactReason(reason: string): string {
|
|
138
|
+
const firstMeaningfulLine = reason.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? reason;
|
|
139
|
+
if (/Loaded policy context:/i.test(reason)) return `${firstMeaningfulLine}\n[policy content omitted from persisted context-manager snapshot]`;
|
|
140
|
+
return reason.length > 1200 ? `${reason.slice(0, 1200)}…\n[truncated in persisted context-manager snapshot]` : reason;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function snapshotReport(report: ContextReport): ContextReport {
|
|
144
|
+
return {
|
|
145
|
+
...report,
|
|
146
|
+
blockedActions: report.blockedActions.map((action) => ({ ...action, reason: compactReason(action.reason) })),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function persistReportSnapshot(pi: ExtensionAPI, state: ContextManagerState, reason: string): void {
|
|
151
|
+
try {
|
|
152
|
+
pi.appendEntry(SNAPSHOT_TYPE, {
|
|
153
|
+
version: 1,
|
|
154
|
+
reason,
|
|
155
|
+
report: snapshotReport(state.report),
|
|
156
|
+
});
|
|
157
|
+
} catch {
|
|
158
|
+
// Persistence should never break agent startup or tool execution.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
1
4
|
import type { SkillRecord } from "./types";
|
|
2
5
|
|
|
3
6
|
export function normalizeText(value: string): string {
|
|
@@ -85,3 +88,120 @@ export function findSkill(skills: Map<string, SkillRecord>, name: string): Skill
|
|
|
85
88
|
const matches = sortedSkills(skills).filter((skill) => normalizeName(skill.name).includes(key));
|
|
86
89
|
return matches.length === 1 ? matches[0] : undefined;
|
|
87
90
|
}
|
|
91
|
+
|
|
92
|
+
function parseFrontmatter(text: string): Record<string, string> {
|
|
93
|
+
const match = text.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
|
|
94
|
+
if (!match) return {};
|
|
95
|
+
const data: Record<string, string> = {};
|
|
96
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
97
|
+
const parsed = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
|
|
98
|
+
if (!parsed) continue;
|
|
99
|
+
data[parsed[1]] = parsed[2].replace(/^['\"]|['\"]$/g, "").trim();
|
|
100
|
+
}
|
|
101
|
+
return data;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
105
|
+
try {
|
|
106
|
+
await stat(filePath);
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function readSkillFile(filePath: string): Promise<SkillRecord | undefined> {
|
|
114
|
+
try {
|
|
115
|
+
const text = await readFile(filePath, "utf8");
|
|
116
|
+
const frontmatter = parseFrontmatter(text);
|
|
117
|
+
const name = frontmatter.name?.trim() || path.basename(path.dirname(filePath));
|
|
118
|
+
const description = frontmatter.description?.trim();
|
|
119
|
+
if (!name || !description) return undefined;
|
|
120
|
+
return { name, description, location: filePath, source: "filesystem" };
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function collectSkillFiles(root: string, directMarkdownFiles = false, depth = 0, maxDepth = 8): Promise<string[]> {
|
|
127
|
+
if (depth > maxDepth || !await pathExists(root)) return [];
|
|
128
|
+
let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }> = [];
|
|
129
|
+
try {
|
|
130
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const files: string[] = [];
|
|
136
|
+
if (await pathExists(path.join(root, "SKILL.md"))) files.push(path.join(root, "SKILL.md"));
|
|
137
|
+
if (directMarkdownFiles && depth === 0) {
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) files.push(path.join(root, entry.name));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (!entry.isDirectory()) continue;
|
|
145
|
+
if ([".git", "node_modules"].includes(entry.name)) continue;
|
|
146
|
+
files.push(...await collectSkillFiles(path.join(root, entry.name), false, depth + 1, maxDepth));
|
|
147
|
+
}
|
|
148
|
+
return [...new Set(files)];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function collectPackageSkillRoots(nodeModulesRoot: string): Promise<string[]> {
|
|
152
|
+
if (!await pathExists(nodeModulesRoot)) return [];
|
|
153
|
+
const roots: string[] = [];
|
|
154
|
+
let packages: Array<{ name: string; isDirectory(): boolean }> = [];
|
|
155
|
+
try {
|
|
156
|
+
packages = await readdir(nodeModulesRoot, { withFileTypes: true });
|
|
157
|
+
} catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
for (const pkg of packages) {
|
|
161
|
+
if (!pkg.isDirectory()) continue;
|
|
162
|
+
if (pkg.name.startsWith("@")) {
|
|
163
|
+
const scopeRoot = path.join(nodeModulesRoot, pkg.name);
|
|
164
|
+
let scoped: Array<{ name: string; isDirectory(): boolean }> = [];
|
|
165
|
+
try {
|
|
166
|
+
scoped = await readdir(scopeRoot, { withFileTypes: true });
|
|
167
|
+
} catch {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
for (const scopedPkg of scoped) if (scopedPkg.isDirectory()) roots.push(path.join(scopeRoot, scopedPkg.name, "skills"));
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
roots.push(path.join(nodeModulesRoot, pkg.name, "skills"));
|
|
174
|
+
}
|
|
175
|
+
return roots;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function ancestorSkillRoots(cwd: string): string[] {
|
|
179
|
+
const roots: string[] = [];
|
|
180
|
+
let current = path.resolve(cwd || process.cwd());
|
|
181
|
+
const seen = new Set<string>();
|
|
182
|
+
while (!seen.has(current)) {
|
|
183
|
+
seen.add(current);
|
|
184
|
+
roots.push(path.join(current, ".agents", "skills"));
|
|
185
|
+
const parent = path.dirname(current);
|
|
186
|
+
if (parent === current) break;
|
|
187
|
+
current = parent;
|
|
188
|
+
}
|
|
189
|
+
roots.push(path.resolve(cwd || process.cwd(), ".pi", "skills"));
|
|
190
|
+
return roots;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function discoverSkillsFromFilesystem(cwd = process.cwd()): Promise<SkillRecord[]> {
|
|
194
|
+
const home = os.homedir();
|
|
195
|
+
const roots = [
|
|
196
|
+
{ root: path.join(home, ".pi", "agent", "skills"), directMarkdownFiles: true },
|
|
197
|
+
{ root: path.join(home, ".agents", "skills"), directMarkdownFiles: false },
|
|
198
|
+
...ancestorSkillRoots(cwd).map((root) => ({ root, directMarkdownFiles: root.endsWith(`${path.sep}.pi${path.sep}skills`) })),
|
|
199
|
+
...(await collectPackageSkillRoots(path.join(home, ".pi", "agent", "npm", "node_modules"))).map((root) => ({ root, directMarkdownFiles: false })),
|
|
200
|
+
];
|
|
201
|
+
const skillFiles = new Set<string>();
|
|
202
|
+
for (const { root, directMarkdownFiles } of roots) {
|
|
203
|
+
for (const file of await collectSkillFiles(root, directMarkdownFiles)) skillFiles.add(file);
|
|
204
|
+
}
|
|
205
|
+
const skills = await Promise.all([...skillFiles].map(readSkillFile));
|
|
206
|
+
return skills.filter((skill): skill is SkillRecord => Boolean(skill));
|
|
207
|
+
}
|
|
@@ -3,7 +3,8 @@ import path from "node:path";
|
|
|
3
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
import type { ContextManagerState } from "./state";
|
|
6
|
-
import { findSkill, normalizeName, sortedSkills } from "./skill-registry";
|
|
6
|
+
import { discoverSkillsFromFilesystem, findSkill, mergeSkills, normalizeName, sortedSkills } from "./skill-registry";
|
|
7
|
+
import { persistReportSnapshot, restoreReportFromSession } from "./session-state";
|
|
7
8
|
|
|
8
9
|
function renderSkillIndex(state: ContextManagerState): string {
|
|
9
10
|
const skills = sortedSkills(state.skills);
|
|
@@ -29,6 +30,12 @@ async function loadSkillContent(location: string): Promise<string> {
|
|
|
29
30
|
return readFile(location, "utf8");
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
async function ensureSkillsDiscovered(state: ContextManagerState, cwd: string): Promise<void> {
|
|
34
|
+
if (state.skills.size > 0) return;
|
|
35
|
+
state.skills = mergeSkills(await discoverSkillsFromFilesystem(cwd));
|
|
36
|
+
state.report.skillCount = state.skills.size;
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
export function registerSkillTools(pi: ExtensionAPI, state: ContextManagerState): void {
|
|
33
40
|
pi.registerTool({
|
|
34
41
|
name: "skill_index",
|
|
@@ -37,8 +44,12 @@ export function registerSkillTools(pi: ExtensionAPI, state: ContextManagerState)
|
|
|
37
44
|
promptSnippet: "List available skill names only for progressive skill loading",
|
|
38
45
|
parameters: Type.Object({}),
|
|
39
46
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
47
|
+
restoreReportFromSession(state, ctx);
|
|
48
|
+
await ensureSkillsDiscovered(state, ctx.cwd);
|
|
49
|
+
state.report.timestamp = new Date().toISOString();
|
|
40
50
|
state.report.cwd = ctx.cwd;
|
|
41
51
|
state.report.toolCalls.skillIndex += 1;
|
|
52
|
+
persistReportSnapshot(pi, state, "skill_index");
|
|
42
53
|
return { content: [{ type: "text", text: renderSkillIndex(state) }], details: { skillCount: state.skills.size } };
|
|
43
54
|
},
|
|
44
55
|
});
|
|
@@ -50,8 +61,12 @@ export function registerSkillTools(pi: ExtensionAPI, state: ContextManagerState)
|
|
|
50
61
|
promptSnippet: "Show selected skill descriptions and locations without full instructions",
|
|
51
62
|
parameters: Type.Object({ skills: Type.Array(Type.String({ description: "Skill name to inspect" })) }),
|
|
52
63
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
64
|
+
restoreReportFromSession(state, ctx);
|
|
65
|
+
await ensureSkillsDiscovered(state, ctx.cwd);
|
|
66
|
+
state.report.timestamp = new Date().toISOString();
|
|
53
67
|
state.report.cwd = ctx.cwd;
|
|
54
68
|
state.report.toolCalls.skillManifest += 1;
|
|
69
|
+
persistReportSnapshot(pi, state, "skill_manifest");
|
|
55
70
|
return { content: [{ type: "text", text: renderManifest(state, params.skills) }], details: { requested: params.skills } };
|
|
56
71
|
},
|
|
57
72
|
});
|
|
@@ -63,16 +78,24 @@ export function registerSkillTools(pi: ExtensionAPI, state: ContextManagerState)
|
|
|
63
78
|
promptSnippet: "Load full SKILL.md instructions for one selected skill",
|
|
64
79
|
parameters: Type.Object({ skill: Type.String({ description: "Exact skill name to load" }) }),
|
|
65
80
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
81
|
+
restoreReportFromSession(state, ctx);
|
|
82
|
+
await ensureSkillsDiscovered(state, ctx.cwd);
|
|
83
|
+
state.report.timestamp = new Date().toISOString();
|
|
66
84
|
state.report.cwd = ctx.cwd;
|
|
67
85
|
state.report.toolCalls.skillLoad += 1;
|
|
68
86
|
const skill = findSkill(state.skills, params.skill);
|
|
69
|
-
if (!skill?.location)
|
|
87
|
+
if (!skill?.location) {
|
|
88
|
+
persistReportSnapshot(pi, state, "skill_load_not_found");
|
|
89
|
+
return { content: [{ type: "text", text: renderManifest(state, [params.skill]) }], details: { found: false, requested: params.skill }, isError: true };
|
|
90
|
+
}
|
|
70
91
|
try {
|
|
71
92
|
const content = await loadSkillContent(skill.location);
|
|
72
|
-
state.report.loadedByTool.
|
|
93
|
+
state.report.loadedByTool = [...new Set([...state.report.loadedByTool, skill.name])].sort();
|
|
94
|
+
persistReportSnapshot(pi, state, "skill_load");
|
|
73
95
|
return { content: [{ type: "text", text: [`Skill: ${skill.name}`, `Location: ${skill.location}`, "", content].join("\n") }], details: { found: true, skill: skill.name, location: skill.location } };
|
|
74
96
|
} catch (error) {
|
|
75
97
|
const message = error instanceof Error ? error.message : String(error);
|
|
98
|
+
persistReportSnapshot(pi, state, "skill_load_error");
|
|
76
99
|
return { content: [{ type: "text", text: message }], details: { found: true, skill: skill.name, error: message }, isError: true };
|
|
77
100
|
}
|
|
78
101
|
},
|
|
@@ -23,7 +23,15 @@ export function createEmptyReport(): ContextReport {
|
|
|
23
23
|
editedFiles: [],
|
|
24
24
|
writtenFiles: [],
|
|
25
25
|
blockedActions: [],
|
|
26
|
+
modelRoutingCorrections: [],
|
|
26
27
|
duplicateExtensionWarnings: [],
|
|
28
|
+
sessionRestore: {
|
|
29
|
+
attempted: false,
|
|
30
|
+
restored: false,
|
|
31
|
+
snapshotCount: 0,
|
|
32
|
+
toolResultCount: 0,
|
|
33
|
+
note: "No session restore attempted yet.",
|
|
34
|
+
},
|
|
27
35
|
promptRewrite: {
|
|
28
36
|
attempted: false,
|
|
29
37
|
changed: false,
|
|
@@ -63,6 +71,8 @@ export function syncReportLedger(state: ContextManagerState): void {
|
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
export function recordBlocked(state: ContextManagerState, toolName: string, reason: string): void {
|
|
66
|
-
|
|
74
|
+
const timestamp = new Date().toISOString();
|
|
75
|
+
state.report.timestamp = timestamp;
|
|
76
|
+
state.report.blockedActions.push({ toolName, reason, timestamp });
|
|
67
77
|
syncReportLedger(state);
|
|
68
78
|
}
|
|
@@ -2,7 +2,7 @@ export type SkillRecord = {
|
|
|
2
2
|
name: string;
|
|
3
3
|
description?: string;
|
|
4
4
|
location?: string;
|
|
5
|
-
source: "systemPromptOptions" | "xml" | "tool";
|
|
5
|
+
source: "systemPromptOptions" | "xml" | "filesystem" | "tool";
|
|
6
6
|
};
|
|
7
7
|
|
|
8
8
|
export type CandidateContext = {
|
|
@@ -57,7 +57,15 @@ export type ContextReport = {
|
|
|
57
57
|
editedFiles: string[];
|
|
58
58
|
writtenFiles: string[];
|
|
59
59
|
blockedActions: Array<{ toolName: string; reason: string; timestamp: string }>;
|
|
60
|
+
modelRoutingCorrections: Array<{ toolName: string; from: string; to: string; timestamp: string; recovery?: string }>;
|
|
60
61
|
duplicateExtensionWarnings: Array<{ toolName: string; paths: string[] }>;
|
|
62
|
+
sessionRestore: {
|
|
63
|
+
attempted: boolean;
|
|
64
|
+
restored: boolean;
|
|
65
|
+
snapshotCount: number;
|
|
66
|
+
toolResultCount: number;
|
|
67
|
+
note: string;
|
|
68
|
+
};
|
|
61
69
|
promptRewrite: {
|
|
62
70
|
attempted: boolean;
|
|
63
71
|
changed: boolean;
|
|
@@ -32,6 +32,7 @@ const SUBCOMMAND_COMPLETIONS: Record<string, TakomiCompletion[]> = {
|
|
|
32
32
|
{ value: "auto", label: "auto", description: "Continue approved plans automatically" },
|
|
33
33
|
],
|
|
34
34
|
subagents: [
|
|
35
|
+
{ value: "list", label: "list", description: "List available Takomi subagents" },
|
|
35
36
|
{ value: "status", label: "status", description: "Show active subagents" },
|
|
36
37
|
{ value: "on", label: "on", description: "Allow subagent delegation" },
|
|
37
38
|
{ value: "off", label: "off", description: "Disable subagent delegation" },
|
|
@@ -95,7 +96,7 @@ export function commandHelp(): string {
|
|
|
95
96
|
"/takomi plan [title]",
|
|
96
97
|
"/takomi mode <direct|orchestrate|review>",
|
|
97
98
|
"/takomi gate <auto|review>",
|
|
98
|
-
"/takomi subagents <on|off|status>",
|
|
99
|
+
"/takomi subagents <list|on|off|status>",
|
|
99
100
|
"/takomi stats [overview|daily|models|projects|projects-full|sessions|sessions-full|tasks|tasks-full|tools|subagents|sources] [since 7d]",
|
|
100
101
|
"/takomi routing [show|where]",
|
|
101
102
|
"/takomi routing <policy text> # updates global policy",
|
|
@@ -9,6 +9,7 @@ import { commandHelp, completions, statusText, workflowPrompt } from "./command-
|
|
|
9
9
|
import type { TakomiSubagentController } from "./subagent-types";
|
|
10
10
|
import { previewTakomiRoutingPolicy, renderRoutingPolicyPreview, resolveTakomiRoutingPolicy, type RoutingPolicyInstallScope } from "./routing-policy";
|
|
11
11
|
import { collectTakomiStats, renderTakomiStats } from "./takomi-stats.js";
|
|
12
|
+
import { discoverTakomiAgents } from "../takomi-subagents/agents";
|
|
12
13
|
|
|
13
14
|
export type TakomiRuntimeCommandState = {
|
|
14
15
|
enabled: boolean;
|
|
@@ -202,6 +203,17 @@ export function registerTakomiCommands(pi: ExtensionAPI, options: RegisterTakomi
|
|
|
202
203
|
|
|
203
204
|
async function handleSubagents(ctx: ExtensionCommandContext, action?: string): Promise<void> {
|
|
204
205
|
const controller = options.subagentController;
|
|
206
|
+
if (action === "list" || action === "agents") {
|
|
207
|
+
const agents = discoverTakomiAgents(ctx.cwd, "both");
|
|
208
|
+
const lines = [
|
|
209
|
+
"Takomi subagents:",
|
|
210
|
+
...(agents.length
|
|
211
|
+
? agents.map((agent) => `- ${agent.name} (${agent.source}): ${agent.description}`)
|
|
212
|
+
: ["- (none discovered)"]),
|
|
213
|
+
];
|
|
214
|
+
ctx.ui.notify(lines.join("\n"), agents.length ? "info" : "warning");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
205
217
|
if (action === "on" || action === "off") {
|
|
206
218
|
await options.updateState(ctx, () => {
|
|
207
219
|
options.getState().subagentsEnabled = action === "on";
|
|
@@ -212,7 +224,7 @@ export function registerTakomiCommands(pi: ExtensionAPI, options: RegisterTakomi
|
|
|
212
224
|
ctx.ui.notify(statusText(options.getState(), controller), controller.hasRuns() ? "info" : "warning");
|
|
213
225
|
return;
|
|
214
226
|
}
|
|
215
|
-
ctx.ui.notify("Usage: /takomi subagents <on|off|status>", "warning");
|
|
227
|
+
ctx.ui.notify("Usage: /takomi subagents <list|on|off|status>", "warning");
|
|
216
228
|
}
|
|
217
229
|
|
|
218
230
|
pi.registerCommand("takomi", {
|
|
@@ -31,8 +31,24 @@ const TaskSchema = Type.Object({
|
|
|
31
31
|
checklist: Type.Optional(Type.Array(Type.Union([Type.String(), ChecklistItemSchema]))),
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
const ContextSchema = Type.Union([
|
|
35
|
+
Type.Literal("fresh"),
|
|
36
|
+
Type.Literal("fork"),
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const ActionSchema = Type.Union([
|
|
40
|
+
Type.Literal("list"),
|
|
41
|
+
Type.Literal("get"),
|
|
42
|
+
Type.Literal("models"),
|
|
43
|
+
Type.Literal("status"),
|
|
44
|
+
Type.Literal("interrupt"),
|
|
45
|
+
Type.Literal("resume"),
|
|
46
|
+
Type.Literal("doctor"),
|
|
47
|
+
]);
|
|
48
|
+
|
|
34
49
|
const SubagentParameters = Type.Object({
|
|
35
|
-
|
|
50
|
+
action: Type.Optional(ActionSchema),
|
|
51
|
+
agent: Type.Optional(Type.String({ description: "Agent name for single execution, or target agent for get/models actions" })),
|
|
36
52
|
task: Type.Optional(Type.String({ description: "Task for single execution" })),
|
|
37
53
|
workflow: Type.Optional(Type.String({ description: "Workflow or playbook overlay for this task" })),
|
|
38
54
|
skills: Type.Optional(Type.Array(Type.String(), { description: "Extra skills to apply during the task" })),
|
|
@@ -47,6 +63,14 @@ const SubagentParameters = Type.Object({
|
|
|
47
63
|
previewOnly: Type.Optional(Type.Boolean({ description: "Return the delegation plan without launching" })),
|
|
48
64
|
clarify: Type.Optional(Type.Boolean({ description: "Show the native pi-subagents TUI to preview/edit before execution. Especially useful for chains; requires an interactive Pi TUI." })),
|
|
49
65
|
chain: Type.Optional(Type.Array(TaskSchema, { description: "Sequential chain of subagent tasks" })),
|
|
66
|
+
context: Type.Optional(ContextSchema),
|
|
67
|
+
async: Type.Optional(Type.Boolean({ description: "Run through native pi-subagents background/async mode" })),
|
|
68
|
+
concurrency: Type.Optional(Type.Number({ description: "Maximum concurrency for parallel task groups" })),
|
|
69
|
+
worktree: Type.Optional(Type.Boolean({ description: "Use native pi-subagents worktree isolation where supported" })),
|
|
70
|
+
id: Type.Optional(Type.String({ description: "Native async/control run id for status, interrupt, or resume actions" })),
|
|
71
|
+
message: Type.Optional(Type.String({ description: "Follow-up message for resume actions" })),
|
|
72
|
+
index: Type.Optional(Type.Number({ description: "Child index for resume actions when needed" })),
|
|
73
|
+
chainName: Type.Optional(Type.String({ description: "Saved chain name for get actions" })),
|
|
50
74
|
agentScope: Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("project"), Type.Literal("both")])),
|
|
51
75
|
// Project-agent confirmation and hard-stop overrides are enforced server-side;
|
|
52
76
|
// they are intentionally not model-callable parameters.
|
|
@@ -56,11 +80,14 @@ function registerSubagentTool(pi: ExtensionAPI): void {
|
|
|
56
80
|
pi.registerTool({
|
|
57
81
|
name: "takomi_subagent",
|
|
58
82
|
label: "Takomi",
|
|
59
|
-
description: "Run subagents with Pi-style single, parallel,
|
|
60
|
-
promptSnippet: "Delegate lifecycle-aware Takomi work to specialist subagents. Use single, tasks, or
|
|
83
|
+
description: "Run subagents with Pi-style single, parallel, chain, async, fork, and management/status modes plus Takomi lifecycle metadata.",
|
|
84
|
+
promptSnippet: "Delegate lifecycle-aware Takomi work to specialist subagents. Use single, tasks, chain, or action=list/status/doctor; reuse conversationId for review loops.",
|
|
61
85
|
promptGuidelines: [
|
|
62
86
|
"Use this tool during orchestration when a specialist should handle a task.",
|
|
87
|
+
"Use action=list to discover agents and action=status/doctor for native pi-subagents diagnostics/control instead of raw subagent.",
|
|
63
88
|
"Use tasks for independent parallel work and chain for dependent handoffs with {previous}.",
|
|
89
|
+
"Set context=fork only when inherited parent-session history is needed; otherwise prefer fresh or the agent default.",
|
|
90
|
+
"Set async=true for long-running work when the parent can continue safely; keep active worktree writes single-threaded unless worktree isolation is enabled.",
|
|
64
91
|
"Set clarify=true when the user asks to preview/edit a subagent run in the native Pi TUI before launch.",
|
|
65
92
|
"Use model, fallbackModels, and thinking only when deliberate; otherwise let the agent/profile defaults apply.",
|
|
66
93
|
"If review sends work back to the same agent, reuse the same conversationId for continuity.",
|
|
@@ -171,12 +171,14 @@ export function renderTakomiSubagentResult(result: ToolResult, options: { expand
|
|
|
171
171
|
return new Text(renderPolicyGateBlock(text, options.expanded, theme), 0, 0);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
const native = renderNativeSubagentResult(result, options, theme, context);
|
|
175
|
+
if (options.isPartial && native) return native;
|
|
176
|
+
|
|
174
177
|
if (options.isPartial) {
|
|
175
178
|
if (options.expanded) return new Text(livePartialText(result, theme), 0, 0);
|
|
176
179
|
return new Text(summarizeCollapsedResult(text, status, theme), 0, 0);
|
|
177
180
|
}
|
|
178
181
|
|
|
179
|
-
const native = renderNativeSubagentResult(result, options, theme, context);
|
|
180
182
|
if (native) return native;
|
|
181
183
|
|
|
182
184
|
if (!options.expanded) {
|