open-multi-agent-kit 0.93.0 → 0.94.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/CHANGELOG.md +23 -1
- package/README.md +2 -2
- package/dist/core/agent-session.d.ts +7 -14
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +78 -347
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/package-manager.d.ts.map +1 -1
- package/dist/core/package-manager.js +11 -1
- package/dist/core/package-manager.js.map +1 -1
- package/dist/core/sandbox/default-policy.d.ts +14 -0
- package/dist/core/sandbox/default-policy.d.ts.map +1 -0
- package/dist/core/sandbox/default-policy.js +58 -0
- package/dist/core/sandbox/default-policy.js.map +1 -0
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +1 -1
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/session-bash-runtime.d.ts +62 -0
- package/dist/core/session-bash-runtime.d.ts.map +1 -0
- package/dist/core/session-bash-runtime.js +167 -0
- package/dist/core/session-bash-runtime.js.map +1 -0
- package/dist/core/session-bash-service.d.ts +61 -0
- package/dist/core/session-bash-service.d.ts.map +1 -0
- package/dist/core/session-bash-service.js +155 -0
- package/dist/core/session-bash-service.js.map +1 -0
- package/dist/core/session-compaction-service.d.ts +48 -0
- package/dist/core/session-compaction-service.d.ts.map +1 -0
- package/dist/core/session-compaction-service.js +250 -0
- package/dist/core/session-compaction-service.js.map +1 -0
- package/dist/core/skills-catalog-cache.d.ts +25 -0
- package/dist/core/skills-catalog-cache.d.ts.map +1 -0
- package/dist/core/skills-catalog-cache.js +123 -0
- package/dist/core/skills-catalog-cache.js.map +1 -0
- package/dist/core/skills.d.ts +6 -0
- package/dist/core/skills.d.ts.map +1 -1
- package/dist/core/skills.js +25 -4
- package/dist/core/skills.js.map +1 -1
- package/dist/core/system-prompt.d.ts.map +1 -1
- package/dist/core/system-prompt.js +1 -1
- package/dist/core/system-prompt.js.map +1 -1
- package/dist/core/tools/bash.d.ts +3 -0
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js +3 -2
- package/dist/core/tools/bash.js.map +1 -1
- package/dist/core/tools/diagnostics.d.ts +29 -0
- package/dist/core/tools/diagnostics.d.ts.map +1 -0
- package/dist/core/tools/diagnostics.js +325 -0
- package/dist/core/tools/diagnostics.js.map +1 -0
- package/dist/core/tools/index.d.ts +2 -1
- package/dist/core/tools/index.d.ts.map +1 -1
- package/dist/core/tools/index.js +18 -1
- package/dist/core/tools/index.js.map +1 -1
- package/dist/core/verified-bash-adapter.d.ts +18 -1
- package/dist/core/verified-bash-adapter.d.ts.map +1 -1
- package/dist/core/verified-bash-adapter.js +38 -1
- package/dist/core/verified-bash-adapter.js.map +1 -1
- package/dist/core/verified-bash-runtime.d.ts +13 -0
- package/dist/core/verified-bash-runtime.d.ts.map +1 -0
- package/dist/core/verified-bash-runtime.js +81 -0
- package/dist/core/verified-bash-runtime.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/types/evidence.d.ts +1 -1
- package/dist/types/evidence.d.ts.map +1 -1
- package/dist/types/evidence.js.map +1 -1
- package/dist/utils/frontmatter.d.ts.map +1 -1
- package/dist/utils/frontmatter.js +31 -5
- package/dist/utils/frontmatter.js.map +1 -1
- package/docs/environment-variables.md +4 -0
- package/docs/sdk.md +10 -4
- package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/gondolin/package-lock.json +2 -2
- package/examples/extensions/gondolin/package.json +1 -1
- package/examples/extensions/sandbox/package-lock.json +2 -2
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package-lock.json +2 -2
- package/examples/extensions/with-deps/package.json +1 -1
- package/npm-shrinkwrap.json +15 -15
- package/package.json +5 -5
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session compaction service: capture / barrier / repair / transaction / commit.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from AgentSession so the session keeps lifecycle orchestration
|
|
5
|
+
* (abort controllers, extension events, auth, model resolution) while this
|
|
6
|
+
* owns the integrity-critical compaction state machine. Every method preserves
|
|
7
|
+
* the exact semantics of the original session implementation — the
|
|
8
|
+
* compaction-transaction / session-integrity suites pin them.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { repairTranscriptIntegrity } from "omk-agent-core";
|
|
13
|
+
import { sanitizeBinaryOutput } from "../utils/shell.js";
|
|
14
|
+
import { createCompactionEnvelope, createCompactionSourceIdentity, createCompactionTransaction, decideCompactionCommit, evaluateCompactionBarrier, validateCompactionEnvelope, } from "./compaction/transaction.js";
|
|
15
|
+
import { redactSensitiveText } from "./redaction.js";
|
|
16
|
+
import { inspectSessionIntegrity } from "./session-integrity.js";
|
|
17
|
+
export class SessionCompactionService {
|
|
18
|
+
deps;
|
|
19
|
+
constructor(deps) {
|
|
20
|
+
this.deps = deps;
|
|
21
|
+
}
|
|
22
|
+
captureState() {
|
|
23
|
+
return this.deps.sessionManager.withCompactionCommitLock(() => this.captureStateLocked());
|
|
24
|
+
}
|
|
25
|
+
captureStateLocked() {
|
|
26
|
+
const { sessionManager } = this.deps;
|
|
27
|
+
const sessionFile = sessionManager.getSessionFile();
|
|
28
|
+
const bytes = sessionFile && existsSync(sessionFile)
|
|
29
|
+
? new Uint8Array(readFileSync(sessionFile))
|
|
30
|
+
: new TextEncoder().encode(`${[sessionManager.getHeader(), ...sessionManager.getEntries()]
|
|
31
|
+
.filter((entry) => entry !== null)
|
|
32
|
+
.map((entry) => JSON.stringify(entry))
|
|
33
|
+
.join("\n")}\n`);
|
|
34
|
+
const report = inspectSessionIntegrity(bytes, { activeLeafId: sessionManager.getLeafId() });
|
|
35
|
+
const branchEntries = report.activeBranch;
|
|
36
|
+
let latestCompactionIndex = -1;
|
|
37
|
+
for (let index = branchEntries.length - 1; index >= 0; index -= 1) {
|
|
38
|
+
if (branchEntries[index]?.type === "compaction") {
|
|
39
|
+
latestCompactionIndex = index;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const latestCompaction = branchEntries[latestCompactionIndex];
|
|
44
|
+
const firstKeptIndex = latestCompaction?.type === "compaction"
|
|
45
|
+
? branchEntries.findIndex((entry) => entry.id === latestCompaction.firstKeptEntryId)
|
|
46
|
+
: -1;
|
|
47
|
+
const sourceEntries = branchEntries.slice(latestCompactionIndex < 0 ? 0 : firstKeptIndex < 0 ? latestCompactionIndex : firstKeptIndex);
|
|
48
|
+
const firstEntry = sourceEntries[0];
|
|
49
|
+
const lastEntry = sourceEntries.at(-1);
|
|
50
|
+
if (!firstEntry || !lastEntry || report.activeLeafId === null) {
|
|
51
|
+
const barrier = evaluateCompactionBarrier(report, [...this.deps.pendingToolCallIds()]);
|
|
52
|
+
if (barrier.status !== "ready")
|
|
53
|
+
throw this.barrierError(barrier);
|
|
54
|
+
throw new Error("Nothing to compact: the active session branch is empty");
|
|
55
|
+
}
|
|
56
|
+
const revision = sessionManager.getDurableHeadToken();
|
|
57
|
+
const source = createCompactionSourceIdentity({
|
|
58
|
+
sessionId: revision.sessionId,
|
|
59
|
+
entryIds: sourceEntries.map((entry) => entry.id),
|
|
60
|
+
firstEntryId: firstEntry.id,
|
|
61
|
+
lastEntryId: lastEntry.id,
|
|
62
|
+
sourceSha256: createHash("sha256")
|
|
63
|
+
.update(sourceEntries.map((entry) => JSON.stringify(entry)).join("\n"), "utf8")
|
|
64
|
+
.digest("hex"),
|
|
65
|
+
activeLeafId: report.activeLeafId,
|
|
66
|
+
messageCount: report.activeMessages.length,
|
|
67
|
+
});
|
|
68
|
+
return { report, branchEntries, revision, source };
|
|
69
|
+
}
|
|
70
|
+
barrierError(barrier) {
|
|
71
|
+
if (barrier.status === "defer") {
|
|
72
|
+
return new Error(`Compaction deferred until the transcript closes (${barrier.reason})`);
|
|
73
|
+
}
|
|
74
|
+
return new Error(`Compaction failed closed on transcript integrity (${barrier.reason}). Run the session doctor before retrying.`);
|
|
75
|
+
}
|
|
76
|
+
evaluateBarrier(capture, includeMissingTailAsPending, excludedPendingIds = new Set()) {
|
|
77
|
+
const pending = new Set([...this.deps.pendingToolCallIds()].filter((id) => !excludedPendingIds.has(id)));
|
|
78
|
+
if (includeMissingTailAsPending) {
|
|
79
|
+
for (const issue of capture.report.transcript?.issues ?? []) {
|
|
80
|
+
if (issue.kind === "missing_result")
|
|
81
|
+
pending.add(issue.toolCallId);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return evaluateCompactionBarrier(capture.report, [...pending]);
|
|
85
|
+
}
|
|
86
|
+
repairEmergencyTail(capture) {
|
|
87
|
+
const { sessionManager } = this.deps;
|
|
88
|
+
const barrier = this.evaluateBarrier(capture, true);
|
|
89
|
+
if (barrier.status !== "defer" || barrier.reason !== "missing_active_tail_results") {
|
|
90
|
+
if (barrier.status !== "ready")
|
|
91
|
+
throw this.barrierError(barrier);
|
|
92
|
+
return { capture, repairedToolCallIds: new Set() };
|
|
93
|
+
}
|
|
94
|
+
const repairedMessages = repairTranscriptIntegrity([...capture.report.activeMessages], "Tool result missing; synthesized to close an emergency compaction barrier");
|
|
95
|
+
const inserted = repairedMessages.slice(capture.report.activeMessages.length);
|
|
96
|
+
const repairedToolCallIds = new Set();
|
|
97
|
+
for (const message of inserted) {
|
|
98
|
+
if (message.role !== "toolResult") {
|
|
99
|
+
throw new Error("Emergency compaction repair produced a non-tool result");
|
|
100
|
+
}
|
|
101
|
+
const toolResult = message;
|
|
102
|
+
repairedToolCallIds.add(toolResult.toolCallId);
|
|
103
|
+
sessionManager.appendMessage(toolResult);
|
|
104
|
+
}
|
|
105
|
+
sessionManager.appendCustomEntry("compaction_transcript_repaired", {
|
|
106
|
+
insertedToolCallIds: [...repairedToolCallIds],
|
|
107
|
+
reason: "emergency_compaction",
|
|
108
|
+
});
|
|
109
|
+
this.deps.invalidateContextBudget();
|
|
110
|
+
const closedCapture = this.captureState();
|
|
111
|
+
const closedBarrier = this.evaluateBarrier(closedCapture, false, repairedToolCallIds);
|
|
112
|
+
if (closedBarrier.status !== "ready")
|
|
113
|
+
throw this.barrierError(closedBarrier);
|
|
114
|
+
this.deps.refreshAgentMessages();
|
|
115
|
+
return { capture: closedCapture, repairedToolCallIds };
|
|
116
|
+
}
|
|
117
|
+
priorCommittedSourceDigests() {
|
|
118
|
+
const digests = [];
|
|
119
|
+
for (const entry of this.deps.sessionManager.getEntries()) {
|
|
120
|
+
if (entry.type !== "compaction" || typeof entry.details !== "object" || entry.details === null)
|
|
121
|
+
continue;
|
|
122
|
+
if (!Object.hasOwn(entry.details, "compactionEnvelope"))
|
|
123
|
+
continue;
|
|
124
|
+
const envelope = validateCompactionEnvelope(Reflect.get(entry.details, "compactionEnvelope"));
|
|
125
|
+
if (envelope.summary !== entry.summary) {
|
|
126
|
+
throw new Error(`Compaction entry ${entry.id} has invalid provenance. Run the session doctor.`);
|
|
127
|
+
}
|
|
128
|
+
digests.push(envelope.source.sourceSha256);
|
|
129
|
+
}
|
|
130
|
+
return digests;
|
|
131
|
+
}
|
|
132
|
+
beginTransaction(compactionModel, emergency) {
|
|
133
|
+
let capture = this.captureState();
|
|
134
|
+
if (emergency) {
|
|
135
|
+
capture = this.repairEmergencyTail(capture).capture;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const barrier = this.evaluateBarrier(capture, false);
|
|
139
|
+
if (barrier.status !== "ready")
|
|
140
|
+
throw this.barrierError(barrier);
|
|
141
|
+
}
|
|
142
|
+
const transaction = createCompactionTransaction({
|
|
143
|
+
transactionId: randomUUID(),
|
|
144
|
+
baseRevision: capture.revision,
|
|
145
|
+
source: capture.source,
|
|
146
|
+
createdAt: new Date().toISOString(),
|
|
147
|
+
model: { provider: compactionModel.provider, id: compactionModel.id },
|
|
148
|
+
preserved: this.captureProvenance(capture),
|
|
149
|
+
});
|
|
150
|
+
if (this.priorCommittedSourceDigests().includes(transaction.source.sourceSha256)) {
|
|
151
|
+
throw new Error("This exact compaction source was already compacted");
|
|
152
|
+
}
|
|
153
|
+
return { capture, transaction };
|
|
154
|
+
}
|
|
155
|
+
captureProvenance(capture) {
|
|
156
|
+
let latestIntent = "Continue the current session";
|
|
157
|
+
for (let index = capture.report.activeMessages.length - 1; index >= 0; index -= 1) {
|
|
158
|
+
const message = capture.report.activeMessages[index];
|
|
159
|
+
if (message?.role !== "user")
|
|
160
|
+
continue;
|
|
161
|
+
const candidate = sanitizeBinaryOutput(redactSensitiveText(this.deps.getUserMessageText(message)).trim())
|
|
162
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
|
|
163
|
+
.slice(0, 16_384);
|
|
164
|
+
if (candidate.length > 0)
|
|
165
|
+
latestIntent = candidate;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
const modelHistory = capture.branchEntries
|
|
169
|
+
.flatMap((entry) => {
|
|
170
|
+
if (entry.type === "model_change") {
|
|
171
|
+
return [{ entryId: entry.id, provider: entry.provider, modelId: entry.modelId }];
|
|
172
|
+
}
|
|
173
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
174
|
+
return [{ entryId: entry.id, provider: entry.message.provider, modelId: entry.message.model }];
|
|
175
|
+
}
|
|
176
|
+
return [];
|
|
177
|
+
})
|
|
178
|
+
.slice(-256);
|
|
179
|
+
const customEntryIds = (customType) => capture.branchEntries
|
|
180
|
+
.filter((entry) => entry.type === "custom" && entry.customType === customType)
|
|
181
|
+
.map((entry) => entry.id);
|
|
182
|
+
return {
|
|
183
|
+
latestIntent,
|
|
184
|
+
openTasks: [],
|
|
185
|
+
laneIds: customEntryIds("lane"),
|
|
186
|
+
acceptancePredicateIds: customEntryIds("acceptance_predicate"),
|
|
187
|
+
evidenceReceiptIds: customEntryIds("evidence_receipt"),
|
|
188
|
+
blockerReasons: [],
|
|
189
|
+
repairEventIds: [
|
|
190
|
+
...customEntryIds("transcript_repaired"),
|
|
191
|
+
...customEntryIds("compaction_transcript_repaired"),
|
|
192
|
+
],
|
|
193
|
+
branch: null,
|
|
194
|
+
worktree: this.deps.cwd,
|
|
195
|
+
modelHistory,
|
|
196
|
+
nextAction: latestIntent.slice(0, 4096) || "Continue the current session",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
detailsWithEnvelope(details, envelope) {
|
|
200
|
+
if (typeof details === "object" && details !== null && !Array.isArray(details)) {
|
|
201
|
+
return { ...details, compactionEnvelope: envelope };
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
compactionEnvelope: envelope,
|
|
205
|
+
...(details === undefined ? {} : { resultDetails: details }),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
commit(begun, result, fromExtension) {
|
|
209
|
+
if (!begun.transaction.source.entryIds.includes(result.firstKeptEntryId)) {
|
|
210
|
+
throw new Error("Compaction first-kept entry is outside the captured source");
|
|
211
|
+
}
|
|
212
|
+
const committed = this.deps.sessionManager.withCompactionCommitLock(() => {
|
|
213
|
+
const current = this.captureState();
|
|
214
|
+
const barrier = this.evaluateBarrier(current, false);
|
|
215
|
+
const decision = decideCompactionCommit({
|
|
216
|
+
transaction: begun.transaction,
|
|
217
|
+
currentRevision: current.revision,
|
|
218
|
+
currentSource: current.source,
|
|
219
|
+
barrier,
|
|
220
|
+
priorCommittedSourceDigests: this.priorCommittedSourceDigests(),
|
|
221
|
+
});
|
|
222
|
+
switch (decision.decision) {
|
|
223
|
+
case "duplicate":
|
|
224
|
+
throw new Error("This exact compaction source was already compacted");
|
|
225
|
+
case "stale":
|
|
226
|
+
throw new Error(`Session changed during compaction (${decision.reason}); generated summary was discarded`);
|
|
227
|
+
case "defer":
|
|
228
|
+
case "fail_closed":
|
|
229
|
+
throw this.barrierError(barrier);
|
|
230
|
+
case "commit": {
|
|
231
|
+
const envelope = createCompactionEnvelope({
|
|
232
|
+
transaction: begun.transaction,
|
|
233
|
+
decision,
|
|
234
|
+
summary: result.summary,
|
|
235
|
+
summarySha256: createHash("sha256").update(result.summary, "utf8").digest("hex"),
|
|
236
|
+
});
|
|
237
|
+
const entryId = this.deps.sessionManager.appendCompaction(result.summary, result.firstKeptEntryId, result.tokensBefore, this.detailsWithEnvelope(result.details, envelope), fromExtension);
|
|
238
|
+
const entry = this.deps.sessionManager.getEntry(entryId);
|
|
239
|
+
if (!entry || entry.type !== "compaction") {
|
|
240
|
+
throw new Error("Compaction commit did not produce a compaction entry");
|
|
241
|
+
}
|
|
242
|
+
return { entry, envelope };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
this.deps.recordCommit();
|
|
247
|
+
return committed;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
//# sourceMappingURL=session-compaction-service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-compaction-service.js","sourceRoot":"","sources":["../../src/core/session-compaction-service.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Session compaction service: capture / barrier / repair / transaction / commit.\n *\n * Extracted from AgentSession so the session keeps lifecycle orchestration\n * (abort controllers, extension events, auth, model resolution) while this\n * owns the integrity-critical compaction state machine. Every method preserves\n * the exact semantics of the original session implementation — the\n * compaction-transaction / session-integrity suites pin them.\n */\nimport { createHash, randomUUID } from \"node:crypto\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { repairTranscriptIntegrity } from \"omk-agent-core\";\nimport type { Api, Message, Model, ToolResultMessage } from \"omk-ai\";\nimport { sanitizeBinaryOutput } from \"../utils/shell.ts\";\nimport type { CompactionResult } from \"./compaction/index.ts\";\nimport {\n\ttype CompactionBarrierResult,\n\ttype CompactionEnvelope,\n\ttype CompactionPreservedProvenanceInput,\n\ttype CompactionSourceIdentity,\n\ttype CompactionTransaction,\n\tcreateCompactionEnvelope,\n\tcreateCompactionSourceIdentity,\n\tcreateCompactionTransaction,\n\tdecideCompactionCommit,\n\tevaluateCompactionBarrier,\n\tvalidateCompactionEnvelope,\n} from \"./compaction/transaction.ts\";\nimport { redactSensitiveText } from \"./redaction.ts\";\nimport type { SessionIntegrityReport } from \"./session-integrity.ts\";\nimport { inspectSessionIntegrity } from \"./session-integrity.ts\";\nimport type { CompactionEntry, SessionEntry, SessionManager } from \"./session-manager.ts\";\n\nexport interface CapturedCompactionState {\n\treadonly report: SessionIntegrityReport;\n\treadonly branchEntries: readonly SessionEntry[];\n\treadonly revision: CompactionTransaction[\"baseRevision\"];\n\treadonly source: CompactionSourceIdentity;\n}\n\nexport interface BegunCompaction {\n\treadonly capture: CapturedCompactionState;\n\treadonly transaction: CompactionTransaction;\n}\n\nexport interface CommittedCompaction {\n\treadonly entry: CompactionEntry;\n\treadonly envelope: CompactionEnvelope;\n}\n\nexport interface SessionCompactionServiceDeps {\n\treadonly sessionManager: SessionManager;\n\treadonly pendingToolCallIds: () => ReadonlySet<string>;\n\treadonly getUserMessageText: (message: Message) => string;\n\treadonly cwd: string;\n\treadonly invalidateContextBudget: () => void;\n\t/** Refresh agent messages from the session manager after tail repair/commit. */\n\treadonly refreshAgentMessages: () => void;\n\t/** Hysteresis bookkeeping after a successful commit. */\n\treadonly recordCommit: () => void;\n}\n\nexport class SessionCompactionService {\n\tprivate readonly deps: SessionCompactionServiceDeps;\n\n\tconstructor(deps: SessionCompactionServiceDeps) {\n\t\tthis.deps = deps;\n\t}\n\n\tcaptureState(): CapturedCompactionState {\n\t\treturn this.deps.sessionManager.withCompactionCommitLock(() => this.captureStateLocked());\n\t}\n\n\tprivate captureStateLocked(): CapturedCompactionState {\n\t\tconst { sessionManager } = this.deps;\n\t\tconst sessionFile = sessionManager.getSessionFile();\n\t\tconst bytes =\n\t\t\tsessionFile && existsSync(sessionFile)\n\t\t\t\t? new Uint8Array(readFileSync(sessionFile))\n\t\t\t\t: new TextEncoder().encode(\n\t\t\t\t\t\t`${[sessionManager.getHeader(), ...sessionManager.getEntries()]\n\t\t\t\t\t\t\t.filter((entry) => entry !== null)\n\t\t\t\t\t\t\t.map((entry) => JSON.stringify(entry))\n\t\t\t\t\t\t\t.join(\"\\n\")}\\n`,\n\t\t\t\t\t);\n\t\tconst report = inspectSessionIntegrity(bytes, { activeLeafId: sessionManager.getLeafId() });\n\t\tconst branchEntries = report.activeBranch;\n\t\tlet latestCompactionIndex = -1;\n\t\tfor (let index = branchEntries.length - 1; index >= 0; index -= 1) {\n\t\t\tif (branchEntries[index]?.type === \"compaction\") {\n\t\t\t\tlatestCompactionIndex = index;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tconst latestCompaction = branchEntries[latestCompactionIndex];\n\t\tconst firstKeptIndex =\n\t\t\tlatestCompaction?.type === \"compaction\"\n\t\t\t\t? branchEntries.findIndex((entry) => entry.id === latestCompaction.firstKeptEntryId)\n\t\t\t\t: -1;\n\t\tconst sourceEntries = branchEntries.slice(\n\t\t\tlatestCompactionIndex < 0 ? 0 : firstKeptIndex < 0 ? latestCompactionIndex : firstKeptIndex,\n\t\t);\n\t\tconst firstEntry = sourceEntries[0];\n\t\tconst lastEntry = sourceEntries.at(-1);\n\t\tif (!firstEntry || !lastEntry || report.activeLeafId === null) {\n\t\t\tconst barrier = evaluateCompactionBarrier(report, [...this.deps.pendingToolCallIds()]);\n\t\t\tif (barrier.status !== \"ready\") throw this.barrierError(barrier);\n\t\t\tthrow new Error(\"Nothing to compact: the active session branch is empty\");\n\t\t}\n\t\tconst revision = sessionManager.getDurableHeadToken();\n\t\tconst source = createCompactionSourceIdentity({\n\t\t\tsessionId: revision.sessionId,\n\t\t\tentryIds: sourceEntries.map((entry) => entry.id),\n\t\t\tfirstEntryId: firstEntry.id,\n\t\t\tlastEntryId: lastEntry.id,\n\t\t\tsourceSha256: createHash(\"sha256\")\n\t\t\t\t.update(sourceEntries.map((entry) => JSON.stringify(entry)).join(\"\\n\"), \"utf8\")\n\t\t\t\t.digest(\"hex\"),\n\t\t\tactiveLeafId: report.activeLeafId,\n\t\t\tmessageCount: report.activeMessages.length,\n\t\t});\n\t\treturn { report, branchEntries, revision, source };\n\t}\n\n\tbarrierError(barrier: CompactionBarrierResult): Error {\n\t\tif (barrier.status === \"defer\") {\n\t\t\treturn new Error(`Compaction deferred until the transcript closes (${barrier.reason})`);\n\t\t}\n\t\treturn new Error(\n\t\t\t`Compaction failed closed on transcript integrity (${barrier.reason}). Run the session doctor before retrying.`,\n\t\t);\n\t}\n\n\tevaluateBarrier(\n\t\tcapture: CapturedCompactionState,\n\t\tincludeMissingTailAsPending: boolean,\n\t\texcludedPendingIds: ReadonlySet<string> = new Set(),\n\t): CompactionBarrierResult {\n\t\tconst pending = new Set([...this.deps.pendingToolCallIds()].filter((id) => !excludedPendingIds.has(id)));\n\t\tif (includeMissingTailAsPending) {\n\t\t\tfor (const issue of capture.report.transcript?.issues ?? []) {\n\t\t\t\tif (issue.kind === \"missing_result\") pending.add(issue.toolCallId);\n\t\t\t}\n\t\t}\n\t\treturn evaluateCompactionBarrier(capture.report, [...pending]);\n\t}\n\n\trepairEmergencyTail(capture: CapturedCompactionState): {\n\t\treadonly capture: CapturedCompactionState;\n\t\treadonly repairedToolCallIds: ReadonlySet<string>;\n\t} {\n\t\tconst { sessionManager } = this.deps;\n\t\tconst barrier = this.evaluateBarrier(capture, true);\n\t\tif (barrier.status !== \"defer\" || barrier.reason !== \"missing_active_tail_results\") {\n\t\t\tif (barrier.status !== \"ready\") throw this.barrierError(barrier);\n\t\t\treturn { capture, repairedToolCallIds: new Set() };\n\t\t}\n\t\tconst repairedMessages = repairTranscriptIntegrity(\n\t\t\t[...capture.report.activeMessages],\n\t\t\t\"Tool result missing; synthesized to close an emergency compaction barrier\",\n\t\t);\n\t\tconst inserted = repairedMessages.slice(capture.report.activeMessages.length);\n\t\tconst repairedToolCallIds = new Set<string>();\n\t\tfor (const message of inserted) {\n\t\t\tif (message.role !== \"toolResult\") {\n\t\t\t\tthrow new Error(\"Emergency compaction repair produced a non-tool result\");\n\t\t\t}\n\t\t\tconst toolResult: ToolResultMessage = message;\n\t\t\trepairedToolCallIds.add(toolResult.toolCallId);\n\t\t\tsessionManager.appendMessage(toolResult);\n\t\t}\n\t\tsessionManager.appendCustomEntry(\"compaction_transcript_repaired\", {\n\t\t\tinsertedToolCallIds: [...repairedToolCallIds],\n\t\t\treason: \"emergency_compaction\",\n\t\t});\n\t\tthis.deps.invalidateContextBudget();\n\t\tconst closedCapture = this.captureState();\n\t\tconst closedBarrier = this.evaluateBarrier(closedCapture, false, repairedToolCallIds);\n\t\tif (closedBarrier.status !== \"ready\") throw this.barrierError(closedBarrier);\n\t\tthis.deps.refreshAgentMessages();\n\t\treturn { capture: closedCapture, repairedToolCallIds };\n\t}\n\n\tpriorCommittedSourceDigests(): string[] {\n\t\tconst digests: string[] = [];\n\t\tfor (const entry of this.deps.sessionManager.getEntries()) {\n\t\t\tif (entry.type !== \"compaction\" || typeof entry.details !== \"object\" || entry.details === null) continue;\n\t\t\tif (!Object.hasOwn(entry.details, \"compactionEnvelope\")) continue;\n\t\t\tconst envelope = validateCompactionEnvelope(Reflect.get(entry.details, \"compactionEnvelope\"));\n\t\t\tif (envelope.summary !== entry.summary) {\n\t\t\t\tthrow new Error(`Compaction entry ${entry.id} has invalid provenance. Run the session doctor.`);\n\t\t\t}\n\t\t\tdigests.push(envelope.source.sourceSha256);\n\t\t}\n\t\treturn digests;\n\t}\n\n\tbeginTransaction(compactionModel: Model<Api>, emergency: boolean): BegunCompaction {\n\t\tlet capture = this.captureState();\n\t\tif (emergency) {\n\t\t\tcapture = this.repairEmergencyTail(capture).capture;\n\t\t} else {\n\t\t\tconst barrier = this.evaluateBarrier(capture, false);\n\t\t\tif (barrier.status !== \"ready\") throw this.barrierError(barrier);\n\t\t}\n\t\tconst transaction = createCompactionTransaction({\n\t\t\ttransactionId: randomUUID(),\n\t\t\tbaseRevision: capture.revision,\n\t\t\tsource: capture.source,\n\t\t\tcreatedAt: new Date().toISOString(),\n\t\t\tmodel: { provider: compactionModel.provider, id: compactionModel.id },\n\t\t\tpreserved: this.captureProvenance(capture),\n\t\t});\n\t\tif (this.priorCommittedSourceDigests().includes(transaction.source.sourceSha256)) {\n\t\t\tthrow new Error(\"This exact compaction source was already compacted\");\n\t\t}\n\t\treturn { capture, transaction };\n\t}\n\n\tcaptureProvenance(capture: CapturedCompactionState): CompactionPreservedProvenanceInput {\n\t\tlet latestIntent = \"Continue the current session\";\n\t\tfor (let index = capture.report.activeMessages.length - 1; index >= 0; index -= 1) {\n\t\t\tconst message = capture.report.activeMessages[index];\n\t\t\tif (message?.role !== \"user\") continue;\n\t\t\tconst candidate = sanitizeBinaryOutput(redactSensitiveText(this.deps.getUserMessageText(message)).trim())\n\t\t\t\t.replace(/[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]/gu, \"\")\n\t\t\t\t.slice(0, 16_384);\n\t\t\tif (candidate.length > 0) latestIntent = candidate;\n\t\t\tbreak;\n\t\t}\n\t\tconst modelHistory = capture.branchEntries\n\t\t\t.flatMap((entry) => {\n\t\t\t\tif (entry.type === \"model_change\") {\n\t\t\t\t\treturn [{ entryId: entry.id, provider: entry.provider, modelId: entry.modelId }];\n\t\t\t\t}\n\t\t\t\tif (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\t\t\treturn [{ entryId: entry.id, provider: entry.message.provider, modelId: entry.message.model }];\n\t\t\t\t}\n\t\t\t\treturn [];\n\t\t\t})\n\t\t\t.slice(-256);\n\t\tconst customEntryIds = (customType: string): string[] =>\n\t\t\tcapture.branchEntries\n\t\t\t\t.filter((entry) => entry.type === \"custom\" && entry.customType === customType)\n\t\t\t\t.map((entry) => entry.id);\n\t\treturn {\n\t\t\tlatestIntent,\n\t\t\topenTasks: [],\n\t\t\tlaneIds: customEntryIds(\"lane\"),\n\t\t\tacceptancePredicateIds: customEntryIds(\"acceptance_predicate\"),\n\t\t\tevidenceReceiptIds: customEntryIds(\"evidence_receipt\"),\n\t\t\tblockerReasons: [],\n\t\t\trepairEventIds: [\n\t\t\t\t...customEntryIds(\"transcript_repaired\"),\n\t\t\t\t...customEntryIds(\"compaction_transcript_repaired\"),\n\t\t\t],\n\t\t\tbranch: null,\n\t\t\tworktree: this.deps.cwd,\n\t\t\tmodelHistory,\n\t\t\tnextAction: latestIntent.slice(0, 4096) || \"Continue the current session\",\n\t\t};\n\t}\n\n\tdetailsWithEnvelope(details: unknown, envelope: CompactionEnvelope): unknown {\n\t\tif (typeof details === \"object\" && details !== null && !Array.isArray(details)) {\n\t\t\treturn { ...details, compactionEnvelope: envelope };\n\t\t}\n\t\treturn {\n\t\t\tcompactionEnvelope: envelope,\n\t\t\t...(details === undefined ? {} : { resultDetails: details }),\n\t\t};\n\t}\n\n\tcommit(begun: BegunCompaction, result: CompactionResult, fromExtension: boolean): CommittedCompaction {\n\t\tif (!begun.transaction.source.entryIds.includes(result.firstKeptEntryId)) {\n\t\t\tthrow new Error(\"Compaction first-kept entry is outside the captured source\");\n\t\t}\n\t\tconst committed = this.deps.sessionManager.withCompactionCommitLock(() => {\n\t\t\tconst current = this.captureState();\n\t\t\tconst barrier = this.evaluateBarrier(current, false);\n\t\t\tconst decision = decideCompactionCommit({\n\t\t\t\ttransaction: begun.transaction,\n\t\t\t\tcurrentRevision: current.revision,\n\t\t\t\tcurrentSource: current.source,\n\t\t\t\tbarrier,\n\t\t\t\tpriorCommittedSourceDigests: this.priorCommittedSourceDigests(),\n\t\t\t});\n\t\t\tswitch (decision.decision) {\n\t\t\t\tcase \"duplicate\":\n\t\t\t\t\tthrow new Error(\"This exact compaction source was already compacted\");\n\t\t\t\tcase \"stale\":\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`Session changed during compaction (${decision.reason}); generated summary was discarded`,\n\t\t\t\t\t);\n\t\t\t\tcase \"defer\":\n\t\t\t\tcase \"fail_closed\":\n\t\t\t\t\tthrow this.barrierError(barrier);\n\t\t\t\tcase \"commit\": {\n\t\t\t\t\tconst envelope = createCompactionEnvelope({\n\t\t\t\t\t\ttransaction: begun.transaction,\n\t\t\t\t\t\tdecision,\n\t\t\t\t\t\tsummary: result.summary,\n\t\t\t\t\t\tsummarySha256: createHash(\"sha256\").update(result.summary, \"utf8\").digest(\"hex\"),\n\t\t\t\t\t});\n\t\t\t\t\tconst entryId = this.deps.sessionManager.appendCompaction(\n\t\t\t\t\t\tresult.summary,\n\t\t\t\t\t\tresult.firstKeptEntryId,\n\t\t\t\t\t\tresult.tokensBefore,\n\t\t\t\t\t\tthis.detailsWithEnvelope(result.details, envelope),\n\t\t\t\t\t\tfromExtension,\n\t\t\t\t\t);\n\t\t\t\t\tconst entry = this.deps.sessionManager.getEntry(entryId);\n\t\t\t\t\tif (!entry || entry.type !== \"compaction\") {\n\t\t\t\t\t\tthrow new Error(\"Compaction commit did not produce a compaction entry\");\n\t\t\t\t\t}\n\t\t\t\t\treturn { entry, envelope };\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t\tthis.deps.recordCommit();\n\t\treturn committed;\n\t}\n}\n"]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface SkillDirFingerprint {
|
|
2
|
+
readonly files: number;
|
|
3
|
+
readonly maxMtimeMs: number;
|
|
4
|
+
readonly totalSize: number;
|
|
5
|
+
}
|
|
6
|
+
export interface SkillCatalogCacheEntry<T> {
|
|
7
|
+
readonly fingerprint: SkillDirFingerprint;
|
|
8
|
+
readonly result: T;
|
|
9
|
+
}
|
|
10
|
+
type CatalogStore = Record<string, SkillCatalogCacheEntry<unknown>>;
|
|
11
|
+
/** Fingerprint walk: readdir/stat only, no file reads. Over-inclusive is safe. */
|
|
12
|
+
export declare function fingerprintSkillDir(root: string): SkillDirFingerprint;
|
|
13
|
+
export declare function readSkillCatalog(agentDir: string): CatalogStore;
|
|
14
|
+
export declare function writeSkillCatalog(agentDir: string, store: CatalogStore): void;
|
|
15
|
+
/**
|
|
16
|
+
* Cache-guarded scan: returns the cached result when the fingerprint matches,
|
|
17
|
+
* otherwise runs `scan`, stores, and returns its fresh result. The result must
|
|
18
|
+
* be JSON-serializable (skills and diagnostics are plain data).
|
|
19
|
+
*/
|
|
20
|
+
export declare function cachedSkillScan<T>(agentDir: string | undefined, dir: string, scan: () => T, store?: CatalogStore): {
|
|
21
|
+
result: T;
|
|
22
|
+
store?: CatalogStore;
|
|
23
|
+
};
|
|
24
|
+
export {};
|
|
25
|
+
//# sourceMappingURL=skills-catalog-cache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"skills-catalog-cache.d.ts","sourceRoot":"","sources":["../../src/core/skills-catalog-cache.ts"],"names":[],"mappings":"AA4BA,MAAM,WAAW,mBAAmB;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,sBAAsB,CAAC,CAAC;IACxC,QAAQ,CAAC,WAAW,EAAE,mBAAmB,CAAC;IAC1C,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;CACnB;AAED,KAAK,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,sBAAsB,CAAC,OAAO,CAAC,CAAC,CAAC;AAOpE,kFAAkF;AAClF,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,CA8CrE;AAED,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,YAAY,CAO/D;AAED,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,GAAG,IAAI,CAa7E;AAMD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAChC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,CAAC,EACb,KAAK,CAAC,EAAE,YAAY,GAClB;IAAE,MAAM,EAAE,CAAC,CAAC;IAAC,KAAK,CAAC,EAAE,YAAY,CAAA;CAAE,CAcrC","sourcesContent":["/**\n * Persistent skill-catalog cache (startup fast path).\n *\n * A full scan walks every skill dir reading and frontmatter-parsing each\n * SKILL.md; on this host that is 800+ file reads per session start. The cache\n * replaces that with a fingerprint walk (readdir/stat only, no file reads):\n * when `{fileCount, maxMtimeMs, totalSize}` matches the stored fingerprint the\n * parsed catalog is reused verbatim. Any add/edit/delete under the scanned\n * tree changes the fingerprint and triggers exactly one full rescan.\n *\n * Fail-soft: a corrupt or unreadable cache is a miss, never an error. Writes\n * are atomic (tmp + rename). Cross-instance safety relies on the fingerprint,\n * not on the file: another process editing skills between our write and our\n * read still invalidates on the next fingerprint walk.\n */\nimport {\n\ttype Dirent,\n\texistsSync,\n\tmkdirSync,\n\treaddirSync,\n\treadFileSync,\n\trenameSync,\n\ttype Stats,\n\tstatSync,\n\twriteFileSync,\n} from \"node:fs\";\nimport { join, resolve } from \"node:path\";\n\nexport interface SkillDirFingerprint {\n\treadonly files: number;\n\treadonly maxMtimeMs: number;\n\treadonly totalSize: number;\n}\n\nexport interface SkillCatalogCacheEntry<T> {\n\treadonly fingerprint: SkillDirFingerprint;\n\treadonly result: T;\n}\n\ntype CatalogStore = Record<string, SkillCatalogCacheEntry<unknown>>;\n\nconst CACHE_FILE_NAME = \"skill-catalog-v1.json\";\nconst MAX_ENTRIES = 64;\nconst MAX_WALK_DEPTH = 8;\nconst MAX_WALK_ENTRIES = 20_000;\n\n/** Fingerprint walk: readdir/stat only, no file reads. Over-inclusive is safe. */\nexport function fingerprintSkillDir(root: string): SkillDirFingerprint {\n\tlet files = 0;\n\tlet maxMtimeMs = 0;\n\tlet totalSize = 0;\n\tlet walked = 0;\n\tconst visited = new Set<string>();\n\n\tconst walk = (dir: string, depth: number): void => {\n\t\tif (depth > MAX_WALK_DEPTH || walked > MAX_WALK_ENTRIES) return;\n\t\tlet real: string;\n\t\ttry {\n\t\t\treal = resolve(dir);\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\t\tif (visited.has(real)) return;\n\t\tvisited.add(real);\n\t\tlet entries: Dirent[];\n\t\ttry {\n\t\t\tentries = readdirSync(dir, { withFileTypes: true });\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\t\tfor (const entry of entries) {\n\t\t\twalked++;\n\t\t\tif (entry.name.startsWith(\".\") || entry.name === \"node_modules\") continue;\n\t\t\tconst fullPath = join(dir, entry.name);\n\t\t\tlet stats: Stats;\n\t\t\ttry {\n\t\t\t\tstats = statSync(fullPath);\n\t\t\t} catch {\n\t\t\t\tcontinue; // broken symlink or raced delete\n\t\t\t}\n\t\t\tif (stats.isDirectory()) {\n\t\t\t\twalk(fullPath, depth + 1);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (!stats.isFile()) continue;\n\t\t\tfiles++;\n\t\t\ttotalSize += stats.size;\n\t\t\tif (stats.mtimeMs > maxMtimeMs) maxMtimeMs = stats.mtimeMs;\n\t\t}\n\t};\n\n\twalk(root, 0);\n\treturn { files, maxMtimeMs, totalSize };\n}\n\nexport function readSkillCatalog(agentDir: string): CatalogStore {\n\ttry {\n\t\tconst raw = readFileSync(join(agentDir, \"cache\", CACHE_FILE_NAME), \"utf8\");\n\t\tconst parsed = JSON.parse(raw);\n\t\tif (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) return parsed as CatalogStore;\n\t} catch {}\n\treturn {};\n}\n\nexport function writeSkillCatalog(agentDir: string, store: CatalogStore): void {\n\ttry {\n\t\tconst cacheDir = join(agentDir, \"cache\");\n\t\tmkdirSync(cacheDir, { recursive: true });\n\t\tconst keys = Object.keys(store);\n\t\tconst trimmed: CatalogStore =\n\t\t\tkeys.length <= MAX_ENTRIES ? store : Object.fromEntries(keys.slice(-MAX_ENTRIES).map((k) => [k, store[k]]));\n\t\tconst tmp = join(cacheDir, `${CACHE_FILE_NAME}.tmp`);\n\t\twriteFileSync(tmp, JSON.stringify(trimmed));\n\t\trenameSync(tmp, join(cacheDir, CACHE_FILE_NAME));\n\t} catch {\n\t\t// cache is an optimization; never fail the scan\n\t}\n}\n\nfunction fingerprintEquals(a: SkillDirFingerprint, b: SkillDirFingerprint): boolean {\n\treturn a.files === b.files && a.maxMtimeMs === b.maxMtimeMs && a.totalSize === b.totalSize;\n}\n\n/**\n * Cache-guarded scan: returns the cached result when the fingerprint matches,\n * otherwise runs `scan`, stores, and returns its fresh result. The result must\n * be JSON-serializable (skills and diagnostics are plain data).\n */\nexport function cachedSkillScan<T>(\n\tagentDir: string | undefined,\n\tdir: string,\n\tscan: () => T,\n\tstore?: CatalogStore,\n): { result: T; store?: CatalogStore } {\n\tif (!agentDir || !existsSync(dir)) {\n\t\treturn { result: scan(), store };\n\t}\n\tconst catalog = store ?? readSkillCatalog(agentDir);\n\tconst fingerprint = fingerprintSkillDir(dir);\n\tconst key = resolve(dir);\n\tconst hit = catalog[key];\n\tif (hit && fingerprintEquals(hit.fingerprint, fingerprint)) {\n\t\treturn { result: structuredClone(hit.result) as T, store: catalog };\n\t}\n\tconst result = scan();\n\tcatalog[key] = { fingerprint, result };\n\treturn { result, store: catalog };\n}\n"]}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent skill-catalog cache (startup fast path).
|
|
3
|
+
*
|
|
4
|
+
* A full scan walks every skill dir reading and frontmatter-parsing each
|
|
5
|
+
* SKILL.md; on this host that is 800+ file reads per session start. The cache
|
|
6
|
+
* replaces that with a fingerprint walk (readdir/stat only, no file reads):
|
|
7
|
+
* when `{fileCount, maxMtimeMs, totalSize}` matches the stored fingerprint the
|
|
8
|
+
* parsed catalog is reused verbatim. Any add/edit/delete under the scanned
|
|
9
|
+
* tree changes the fingerprint and triggers exactly one full rescan.
|
|
10
|
+
*
|
|
11
|
+
* Fail-soft: a corrupt or unreadable cache is a miss, never an error. Writes
|
|
12
|
+
* are atomic (tmp + rename). Cross-instance safety relies on the fingerprint,
|
|
13
|
+
* not on the file: another process editing skills between our write and our
|
|
14
|
+
* read still invalidates on the next fingerprint walk.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, } from "node:fs";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
const CACHE_FILE_NAME = "skill-catalog-v1.json";
|
|
19
|
+
const MAX_ENTRIES = 64;
|
|
20
|
+
const MAX_WALK_DEPTH = 8;
|
|
21
|
+
const MAX_WALK_ENTRIES = 20_000;
|
|
22
|
+
/** Fingerprint walk: readdir/stat only, no file reads. Over-inclusive is safe. */
|
|
23
|
+
export function fingerprintSkillDir(root) {
|
|
24
|
+
let files = 0;
|
|
25
|
+
let maxMtimeMs = 0;
|
|
26
|
+
let totalSize = 0;
|
|
27
|
+
let walked = 0;
|
|
28
|
+
const visited = new Set();
|
|
29
|
+
const walk = (dir, depth) => {
|
|
30
|
+
if (depth > MAX_WALK_DEPTH || walked > MAX_WALK_ENTRIES)
|
|
31
|
+
return;
|
|
32
|
+
let real;
|
|
33
|
+
try {
|
|
34
|
+
real = resolve(dir);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (visited.has(real))
|
|
40
|
+
return;
|
|
41
|
+
visited.add(real);
|
|
42
|
+
let entries;
|
|
43
|
+
try {
|
|
44
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
walked++;
|
|
51
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
52
|
+
continue;
|
|
53
|
+
const fullPath = join(dir, entry.name);
|
|
54
|
+
let stats;
|
|
55
|
+
try {
|
|
56
|
+
stats = statSync(fullPath);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
continue; // broken symlink or raced delete
|
|
60
|
+
}
|
|
61
|
+
if (stats.isDirectory()) {
|
|
62
|
+
walk(fullPath, depth + 1);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!stats.isFile())
|
|
66
|
+
continue;
|
|
67
|
+
files++;
|
|
68
|
+
totalSize += stats.size;
|
|
69
|
+
if (stats.mtimeMs > maxMtimeMs)
|
|
70
|
+
maxMtimeMs = stats.mtimeMs;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
walk(root, 0);
|
|
74
|
+
return { files, maxMtimeMs, totalSize };
|
|
75
|
+
}
|
|
76
|
+
export function readSkillCatalog(agentDir) {
|
|
77
|
+
try {
|
|
78
|
+
const raw = readFileSync(join(agentDir, "cache", CACHE_FILE_NAME), "utf8");
|
|
79
|
+
const parsed = JSON.parse(raw);
|
|
80
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
81
|
+
return parsed;
|
|
82
|
+
}
|
|
83
|
+
catch { }
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
export function writeSkillCatalog(agentDir, store) {
|
|
87
|
+
try {
|
|
88
|
+
const cacheDir = join(agentDir, "cache");
|
|
89
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
90
|
+
const keys = Object.keys(store);
|
|
91
|
+
const trimmed = keys.length <= MAX_ENTRIES ? store : Object.fromEntries(keys.slice(-MAX_ENTRIES).map((k) => [k, store[k]]));
|
|
92
|
+
const tmp = join(cacheDir, `${CACHE_FILE_NAME}.tmp`);
|
|
93
|
+
writeFileSync(tmp, JSON.stringify(trimmed));
|
|
94
|
+
renameSync(tmp, join(cacheDir, CACHE_FILE_NAME));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// cache is an optimization; never fail the scan
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function fingerprintEquals(a, b) {
|
|
101
|
+
return a.files === b.files && a.maxMtimeMs === b.maxMtimeMs && a.totalSize === b.totalSize;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Cache-guarded scan: returns the cached result when the fingerprint matches,
|
|
105
|
+
* otherwise runs `scan`, stores, and returns its fresh result. The result must
|
|
106
|
+
* be JSON-serializable (skills and diagnostics are plain data).
|
|
107
|
+
*/
|
|
108
|
+
export function cachedSkillScan(agentDir, dir, scan, store) {
|
|
109
|
+
if (!agentDir || !existsSync(dir)) {
|
|
110
|
+
return { result: scan(), store };
|
|
111
|
+
}
|
|
112
|
+
const catalog = store ?? readSkillCatalog(agentDir);
|
|
113
|
+
const fingerprint = fingerprintSkillDir(dir);
|
|
114
|
+
const key = resolve(dir);
|
|
115
|
+
const hit = catalog[key];
|
|
116
|
+
if (hit && fingerprintEquals(hit.fingerprint, fingerprint)) {
|
|
117
|
+
return { result: structuredClone(hit.result), store: catalog };
|
|
118
|
+
}
|
|
119
|
+
const result = scan();
|
|
120
|
+
catalog[key] = { fingerprint, result };
|
|
121
|
+
return { result, store: catalog };
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=skills-catalog-cache.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"skills-catalog-cache.js","sourceRoot":"","sources":["../../src/core/skills-catalog-cache.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Persistent skill-catalog cache (startup fast path).\n *\n * A full scan walks every skill dir reading and frontmatter-parsing each\n * SKILL.md; on this host that is 800+ file reads per session start. The cache\n * replaces that with a fingerprint walk (readdir/stat only, no file reads):\n * when `{fileCount, maxMtimeMs, totalSize}` matches the stored fingerprint the\n * parsed catalog is reused verbatim. Any add/edit/delete under the scanned\n * tree changes the fingerprint and triggers exactly one full rescan.\n *\n * Fail-soft: a corrupt or unreadable cache is a miss, never an error. Writes\n * are atomic (tmp + rename). Cross-instance safety relies on the fingerprint,\n * not on the file: another process editing skills between our write and our\n * read still invalidates on the next fingerprint walk.\n */\nimport {\n\ttype Dirent,\n\texistsSync,\n\tmkdirSync,\n\treaddirSync,\n\treadFileSync,\n\trenameSync,\n\ttype Stats,\n\tstatSync,\n\twriteFileSync,\n} from \"node:fs\";\nimport { join, resolve } from \"node:path\";\n\nexport interface SkillDirFingerprint {\n\treadonly files: number;\n\treadonly maxMtimeMs: number;\n\treadonly totalSize: number;\n}\n\nexport interface SkillCatalogCacheEntry<T> {\n\treadonly fingerprint: SkillDirFingerprint;\n\treadonly result: T;\n}\n\ntype CatalogStore = Record<string, SkillCatalogCacheEntry<unknown>>;\n\nconst CACHE_FILE_NAME = \"skill-catalog-v1.json\";\nconst MAX_ENTRIES = 64;\nconst MAX_WALK_DEPTH = 8;\nconst MAX_WALK_ENTRIES = 20_000;\n\n/** Fingerprint walk: readdir/stat only, no file reads. Over-inclusive is safe. */\nexport function fingerprintSkillDir(root: string): SkillDirFingerprint {\n\tlet files = 0;\n\tlet maxMtimeMs = 0;\n\tlet totalSize = 0;\n\tlet walked = 0;\n\tconst visited = new Set<string>();\n\n\tconst walk = (dir: string, depth: number): void => {\n\t\tif (depth > MAX_WALK_DEPTH || walked > MAX_WALK_ENTRIES) return;\n\t\tlet real: string;\n\t\ttry {\n\t\t\treal = resolve(dir);\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\t\tif (visited.has(real)) return;\n\t\tvisited.add(real);\n\t\tlet entries: Dirent[];\n\t\ttry {\n\t\t\tentries = readdirSync(dir, { withFileTypes: true });\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\t\tfor (const entry of entries) {\n\t\t\twalked++;\n\t\t\tif (entry.name.startsWith(\".\") || entry.name === \"node_modules\") continue;\n\t\t\tconst fullPath = join(dir, entry.name);\n\t\t\tlet stats: Stats;\n\t\t\ttry {\n\t\t\t\tstats = statSync(fullPath);\n\t\t\t} catch {\n\t\t\t\tcontinue; // broken symlink or raced delete\n\t\t\t}\n\t\t\tif (stats.isDirectory()) {\n\t\t\t\twalk(fullPath, depth + 1);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (!stats.isFile()) continue;\n\t\t\tfiles++;\n\t\t\ttotalSize += stats.size;\n\t\t\tif (stats.mtimeMs > maxMtimeMs) maxMtimeMs = stats.mtimeMs;\n\t\t}\n\t};\n\n\twalk(root, 0);\n\treturn { files, maxMtimeMs, totalSize };\n}\n\nexport function readSkillCatalog(agentDir: string): CatalogStore {\n\ttry {\n\t\tconst raw = readFileSync(join(agentDir, \"cache\", CACHE_FILE_NAME), \"utf8\");\n\t\tconst parsed = JSON.parse(raw);\n\t\tif (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) return parsed as CatalogStore;\n\t} catch {}\n\treturn {};\n}\n\nexport function writeSkillCatalog(agentDir: string, store: CatalogStore): void {\n\ttry {\n\t\tconst cacheDir = join(agentDir, \"cache\");\n\t\tmkdirSync(cacheDir, { recursive: true });\n\t\tconst keys = Object.keys(store);\n\t\tconst trimmed: CatalogStore =\n\t\t\tkeys.length <= MAX_ENTRIES ? store : Object.fromEntries(keys.slice(-MAX_ENTRIES).map((k) => [k, store[k]]));\n\t\tconst tmp = join(cacheDir, `${CACHE_FILE_NAME}.tmp`);\n\t\twriteFileSync(tmp, JSON.stringify(trimmed));\n\t\trenameSync(tmp, join(cacheDir, CACHE_FILE_NAME));\n\t} catch {\n\t\t// cache is an optimization; never fail the scan\n\t}\n}\n\nfunction fingerprintEquals(a: SkillDirFingerprint, b: SkillDirFingerprint): boolean {\n\treturn a.files === b.files && a.maxMtimeMs === b.maxMtimeMs && a.totalSize === b.totalSize;\n}\n\n/**\n * Cache-guarded scan: returns the cached result when the fingerprint matches,\n * otherwise runs `scan`, stores, and returns its fresh result. The result must\n * be JSON-serializable (skills and diagnostics are plain data).\n */\nexport function cachedSkillScan<T>(\n\tagentDir: string | undefined,\n\tdir: string,\n\tscan: () => T,\n\tstore?: CatalogStore,\n): { result: T; store?: CatalogStore } {\n\tif (!agentDir || !existsSync(dir)) {\n\t\treturn { result: scan(), store };\n\t}\n\tconst catalog = store ?? readSkillCatalog(agentDir);\n\tconst fingerprint = fingerprintSkillDir(dir);\n\tconst key = resolve(dir);\n\tconst hit = catalog[key];\n\tif (hit && fingerprintEquals(hit.fingerprint, fingerprint)) {\n\t\treturn { result: structuredClone(hit.result) as T, store: catalog };\n\t}\n\tconst result = scan();\n\tcatalog[key] = { fingerprint, result };\n\treturn { result, store: catalog };\n}\n"]}
|
package/dist/core/skills.d.ts
CHANGED
|
@@ -55,6 +55,12 @@ export interface LoadSkillsOptions {
|
|
|
55
55
|
/** Include default skills directories. */
|
|
56
56
|
includeDefaults: boolean;
|
|
57
57
|
resolveSourceInfo?: SkillSourceInfoResolver;
|
|
58
|
+
/**
|
|
59
|
+
* Persistent skill-catalog cache. Default: enabled, except under test
|
|
60
|
+
* runners (VITEST) where writes are off unless explicitly opted in, so
|
|
61
|
+
* fixture directories stay pristine.
|
|
62
|
+
*/
|
|
63
|
+
catalogCache?: boolean;
|
|
58
64
|
}
|
|
59
65
|
/**
|
|
60
66
|
* Load skills from all configured locations.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../../src/core/skills.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAA6B,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAiE9E,MAAM,WAAW,gBAAgB;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,KAAK;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,UAAU,CAAC;IACvB,sBAAsB,EAAE,OAAO,CAAC;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAChC,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,WAAW,EAAE,kBAAkB,EAAE,CAAC;CAClC;AAqED,MAAM,WAAW,wBAAwB;IACxC,mCAAmC;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,yCAAyC;IACzC,MAAM,EAAE,MAAM,CAAC;IACf,iBAAiB,CAAC,EAAE,uBAAuB,CAAC;CAC5C;AAED,KAAK,uBAAuB,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,UAAU,GAAG,SAAS,CAAC;AA0B5E;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,gBAAgB,CAGrF;AA8JD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,CA0B7D;AAeD,MAAM,WAAW,iBAAiB;IACjC,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAC;IACZ,gDAAgD;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,0CAA0C;IAC1C,eAAe,EAAE,OAAO,CAAC;IACzB,iBAAiB,CAAC,EAAE,uBAAuB,CAAC;CAC5C;AAoGD;;;GAGG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,gBAAgB,CA2HvE","sourcesContent":["import { createHash } from \"node:crypto\";\nimport { existsSync, readdirSync, readFileSync, statSync } from \"fs\";\nimport ignore from \"ignore\";\nimport { basename, dirname, join, relative, resolve, sep } from \"path\";\nimport { CONFIG_DIR_NAME, getAgentDir } from \"../config.ts\";\nimport { parseFrontmatter } from \"../utils/frontmatter.ts\";\nimport { canonicalizePath, resolvePath } from \"../utils/paths.ts\";\nimport type { ResourceDiagnostic } from \"./diagnostics.ts\";\nimport { createSyntheticSourceInfo, type SourceInfo } from \"./source-info.ts\";\n\n/** Max name length per spec */\nconst MAX_NAME_LENGTH = 64;\n\n/** Max description length per spec */\nconst MAX_DESCRIPTION_LENGTH = 1024;\n\nconst IGNORE_FILE_NAMES = [\".gitignore\", \".ignore\", \".fdignore\"];\n\ntype IgnoreMatcher = ReturnType<typeof ignore>;\n\nfunction compareDirentNames(a: { name: string }, b: { name: string }): number {\n\tif (a.name < b.name) return -1;\n\tif (a.name > b.name) return 1;\n\treturn 0;\n}\n\nfunction toPosixPath(p: string): string {\n\treturn p.split(sep).join(\"/\");\n}\n\nfunction prefixIgnorePattern(line: string, prefix: string): string | null {\n\tconst trimmed = line.trim();\n\tif (!trimmed) return null;\n\tif (trimmed.startsWith(\"#\") && !trimmed.startsWith(\"\\\\#\")) return null;\n\n\tlet pattern = line;\n\tlet negated = false;\n\n\tif (pattern.startsWith(\"!\")) {\n\t\tnegated = true;\n\t\tpattern = pattern.slice(1);\n\t} else if (pattern.startsWith(\"\\\\!\")) {\n\t\tpattern = pattern.slice(1);\n\t}\n\n\tif (pattern.startsWith(\"/\")) {\n\t\tpattern = pattern.slice(1);\n\t}\n\n\tconst prefixed = prefix ? `${prefix}${pattern}` : pattern;\n\treturn negated ? `!${prefixed}` : prefixed;\n}\n\nfunction addIgnoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void {\n\tconst relativeDir = relative(rootDir, dir);\n\tconst prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : \"\";\n\n\tfor (const filename of IGNORE_FILE_NAMES) {\n\t\tconst ignorePath = join(dir, filename);\n\t\tif (!existsSync(ignorePath)) continue;\n\t\ttry {\n\t\t\tconst content = readFileSync(ignorePath, \"utf-8\");\n\t\t\tconst patterns = content\n\t\t\t\t.split(/\\r?\\n/)\n\t\t\t\t.map((line) => prefixIgnorePattern(line, prefix))\n\t\t\t\t.filter((line): line is string => Boolean(line));\n\t\t\tif (patterns.length > 0) {\n\t\t\t\tig.add(patterns);\n\t\t\t}\n\t\t} catch {}\n\t}\n}\n\nexport interface SkillFrontmatter {\n\tname?: string;\n\tdescription?: string;\n\t\"disable-model-invocation\"?: boolean;\n\t[key: string]: unknown;\n}\n\nexport interface Skill {\n\tname: string;\n\tdescription: string;\n\tfilePath: string;\n\tbaseDir: string;\n\tsourceInfo: SourceInfo;\n\tdisableModelInvocation: boolean;\n\tcontentHash?: string;\n}\n\nexport interface LoadSkillsResult {\n\tskills: Skill[];\n\tdiagnostics: ResourceDiagnostic[];\n}\n\n/**\n * Validate skill name per Agent Skills spec.\n * Returns array of validation error messages (empty if valid).\n */\nfunction validateName(name: string): string[] {\n\tconst errors: string[] = [];\n\n\tif (name.length > MAX_NAME_LENGTH) {\n\t\terrors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`);\n\t}\n\n\tif (!/^[a-z0-9-]+$/.test(name)) {\n\t\terrors.push(`name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)`);\n\t}\n\n\tif (name.startsWith(\"-\") || name.endsWith(\"-\")) {\n\t\terrors.push(`name must not start or end with a hyphen`);\n\t}\n\n\tif (name.includes(\"--\")) {\n\t\terrors.push(`name must not contain consecutive hyphens`);\n\t}\n\n\treturn errors;\n}\n\n/**\n * Validate description per Agent Skills spec.\n */\nfunction validateDescription(description: string | undefined): string[] {\n\tconst errors: string[] = [];\n\n\tif (!description || description.trim() === \"\") {\n\t\terrors.push(\"description is required\");\n\t} else if (description.length > MAX_DESCRIPTION_LENGTH) {\n\t\terrors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`);\n\t}\n\n\treturn errors;\n}\n\nfunction sanitizeSkillLoadError(error: unknown): string {\n\tconst fallback = \"failed to parse skill file\";\n\tif (typeof error !== \"object\" || error === null || !(\"linePos\" in error)) {\n\t\treturn fallback;\n\t}\n\n\tconst linePos = error.linePos;\n\tif (!Array.isArray(linePos)) {\n\t\treturn fallback;\n\t}\n\n\tconst firstPosition = linePos[0];\n\tif (\n\t\ttypeof firstPosition !== \"object\" ||\n\t\tfirstPosition === null ||\n\t\t!(\"line\" in firstPosition) ||\n\t\t!(\"col\" in firstPosition) ||\n\t\ttypeof firstPosition.line !== \"number\" ||\n\t\ttypeof firstPosition.col !== \"number\"\n\t) {\n\t\treturn fallback;\n\t}\n\n\treturn `${fallback} at line ${firstPosition.line}, column ${firstPosition.col}`;\n}\n\nexport interface LoadSkillsFromDirOptions {\n\t/** Directory to scan for skills */\n\tdir: string;\n\t/** Source identifier for these skills */\n\tsource: string;\n\tresolveSourceInfo?: SkillSourceInfoResolver;\n}\n\ntype SkillSourceInfoResolver = (filePath: string) => SourceInfo | undefined;\n\nfunction createSkillSourceInfo(filePath: string, baseDir: string, source: string): SourceInfo {\n\tswitch (source) {\n\t\tcase \"user\":\n\t\t\treturn createSyntheticSourceInfo(filePath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tscope: \"user\",\n\t\t\t\tbaseDir,\n\t\t\t});\n\t\tcase \"project\":\n\t\t\treturn createSyntheticSourceInfo(filePath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tscope: \"project\",\n\t\t\t\tbaseDir,\n\t\t\t});\n\t\tcase \"path\":\n\t\t\treturn createSyntheticSourceInfo(filePath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tbaseDir,\n\t\t\t});\n\t\tdefault:\n\t\t\treturn createSyntheticSourceInfo(filePath, { source, baseDir });\n\t}\n}\n\n/**\n * Load skills from a directory.\n *\n * Discovery rules:\n * - if a directory contains SKILL.md, treat it as a skill root and do not recurse further\n * - otherwise, load direct .md children in the root\n * - recurse into subdirectories to find SKILL.md\n */\nexport function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {\n\tconst { dir, resolveSourceInfo, source } = options;\n\treturn loadSkillsFromDirInternal(dir, source, true, undefined, undefined, resolveSourceInfo);\n}\n\nfunction loadSkillsFromDirInternal(\n\tdir: string,\n\tsource: string,\n\tincludeRootFiles: boolean,\n\tignoreMatcher?: IgnoreMatcher,\n\trootDir?: string,\n\tresolveSourceInfo?: SkillSourceInfoResolver,\n): LoadSkillsResult {\n\tconst skills: Skill[] = [];\n\tconst diagnostics: ResourceDiagnostic[] = [];\n\n\tif (!existsSync(dir)) {\n\t\treturn { skills, diagnostics };\n\t}\n\n\tconst root = rootDir ?? dir;\n\tconst ig = ignoreMatcher ?? ignore();\n\taddIgnoreRules(ig, dir, root);\n\n\ttry {\n\t\tconst entries = readdirSync(dir, { withFileTypes: true }).sort(compareDirentNames);\n\n\t\tfor (const entry of entries) {\n\t\t\tif (entry.name !== \"SKILL.md\") {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst fullPath = join(dir, entry.name);\n\n\t\t\tlet isFile = entry.isFile();\n\t\t\tif (entry.isSymbolicLink()) {\n\t\t\t\ttry {\n\t\t\t\t\tisFile = statSync(fullPath).isFile();\n\t\t\t\t} catch {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst relPath = toPosixPath(relative(root, fullPath));\n\t\t\tif (!isFile || ig.ignores(relPath)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst result = loadSkillFromFile(fullPath, source, resolveSourceInfo?.(fullPath));\n\t\t\tif (result.skill) {\n\t\t\t\tskills.push(result.skill);\n\t\t\t}\n\t\t\tdiagnostics.push(...result.diagnostics);\n\t\t\treturn { skills, diagnostics };\n\t\t}\n\n\t\tfor (const entry of entries) {\n\t\t\tif (entry.name.startsWith(\".\")) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Skip node_modules to avoid scanning dependencies\n\t\t\tif (entry.name === \"node_modules\") {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst fullPath = join(dir, entry.name);\n\n\t\t\t// For symlinks, check if they point to a directory and follow them\n\t\t\tlet isDirectory = entry.isDirectory();\n\t\t\tlet isFile = entry.isFile();\n\t\t\tif (entry.isSymbolicLink()) {\n\t\t\t\ttry {\n\t\t\t\t\tconst stats = statSync(fullPath);\n\t\t\t\t\tisDirectory = stats.isDirectory();\n\t\t\t\t\tisFile = stats.isFile();\n\t\t\t\t} catch {\n\t\t\t\t\t// Broken symlink, skip it\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst relPath = toPosixPath(relative(root, fullPath));\n\t\t\tconst ignorePath = isDirectory ? `${relPath}/` : relPath;\n\t\t\tif (ig.ignores(ignorePath)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (isDirectory) {\n\t\t\t\tconst subResult = loadSkillsFromDirInternal(fullPath, source, false, ig, root, resolveSourceInfo);\n\t\t\t\tskills.push(...subResult.skills);\n\t\t\t\tdiagnostics.push(...subResult.diagnostics);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (!isFile || !includeRootFiles || !entry.name.endsWith(\".md\")) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst result = loadSkillFromFile(fullPath, source, resolveSourceInfo?.(fullPath));\n\t\t\tif (result.skill) {\n\t\t\t\tskills.push(result.skill);\n\t\t\t}\n\t\t\tdiagnostics.push(...result.diagnostics);\n\t\t}\n\t} catch {}\n\n\treturn { skills, diagnostics };\n}\n\nfunction loadSkillFromFile(\n\tfilePath: string,\n\tsource: string,\n\tsourceInfoOverride?: SourceInfo,\n): { skill: Skill | null; diagnostics: ResourceDiagnostic[] } {\n\tconst diagnostics: ResourceDiagnostic[] = [];\n\n\ttry {\n\t\tconst rawContent = readFileSync(filePath, \"utf-8\");\n\t\tconst { frontmatter } = parseFrontmatter<SkillFrontmatter>(rawContent);\n\t\tconst skillDir = dirname(filePath);\n\t\tconst parentDirName = basename(skillDir);\n\n\t\t// Validate description\n\t\tconst descErrors = validateDescription(frontmatter.description);\n\t\tfor (const error of descErrors) {\n\t\t\tdiagnostics.push({ type: \"warning\", message: error, path: filePath });\n\t\t}\n\n\t\t// Use name from frontmatter, or fall back to parent directory name\n\t\tconst name = frontmatter.name || parentDirName;\n\n\t\t// Validate name\n\t\tconst nameErrors = validateName(name);\n\t\tfor (const error of nameErrors) {\n\t\t\tdiagnostics.push({ type: \"warning\", message: error, path: filePath });\n\t\t}\n\n\t\t// Still load the skill even with warnings (unless description is completely missing)\n\t\tif (!frontmatter.description || frontmatter.description.trim() === \"\") {\n\t\t\treturn { skill: null, diagnostics };\n\t\t}\n\n\t\treturn {\n\t\t\tskill: {\n\t\t\t\tname,\n\t\t\t\tdescription: frontmatter.description,\n\t\t\t\tfilePath,\n\t\t\t\tbaseDir: skillDir,\n\t\t\t\tsourceInfo: sourceInfoOverride ?? createSkillSourceInfo(filePath, skillDir, source),\n\t\t\t\tdisableModelInvocation: frontmatter[\"disable-model-invocation\"] === true,\n\t\t\t\tcontentHash: hashSkillContent(rawContent),\n\t\t\t},\n\t\t\tdiagnostics,\n\t\t};\n\t} catch (error) {\n\t\tdiagnostics.push({ type: \"warning\", message: sanitizeSkillLoadError(error), path: filePath });\n\t\treturn { skill: null, diagnostics };\n\t}\n}\n\n/**\n * Format skills for inclusion in a system prompt.\n * Uses XML format per Agent Skills standard.\n * See: https://agentskills.io/integrate-skills\n *\n * Skills with disableModelInvocation=true are excluded from the prompt\n * (they can only be invoked explicitly via /skill:name commands).\n */\nexport function formatSkillsForPrompt(skills: Skill[]): string {\n\tconst visibleSkills = skills.filter((s) => !s.disableModelInvocation);\n\n\tif (visibleSkills.length === 0) {\n\t\treturn \"\";\n\t}\n\n\tconst lines = [\n\t\t\"\\n\\nThe following skills provide specialized instructions for specific tasks.\",\n\t\t\"Use the read tool to load a skill's file when the task matches its description.\",\n\t\t\"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.\",\n\t\t\"\",\n\t\t\"<available_skills>\",\n\t];\n\n\tfor (const skill of visibleSkills) {\n\t\tlines.push(\" <skill>\");\n\t\tlines.push(` <name>${escapeXml(skill.name)}</name>`);\n\t\tlines.push(` <description>${escapeXml(skill.description)}</description>`);\n\t\tlines.push(` <location>${escapeXml(skill.filePath)}</location>`);\n\t\tlines.push(\" </skill>\");\n\t}\n\n\tlines.push(\"</available_skills>\");\n\n\treturn lines.join(\"\\n\");\n}\n\nfunction escapeXml(str: string): string {\n\treturn str\n\t\t.replace(/&/g, \"&\")\n\t\t.replace(/</g, \"<\")\n\t\t.replace(/>/g, \">\")\n\t\t.replace(/\"/g, \""\")\n\t\t.replace(/'/g, \"'\");\n}\n\nfunction hashSkillContent(content: string): string {\n\treturn createHash(\"sha256\").update(content).digest(\"hex\");\n}\n\nexport interface LoadSkillsOptions {\n\t/** Working directory for project-local skills. */\n\tcwd: string;\n\t/** Agent config directory for global skills. */\n\tagentDir: string;\n\t/** Explicit skill paths (files or directories) */\n\tskillPaths: string[];\n\t/** Include default skills directories. */\n\tincludeDefaults: boolean;\n\tresolveSourceInfo?: SkillSourceInfoResolver;\n}\n\ninterface SkillCandidate {\n\tskill: Skill;\n\trealPath: string;\n\torder: number;\n}\n\nfunction skillPrecedenceRank(skill: Skill): number {\n\tconst { origin, scope, source } = skill.sourceInfo;\n\tif (origin === \"package\") {\n\t\tif (scope === \"project\") {\n\t\t\treturn 4;\n\t\t}\n\t\tif (scope === \"user\") {\n\t\t\treturn 5;\n\t\t}\n\t\treturn 6;\n\t}\n\tif (scope === \"project\") {\n\t\treturn source === \"auto\" ? 1 : 0;\n\t}\n\tif (scope === \"user\") {\n\t\treturn source === \"auto\" ? 3 : 2;\n\t}\n\treturn 7;\n}\n\nfunction compareStrings(a: string, b: string): number {\n\tif (a < b) return -1;\n\tif (a > b) return 1;\n\treturn 0;\n}\n\nfunction compareSkillCandidates(a: SkillCandidate, b: SkillCandidate): number {\n\tconst rankDelta = skillPrecedenceRank(a.skill) - skillPrecedenceRank(b.skill);\n\tif (rankDelta !== 0) {\n\t\treturn rankDelta;\n\t}\n\n\tconst pathDelta = compareStrings(a.realPath, b.realPath);\n\tif (pathDelta !== 0) {\n\t\treturn pathDelta;\n\t}\n\n\treturn a.order - b.order;\n}\n\nfunction getSkillCollisionReason(\n\twinner: SkillCandidate,\n\tloser: SkillCandidate,\n): {\n\treason: string;\n\taction: string;\n} {\n\tif (skillPrecedenceRank(winner.skill) !== skillPrecedenceRank(loser.skill)) {\n\t\treturn {\n\t\t\treason: \"higher-precedence skill source\",\n\t\t\taction: \"Rename one skill or remove the lower-precedence duplicate.\",\n\t\t};\n\t}\n\n\tif (winner.realPath !== loser.realPath) {\n\t\treturn {\n\t\t\treason: \"same-precedence canonical path ordering\",\n\t\t\taction: \"Rename one skill or move one duplicate so the intended canonical path sorts first.\",\n\t\t};\n\t}\n\n\treturn {\n\t\treason: \"earlier configured skill path\",\n\t\taction: \"Rename one skill or reorder configured skill paths so the intended skill is loaded first.\",\n\t};\n}\n\nfunction createSkillCollisionDiagnostic(winner: SkillCandidate, loser: SkillCandidate): ResourceDiagnostic {\n\tconst { reason, action } = getSkillCollisionReason(winner, loser);\n\tconst winnerSkill = winner.skill;\n\tconst loserSkill = loser.skill;\n\treturn {\n\t\ttype: \"collision\",\n\t\tmessage: `name \"${loserSkill.name}\" collision: kept ${winnerSkill.filePath}; skipped ${loserSkill.filePath}; reason: ${reason}; action: ${action}`,\n\t\tpath: loserSkill.filePath,\n\t\tcollision: {\n\t\t\tresourceType: \"skill\",\n\t\t\tname: loserSkill.name,\n\t\t\twinnerPath: winnerSkill.filePath,\n\t\t\tloserPath: loserSkill.filePath,\n\t\t\twinnerSource: winnerSkill.sourceInfo.source,\n\t\t\tloserSource: loserSkill.sourceInfo.source,\n\t\t\twinnerScope: winnerSkill.sourceInfo.scope,\n\t\t\tloserScope: loserSkill.sourceInfo.scope,\n\t\t\twinnerOrigin: winnerSkill.sourceInfo.origin,\n\t\t\tloserOrigin: loserSkill.sourceInfo.origin,\n\t\t\tresolutionReason: reason,\n\t\t\tresolutionAction: action,\n\t\t},\n\t};\n}\n\n/**\n * Load skills from all configured locations.\n * Returns skills and any validation diagnostics.\n */\nexport function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {\n\tconst { agentDir, skillPaths, includeDefaults, resolveSourceInfo } = options;\n\n\t// Resolve agentDir - if not provided, use default from config\n\tconst resolvedCwd = resolvePath(options.cwd);\n\tconst resolvedAgentDir = resolvePath(agentDir ?? getAgentDir());\n\n\tconst skillCandidates = new Map<string, SkillCandidate[]>();\n\tconst realPathSet = new Set<string>();\n\tconst allDiagnostics: ResourceDiagnostic[] = [];\n\tconst collisionDiagnostics: ResourceDiagnostic[] = [];\n\tlet candidateOrder = 0;\n\n\tfunction addSkills(result: LoadSkillsResult) {\n\t\tallDiagnostics.push(...result.diagnostics);\n\t\tfor (const skill of result.skills) {\n\t\t\t// Resolve symlinks to detect duplicate files\n\t\t\tconst realPath = canonicalizePath(skill.filePath);\n\n\t\t\t// Skip silently if we've already loaded this exact file (via symlink)\n\t\t\tif (realPathSet.has(realPath)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\trealPathSet.add(realPath);\n\t\t\tconst candidates = skillCandidates.get(skill.name) ?? [];\n\t\t\tcandidates.push({ skill, realPath, order: candidateOrder });\n\t\t\tcandidateOrder += 1;\n\t\t\tskillCandidates.set(skill.name, candidates);\n\t\t}\n\t}\n\n\tif (includeDefaults) {\n\t\taddSkills(\n\t\t\tloadSkillsFromDirInternal(\n\t\t\t\tresolve(resolvedCwd, CONFIG_DIR_NAME, \"skills\"),\n\t\t\t\t\"project\",\n\t\t\t\ttrue,\n\t\t\t\tundefined,\n\t\t\t\tundefined,\n\t\t\t\tresolveSourceInfo,\n\t\t\t),\n\t\t);\n\t\taddSkills(\n\t\t\tloadSkillsFromDirInternal(\n\t\t\t\tjoin(resolvedAgentDir, \"skills\"),\n\t\t\t\t\"user\",\n\t\t\t\ttrue,\n\t\t\t\tundefined,\n\t\t\t\tundefined,\n\t\t\t\tresolveSourceInfo,\n\t\t\t),\n\t\t);\n\t}\n\n\tconst userSkillsDir = join(resolvedAgentDir, \"skills\");\n\tconst projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, \"skills\");\n\n\tconst isUnderPath = (target: string, root: string): boolean => {\n\t\tconst normalizedRoot = resolve(root);\n\t\tif (target === normalizedRoot) {\n\t\t\treturn true;\n\t\t}\n\t\tconst prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;\n\t\treturn target.startsWith(prefix);\n\t};\n\n\tconst getSource = (resolvedPath: string): \"user\" | \"project\" | \"path\" => {\n\t\tif (!includeDefaults) {\n\t\t\tif (isUnderPath(resolvedPath, userSkillsDir)) return \"user\";\n\t\t\tif (isUnderPath(resolvedPath, projectSkillsDir)) return \"project\";\n\t\t}\n\t\treturn \"path\";\n\t};\n\n\tfor (const rawPath of skillPaths) {\n\t\tconst resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true });\n\t\tif (!existsSync(resolvedPath)) {\n\t\t\tallDiagnostics.push({ type: \"warning\", message: \"skill path does not exist\", path: resolvedPath });\n\t\t\tcontinue;\n\t\t}\n\n\t\ttry {\n\t\t\tconst stats = statSync(resolvedPath);\n\t\t\tconst source = getSource(resolvedPath);\n\t\t\tif (stats.isDirectory()) {\n\t\t\t\taddSkills(loadSkillsFromDirInternal(resolvedPath, source, true, undefined, undefined, resolveSourceInfo));\n\t\t\t} else if (stats.isFile() && resolvedPath.endsWith(\".md\")) {\n\t\t\t\tconst result = loadSkillFromFile(resolvedPath, source, resolveSourceInfo?.(resolvedPath));\n\t\t\t\tif (result.skill) {\n\t\t\t\t\taddSkills({ skills: [result.skill], diagnostics: result.diagnostics });\n\t\t\t\t} else {\n\t\t\t\t\tallDiagnostics.push(...result.diagnostics);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tallDiagnostics.push({ type: \"warning\", message: \"skill path is not a markdown file\", path: resolvedPath });\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : \"failed to read skill path\";\n\t\t\tallDiagnostics.push({ type: \"warning\", message, path: resolvedPath });\n\t\t}\n\t}\n\n\tconst skills: Skill[] = [];\n\tfor (const candidates of skillCandidates.values()) {\n\t\tconst sortedCandidates = [...candidates].sort(compareSkillCandidates);\n\t\tconst winner = sortedCandidates[0];\n\t\tif (!winner) {\n\t\t\tcontinue;\n\t\t}\n\t\tskills.push(winner.skill);\n\t\tfor (const loser of sortedCandidates.slice(1)) {\n\t\t\tif (winner.skill.contentHash && winner.skill.contentHash === loser.skill.contentHash) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tcollisionDiagnostics.push(createSkillCollisionDiagnostic(winner, loser));\n\t\t}\n\t}\n\n\treturn {\n\t\tskills,\n\t\tdiagnostics: [...allDiagnostics, ...collisionDiagnostics],\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"skills.d.ts","sourceRoot":"","sources":["../../src/core/skills.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAE3D,OAAO,EAA6B,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAiE9E,MAAM,WAAW,gBAAgB;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,KAAK;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,UAAU,CAAC;IACvB,sBAAsB,EAAE,OAAO,CAAC;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAChC,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,WAAW,EAAE,kBAAkB,EAAE,CAAC;CAClC;AAqED,MAAM,WAAW,wBAAwB;IACxC,mCAAmC;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,yCAAyC;IACzC,MAAM,EAAE,MAAM,CAAC;IACf,iBAAiB,CAAC,EAAE,uBAAuB,CAAC;CAC5C;AAED,KAAK,uBAAuB,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,UAAU,GAAG,SAAS,CAAC;AA0B5E;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,gBAAgB,CAGrF;AAoKD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,CA0B7D;AAeD,MAAM,WAAW,iBAAiB;IACjC,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAC;IACZ,gDAAgD;IAChD,QAAQ,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,0CAA0C;IAC1C,eAAe,EAAE,OAAO,CAAC;IACzB,iBAAiB,CAAC,EAAE,uBAAuB,CAAC;IAC5C;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACvB;AAoGD;;;GAGG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,gBAAgB,CA4HvE","sourcesContent":["import { createHash } from \"node:crypto\";\nimport { existsSync, readdirSync, readFileSync, statSync } from \"fs\";\nimport ignore from \"ignore\";\nimport { basename, dirname, join, relative, resolve, sep } from \"path\";\nimport { CONFIG_DIR_NAME, getAgentDir } from \"../config.ts\";\nimport { parseFrontmatter } from \"../utils/frontmatter.ts\";\nimport { canonicalizePath, resolvePath } from \"../utils/paths.ts\";\nimport type { ResourceDiagnostic } from \"./diagnostics.ts\";\nimport { cachedSkillScan, type SkillCatalogCacheEntry, writeSkillCatalog } from \"./skills-catalog-cache.ts\";\nimport { createSyntheticSourceInfo, type SourceInfo } from \"./source-info.ts\";\n\n/** Max name length per spec */\nconst MAX_NAME_LENGTH = 64;\n\n/** Max description length per spec */\nconst MAX_DESCRIPTION_LENGTH = 1024;\n\nconst IGNORE_FILE_NAMES = [\".gitignore\", \".ignore\", \".fdignore\"];\n\ntype IgnoreMatcher = ReturnType<typeof ignore>;\n\nfunction compareDirentNames(a: { name: string }, b: { name: string }): number {\n\tif (a.name < b.name) return -1;\n\tif (a.name > b.name) return 1;\n\treturn 0;\n}\n\nfunction toPosixPath(p: string): string {\n\treturn p.split(sep).join(\"/\");\n}\n\nfunction prefixIgnorePattern(line: string, prefix: string): string | null {\n\tconst trimmed = line.trim();\n\tif (!trimmed) return null;\n\tif (trimmed.startsWith(\"#\") && !trimmed.startsWith(\"\\\\#\")) return null;\n\n\tlet pattern = line;\n\tlet negated = false;\n\n\tif (pattern.startsWith(\"!\")) {\n\t\tnegated = true;\n\t\tpattern = pattern.slice(1);\n\t} else if (pattern.startsWith(\"\\\\!\")) {\n\t\tpattern = pattern.slice(1);\n\t}\n\n\tif (pattern.startsWith(\"/\")) {\n\t\tpattern = pattern.slice(1);\n\t}\n\n\tconst prefixed = prefix ? `${prefix}${pattern}` : pattern;\n\treturn negated ? `!${prefixed}` : prefixed;\n}\n\nfunction addIgnoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void {\n\tconst relativeDir = relative(rootDir, dir);\n\tconst prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : \"\";\n\n\tfor (const filename of IGNORE_FILE_NAMES) {\n\t\tconst ignorePath = join(dir, filename);\n\t\tif (!existsSync(ignorePath)) continue;\n\t\ttry {\n\t\t\tconst content = readFileSync(ignorePath, \"utf-8\");\n\t\t\tconst patterns = content\n\t\t\t\t.split(/\\r?\\n/)\n\t\t\t\t.map((line) => prefixIgnorePattern(line, prefix))\n\t\t\t\t.filter((line): line is string => Boolean(line));\n\t\t\tif (patterns.length > 0) {\n\t\t\t\tig.add(patterns);\n\t\t\t}\n\t\t} catch {}\n\t}\n}\n\nexport interface SkillFrontmatter {\n\tname?: string;\n\tdescription?: string;\n\t\"disable-model-invocation\"?: boolean;\n\t[key: string]: unknown;\n}\n\nexport interface Skill {\n\tname: string;\n\tdescription: string;\n\tfilePath: string;\n\tbaseDir: string;\n\tsourceInfo: SourceInfo;\n\tdisableModelInvocation: boolean;\n\tcontentHash?: string;\n}\n\nexport interface LoadSkillsResult {\n\tskills: Skill[];\n\tdiagnostics: ResourceDiagnostic[];\n}\n\n/**\n * Validate skill name per Agent Skills spec.\n * Returns array of validation error messages (empty if valid).\n */\nfunction validateName(name: string): string[] {\n\tconst errors: string[] = [];\n\n\tif (name.length > MAX_NAME_LENGTH) {\n\t\terrors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`);\n\t}\n\n\tif (!/^[a-z0-9-]+$/.test(name)) {\n\t\terrors.push(`name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)`);\n\t}\n\n\tif (name.startsWith(\"-\") || name.endsWith(\"-\")) {\n\t\terrors.push(`name must not start or end with a hyphen`);\n\t}\n\n\tif (name.includes(\"--\")) {\n\t\terrors.push(`name must not contain consecutive hyphens`);\n\t}\n\n\treturn errors;\n}\n\n/**\n * Validate description per Agent Skills spec.\n */\nfunction validateDescription(description: string | undefined): string[] {\n\tconst errors: string[] = [];\n\n\tif (!description || description.trim() === \"\") {\n\t\terrors.push(\"description is required\");\n\t} else if (description.length > MAX_DESCRIPTION_LENGTH) {\n\t\terrors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`);\n\t}\n\n\treturn errors;\n}\n\nfunction sanitizeSkillLoadError(error: unknown): string {\n\tconst fallback = \"failed to parse skill file\";\n\tif (typeof error !== \"object\" || error === null || !(\"linePos\" in error)) {\n\t\treturn fallback;\n\t}\n\n\tconst linePos = error.linePos;\n\tif (!Array.isArray(linePos)) {\n\t\treturn fallback;\n\t}\n\n\tconst firstPosition = linePos[0];\n\tif (\n\t\ttypeof firstPosition !== \"object\" ||\n\t\tfirstPosition === null ||\n\t\t!(\"line\" in firstPosition) ||\n\t\t!(\"col\" in firstPosition) ||\n\t\ttypeof firstPosition.line !== \"number\" ||\n\t\ttypeof firstPosition.col !== \"number\"\n\t) {\n\t\treturn fallback;\n\t}\n\n\treturn `${fallback} at line ${firstPosition.line}, column ${firstPosition.col}`;\n}\n\nexport interface LoadSkillsFromDirOptions {\n\t/** Directory to scan for skills */\n\tdir: string;\n\t/** Source identifier for these skills */\n\tsource: string;\n\tresolveSourceInfo?: SkillSourceInfoResolver;\n}\n\ntype SkillSourceInfoResolver = (filePath: string) => SourceInfo | undefined;\n\nfunction createSkillSourceInfo(filePath: string, baseDir: string, source: string): SourceInfo {\n\tswitch (source) {\n\t\tcase \"user\":\n\t\t\treturn createSyntheticSourceInfo(filePath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tscope: \"user\",\n\t\t\t\tbaseDir,\n\t\t\t});\n\t\tcase \"project\":\n\t\t\treturn createSyntheticSourceInfo(filePath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tscope: \"project\",\n\t\t\t\tbaseDir,\n\t\t\t});\n\t\tcase \"path\":\n\t\t\treturn createSyntheticSourceInfo(filePath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tbaseDir,\n\t\t\t});\n\t\tdefault:\n\t\t\treturn createSyntheticSourceInfo(filePath, { source, baseDir });\n\t}\n}\n\n/**\n * Load skills from a directory.\n *\n * Discovery rules:\n * - if a directory contains SKILL.md, treat it as a skill root and do not recurse further\n * - otherwise, load direct .md children in the root\n * - recurse into subdirectories to find SKILL.md\n */\nexport function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {\n\tconst { dir, resolveSourceInfo, source } = options;\n\treturn loadSkillsFromDirInternal(dir, source, true, undefined, undefined, resolveSourceInfo);\n}\n\nfunction loadSkillsFromDirInternal(\n\tdir: string,\n\tsource: string,\n\tincludeRootFiles: boolean,\n\tignoreMatcher?: IgnoreMatcher,\n\trootDir?: string,\n\tresolveSourceInfo?: SkillSourceInfoResolver,\n): LoadSkillsResult {\n\tconst skills: Skill[] = [];\n\tconst diagnostics: ResourceDiagnostic[] = [];\n\n\tif (!existsSync(dir)) {\n\t\treturn { skills, diagnostics };\n\t}\n\n\tconst root = rootDir ?? dir;\n\tconst ig = ignoreMatcher ?? ignore();\n\taddIgnoreRules(ig, dir, root);\n\n\ttry {\n\t\tconst entries = readdirSync(dir, { withFileTypes: true }).sort(compareDirentNames);\n\n\t\tfor (const entry of entries) {\n\t\t\tif (entry.name !== \"SKILL.md\") {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst fullPath = join(dir, entry.name);\n\n\t\t\tlet isFile = entry.isFile();\n\t\t\tif (entry.isSymbolicLink()) {\n\t\t\t\ttry {\n\t\t\t\t\tisFile = statSync(fullPath).isFile();\n\t\t\t\t} catch {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst relPath = toPosixPath(relative(root, fullPath));\n\t\t\tif (!isFile || ig.ignores(relPath)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst result = loadSkillFromFile(fullPath, source, resolveSourceInfo?.(fullPath));\n\t\t\tif (result.skill) {\n\t\t\t\tskills.push(result.skill);\n\t\t\t}\n\t\t\tdiagnostics.push(...result.diagnostics);\n\t\t\treturn { skills, diagnostics };\n\t\t}\n\n\t\tfor (const entry of entries) {\n\t\t\tif (entry.name.startsWith(\".\")) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Skip node_modules to avoid scanning dependencies\n\t\t\tif (entry.name === \"node_modules\") {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst fullPath = join(dir, entry.name);\n\n\t\t\t// For symlinks, check if they point to a directory and follow them\n\t\t\tlet isDirectory = entry.isDirectory();\n\t\t\tlet isFile = entry.isFile();\n\t\t\tif (entry.isSymbolicLink()) {\n\t\t\t\ttry {\n\t\t\t\t\tconst stats = statSync(fullPath);\n\t\t\t\t\tisDirectory = stats.isDirectory();\n\t\t\t\t\tisFile = stats.isFile();\n\t\t\t\t} catch {\n\t\t\t\t\t// Broken symlink, skip it\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst relPath = toPosixPath(relative(root, fullPath));\n\t\t\tconst ignorePath = isDirectory ? `${relPath}/` : relPath;\n\t\t\tif (ig.ignores(ignorePath)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (isDirectory) {\n\t\t\t\tconst subResult = loadSkillsFromDirInternal(fullPath, source, false, ig, root, resolveSourceInfo);\n\t\t\t\tskills.push(...subResult.skills);\n\t\t\t\tdiagnostics.push(...subResult.diagnostics);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Root-level docs (README/CHANGELOG/LICENSE) are not skills.\n\t\t\tif (\n\t\t\t\t!isFile ||\n\t\t\t\t!includeRootFiles ||\n\t\t\t\t!entry.name.endsWith(\".md\") ||\n\t\t\t\t/^(readme|changelog|license|contributing|history)(\\..+)?$/i.test(entry.name)\n\t\t\t) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst result = loadSkillFromFile(fullPath, source, resolveSourceInfo?.(fullPath));\n\t\t\tif (result.skill) {\n\t\t\t\tskills.push(result.skill);\n\t\t\t}\n\t\t\tdiagnostics.push(...result.diagnostics);\n\t\t}\n\t} catch {}\n\n\treturn { skills, diagnostics };\n}\n\nfunction loadSkillFromFile(\n\tfilePath: string,\n\tsource: string,\n\tsourceInfoOverride?: SourceInfo,\n): { skill: Skill | null; diagnostics: ResourceDiagnostic[] } {\n\tconst diagnostics: ResourceDiagnostic[] = [];\n\n\ttry {\n\t\tconst rawContent = readFileSync(filePath, \"utf-8\");\n\t\tconst { frontmatter } = parseFrontmatter<SkillFrontmatter>(rawContent);\n\t\tconst skillDir = dirname(filePath);\n\t\tconst parentDirName = basename(skillDir);\n\n\t\t// Validate description\n\t\tconst descErrors = validateDescription(frontmatter.description);\n\t\tfor (const error of descErrors) {\n\t\t\tdiagnostics.push({ type: \"warning\", message: error, path: filePath });\n\t\t}\n\n\t\t// Use name from frontmatter, or fall back to parent directory name\n\t\tconst name = frontmatter.name || parentDirName;\n\n\t\t// Validate name\n\t\tconst nameErrors = validateName(name);\n\t\tfor (const error of nameErrors) {\n\t\t\tdiagnostics.push({ type: \"warning\", message: error, path: filePath });\n\t\t}\n\n\t\t// Still load the skill even with warnings (unless description is completely missing)\n\t\tif (!frontmatter.description || frontmatter.description.trim() === \"\") {\n\t\t\treturn { skill: null, diagnostics };\n\t\t}\n\n\t\treturn {\n\t\t\tskill: {\n\t\t\t\tname,\n\t\t\t\tdescription: frontmatter.description,\n\t\t\t\tfilePath,\n\t\t\t\tbaseDir: skillDir,\n\t\t\t\tsourceInfo: sourceInfoOverride ?? createSkillSourceInfo(filePath, skillDir, source),\n\t\t\t\tdisableModelInvocation: frontmatter[\"disable-model-invocation\"] === true,\n\t\t\t\tcontentHash: hashSkillContent(rawContent),\n\t\t\t},\n\t\t\tdiagnostics,\n\t\t};\n\t} catch (error) {\n\t\tdiagnostics.push({ type: \"warning\", message: sanitizeSkillLoadError(error), path: filePath });\n\t\treturn { skill: null, diagnostics };\n\t}\n}\n\n/**\n * Format skills for inclusion in a system prompt.\n * Uses XML format per Agent Skills standard.\n * See: https://agentskills.io/integrate-skills\n *\n * Skills with disableModelInvocation=true are excluded from the prompt\n * (they can only be invoked explicitly via /skill:name commands).\n */\nexport function formatSkillsForPrompt(skills: Skill[]): string {\n\tconst visibleSkills = skills.filter((s) => !s.disableModelInvocation);\n\n\tif (visibleSkills.length === 0) {\n\t\treturn \"\";\n\t}\n\n\tconst lines = [\n\t\t\"\\n\\nThe following skills provide specialized instructions for specific tasks.\",\n\t\t\"Use the read tool to load a skill's file when the task matches its description.\",\n\t\t\"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.\",\n\t\t\"\",\n\t\t\"<available_skills>\",\n\t];\n\n\tfor (const skill of visibleSkills) {\n\t\tlines.push(\" <skill>\");\n\t\tlines.push(` <name>${escapeXml(skill.name)}</name>`);\n\t\tlines.push(` <description>${escapeXml(skill.description)}</description>`);\n\t\tlines.push(` <location>${escapeXml(skill.filePath)}</location>`);\n\t\tlines.push(\" </skill>\");\n\t}\n\n\tlines.push(\"</available_skills>\");\n\n\treturn lines.join(\"\\n\");\n}\n\nfunction escapeXml(str: string): string {\n\treturn str\n\t\t.replace(/&/g, \"&\")\n\t\t.replace(/</g, \"<\")\n\t\t.replace(/>/g, \">\")\n\t\t.replace(/\"/g, \""\")\n\t\t.replace(/'/g, \"'\");\n}\n\nfunction hashSkillContent(content: string): string {\n\treturn createHash(\"sha256\").update(content).digest(\"hex\");\n}\n\nexport interface LoadSkillsOptions {\n\t/** Working directory for project-local skills. */\n\tcwd: string;\n\t/** Agent config directory for global skills. */\n\tagentDir: string;\n\t/** Explicit skill paths (files or directories) */\n\tskillPaths: string[];\n\t/** Include default skills directories. */\n\tincludeDefaults: boolean;\n\tresolveSourceInfo?: SkillSourceInfoResolver;\n\t/**\n\t * Persistent skill-catalog cache. Default: enabled, except under test\n\t * runners (VITEST) where writes are off unless explicitly opted in, so\n\t * fixture directories stay pristine.\n\t */\n\tcatalogCache?: boolean;\n}\n\ninterface SkillCandidate {\n\tskill: Skill;\n\trealPath: string;\n\torder: number;\n}\n\nfunction skillPrecedenceRank(skill: Skill): number {\n\tconst { origin, scope, source } = skill.sourceInfo;\n\tif (origin === \"package\") {\n\t\tif (scope === \"project\") {\n\t\t\treturn 4;\n\t\t}\n\t\tif (scope === \"user\") {\n\t\t\treturn 5;\n\t\t}\n\t\treturn 6;\n\t}\n\tif (scope === \"project\") {\n\t\treturn source === \"auto\" ? 1 : 0;\n\t}\n\tif (scope === \"user\") {\n\t\treturn source === \"auto\" ? 3 : 2;\n\t}\n\treturn 7;\n}\n\nfunction compareStrings(a: string, b: string): number {\n\tif (a < b) return -1;\n\tif (a > b) return 1;\n\treturn 0;\n}\n\nfunction compareSkillCandidates(a: SkillCandidate, b: SkillCandidate): number {\n\tconst rankDelta = skillPrecedenceRank(a.skill) - skillPrecedenceRank(b.skill);\n\tif (rankDelta !== 0) {\n\t\treturn rankDelta;\n\t}\n\n\tconst pathDelta = compareStrings(a.realPath, b.realPath);\n\tif (pathDelta !== 0) {\n\t\treturn pathDelta;\n\t}\n\n\treturn a.order - b.order;\n}\n\nfunction getSkillCollisionReason(\n\twinner: SkillCandidate,\n\tloser: SkillCandidate,\n): {\n\treason: string;\n\taction: string;\n} {\n\tif (skillPrecedenceRank(winner.skill) !== skillPrecedenceRank(loser.skill)) {\n\t\treturn {\n\t\t\treason: \"higher-precedence skill source\",\n\t\t\taction: \"Rename one skill or remove the lower-precedence duplicate.\",\n\t\t};\n\t}\n\n\tif (winner.realPath !== loser.realPath) {\n\t\treturn {\n\t\t\treason: \"same-precedence canonical path ordering\",\n\t\t\taction: \"Rename one skill or move one duplicate so the intended canonical path sorts first.\",\n\t\t};\n\t}\n\n\treturn {\n\t\treason: \"earlier configured skill path\",\n\t\taction: \"Rename one skill or reorder configured skill paths so the intended skill is loaded first.\",\n\t};\n}\n\nfunction createSkillCollisionDiagnostic(winner: SkillCandidate, loser: SkillCandidate): ResourceDiagnostic {\n\tconst { reason, action } = getSkillCollisionReason(winner, loser);\n\tconst winnerSkill = winner.skill;\n\tconst loserSkill = loser.skill;\n\treturn {\n\t\ttype: \"collision\",\n\t\tmessage: `name \"${loserSkill.name}\" collision: kept ${winnerSkill.filePath}; skipped ${loserSkill.filePath}; reason: ${reason}; action: ${action}`,\n\t\tpath: loserSkill.filePath,\n\t\tcollision: {\n\t\t\tresourceType: \"skill\",\n\t\t\tname: loserSkill.name,\n\t\t\twinnerPath: winnerSkill.filePath,\n\t\t\tloserPath: loserSkill.filePath,\n\t\t\twinnerSource: winnerSkill.sourceInfo.source,\n\t\t\tloserSource: loserSkill.sourceInfo.source,\n\t\t\twinnerScope: winnerSkill.sourceInfo.scope,\n\t\t\tloserScope: loserSkill.sourceInfo.scope,\n\t\t\twinnerOrigin: winnerSkill.sourceInfo.origin,\n\t\t\tloserOrigin: loserSkill.sourceInfo.origin,\n\t\t\tresolutionReason: reason,\n\t\t\tresolutionAction: action,\n\t\t},\n\t};\n}\n\n/**\n * Load skills from all configured locations.\n * Returns skills and any validation diagnostics.\n */\nexport function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {\n\tconst { agentDir, skillPaths, includeDefaults, resolveSourceInfo } = options;\n\n\t// Resolve agentDir - if not provided, use default from config\n\tconst resolvedCwd = resolvePath(options.cwd);\n\tconst resolvedAgentDir = resolvePath(agentDir ?? getAgentDir());\n\n\tconst skillCandidates = new Map<string, SkillCandidate[]>();\n\tconst realPathSet = new Set<string>();\n\tconst allDiagnostics: ResourceDiagnostic[] = [];\n\tconst collisionDiagnostics: ResourceDiagnostic[] = [];\n\tlet candidateOrder = 0;\n\n\t// Startup fast path: per-dir fingerprint cache, written once at the end.\n\tconst catalogStore: Record<string, SkillCatalogCacheEntry<LoadSkillsResult>> = {};\n\tconst catalogEnabled = options.catalogCache ?? process.env.VITEST !== \"true\";\n\tconst catalogAgentDir = resolvedAgentDir;\n\tfunction cachedScan(dir: string, source: string, resolver?: SkillSourceInfoResolver): LoadSkillsResult {\n\t\tif (!catalogEnabled) {\n\t\t\treturn loadSkillsFromDirInternal(dir, source, true, undefined, undefined, resolver);\n\t\t}\n\t\tconst { result, store } = cachedSkillScan<LoadSkillsResult>(catalogAgentDir, dir, () =>\n\t\t\tloadSkillsFromDirInternal(dir, source, true, undefined, undefined, resolver),\n\t\t);\n\t\tif (store) Object.assign(catalogStore, store);\n\t\treturn result;\n\t}\n\n\tfunction addSkills(result: LoadSkillsResult) {\n\t\tallDiagnostics.push(...result.diagnostics);\n\t\tfor (const skill of result.skills) {\n\t\t\t// Resolve symlinks to detect duplicate files\n\t\t\tconst realPath = canonicalizePath(skill.filePath);\n\n\t\t\t// Skip silently if we've already loaded this exact file (via symlink)\n\t\t\tif (realPathSet.has(realPath)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\trealPathSet.add(realPath);\n\t\t\tconst candidates = skillCandidates.get(skill.name) ?? [];\n\t\t\tcandidates.push({ skill, realPath, order: candidateOrder });\n\t\t\tcandidateOrder += 1;\n\t\t\tskillCandidates.set(skill.name, candidates);\n\t\t}\n\t}\n\n\tif (includeDefaults) {\n\t\taddSkills(cachedScan(resolve(resolvedCwd, CONFIG_DIR_NAME, \"skills\"), \"project\", resolveSourceInfo));\n\t\taddSkills(cachedScan(join(resolvedAgentDir, \"skills\"), \"user\", resolveSourceInfo));\n\t}\n\n\tconst userSkillsDir = join(resolvedAgentDir, \"skills\");\n\tconst projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, \"skills\");\n\n\tconst isUnderPath = (target: string, root: string): boolean => {\n\t\tconst normalizedRoot = resolve(root);\n\t\tif (target === normalizedRoot) {\n\t\t\treturn true;\n\t\t}\n\t\tconst prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;\n\t\treturn target.startsWith(prefix);\n\t};\n\n\tconst getSource = (resolvedPath: string): \"user\" | \"project\" | \"path\" => {\n\t\tif (!includeDefaults) {\n\t\t\tif (isUnderPath(resolvedPath, userSkillsDir)) return \"user\";\n\t\t\tif (isUnderPath(resolvedPath, projectSkillsDir)) return \"project\";\n\t\t}\n\t\treturn \"path\";\n\t};\n\n\tfor (const rawPath of skillPaths) {\n\t\tconst resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true });\n\t\tif (!existsSync(resolvedPath)) {\n\t\t\tallDiagnostics.push({ type: \"warning\", message: \"skill path does not exist\", path: resolvedPath });\n\t\t\tcontinue;\n\t\t}\n\n\t\ttry {\n\t\t\tconst stats = statSync(resolvedPath);\n\t\t\tconst source = getSource(resolvedPath);\n\t\t\tif (stats.isDirectory()) {\n\t\t\t\taddSkills(cachedScan(resolvedPath, source, resolveSourceInfo));\n\t\t\t} else if (stats.isFile() && resolvedPath.endsWith(\".md\")) {\n\t\t\t\tconst result = loadSkillFromFile(resolvedPath, source, resolveSourceInfo?.(resolvedPath));\n\t\t\t\tif (result.skill) {\n\t\t\t\t\taddSkills({ skills: [result.skill], diagnostics: result.diagnostics });\n\t\t\t\t} else {\n\t\t\t\t\tallDiagnostics.push(...result.diagnostics);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tallDiagnostics.push({ type: \"warning\", message: \"skill path is not a markdown file\", path: resolvedPath });\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : \"failed to read skill path\";\n\t\t\tallDiagnostics.push({ type: \"warning\", message, path: resolvedPath });\n\t\t}\n\t}\n\n\tconst skills: Skill[] = [];\n\tfor (const candidates of skillCandidates.values()) {\n\t\tconst sortedCandidates = [...candidates].sort(compareSkillCandidates);\n\t\tconst winner = sortedCandidates[0];\n\t\tif (!winner) {\n\t\t\tcontinue;\n\t\t}\n\t\tskills.push(winner.skill);\n\t\tfor (const loser of sortedCandidates.slice(1)) {\n\t\t\tif (winner.skill.contentHash && winner.skill.contentHash === loser.skill.contentHash) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tcollisionDiagnostics.push(createSkillCollisionDiagnostic(winner, loser));\n\t\t}\n\t}\n\n\tif (catalogEnabled) {\n\t\twriteSkillCatalog(catalogAgentDir, catalogStore);\n\t}\n\n\treturn {\n\t\tskills,\n\t\tdiagnostics: [...allDiagnostics, ...collisionDiagnostics],\n\t};\n}\n"]}
|