kc-beta 0.3.1 → 0.5.3
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/package.json +1 -1
- package/src/agent/confidence-scorer.js +8 -0
- package/src/agent/context.js +25 -0
- package/src/agent/corner-case-registry.js +5 -0
- package/src/agent/engine.js +514 -75
- package/src/agent/event-log.js +15 -2
- package/src/agent/history.js +91 -23
- package/src/agent/pipelines/initializer.js +3 -6
- package/src/agent/retry.js +9 -1
- package/src/agent/scheduler.js +276 -0
- package/src/agent/session-state.js +11 -2
- package/src/agent/task-manager.js +5 -0
- package/src/agent/tools/agent-tool.js +57 -14
- package/src/agent/tools/archive-file.js +94 -0
- package/src/agent/tools/copy-to-workspace.js +140 -0
- package/src/agent/tools/phase-advance.js +60 -0
- package/src/agent/tools/release.js +322 -0
- package/src/agent/tools/schedule-fetch.js +118 -0
- package/src/agent/tools/snapshot.js +101 -0
- package/src/agent/tools/workspace-file.js +10 -7
- package/src/agent/version-manager.js +29 -120
- package/src/agent/workspace.js +127 -4
- package/src/cli/components.js +4 -1
- package/src/cli/index.js +57 -4
- package/src/config.js +10 -1
- package/template/release-runtime/README.md.tmpl +84 -0
- package/template/release-runtime/kc_runtime/__init__.py +2 -0
- package/template/release-runtime/kc_runtime/confidence.py +93 -0
- package/template/release-runtime/kc_runtime/dashboard.py +208 -0
- package/template/release-runtime/render_dashboard.py +49 -0
- package/template/release-runtime/run.py +230 -0
- package/template/release-runtime/serve.sh +15 -0
- package/template/skills/en/meta/entity-extraction/SKILL.md +6 -0
- package/template/skills/en/meta-meta/bootstrap-workspace/SKILL.md +11 -0
- package/template/skills/en/meta-meta/quality-control/SKILL.md +13 -1
- package/template/skills/en/meta-meta/rule-extraction/SKILL.md +35 -0
- package/template/skills/en/meta-meta/rule-graph/SKILL.md +16 -0
- package/template/skills/en/meta-meta/skill-to-workflow/SKILL.md +8 -0
- package/template/skills/en/meta-meta/task-decomposition/SKILL.md +13 -0
- package/template/skills/en/meta-meta/version-control/SKILL.md +13 -0
- package/template/skills/zh/meta/entity-extraction/SKILL.md +6 -0
- package/template/skills/zh/meta-meta/bootstrap-workspace/SKILL.md +11 -0
- package/template/skills/zh/meta-meta/quality-control/SKILL.md +12 -0
- package/template/skills/zh/meta-meta/rule-extraction/SKILL.md +35 -0
- package/template/skills/zh/meta-meta/rule-graph/SKILL.md +16 -0
- package/template/skills/zh/meta-meta/skill-to-workflow/SKILL.md +8 -0
- package/template/skills/zh/meta-meta/task-decomposition/SKILL.md +16 -0
- package/template/skills/zh/meta-meta/version-control/SKILL.md +13 -0
- package/template/workspace.gitignore +22 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { BaseTool, ToolResult } from "./base.js";
|
|
5
|
+
import { SnapshotTool } from "./snapshot.js";
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const TEMPLATE_DIR = path.resolve(__dirname, "../../../template/release-runtime");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Bundle the current workspace into a portable, self-contained release for
|
|
12
|
+
* end users. The bundle has no kc-beta dependency — anyone with Python +
|
|
13
|
+
* a worker LLM API key can run `python run.py <doc>`.
|
|
14
|
+
*
|
|
15
|
+
* Sequence:
|
|
16
|
+
* 1. Snapshot the workspace (git tag + snapshots/<slug>/snapshot.json)
|
|
17
|
+
* 2. Read rules/catalog.json, filter by `include` if given
|
|
18
|
+
* 3. For each rule, find the latest workflow under workflows/<rule_id>/
|
|
19
|
+
* 4. Build output/releases/<slug>/ from template/release-runtime/ + workspace
|
|
20
|
+
* artifacts (workflows, glossary, catalog, corner_cases, calibration, models)
|
|
21
|
+
* 5. Write manifest.json + auto-generated README.md
|
|
22
|
+
* 6. Optionally include KC-selected fixtures from samples/
|
|
23
|
+
*/
|
|
24
|
+
export class ReleaseTool extends BaseTool {
|
|
25
|
+
/**
|
|
26
|
+
* @param {import('../workspace.js').Workspace} workspace
|
|
27
|
+
* @param {object} [opts]
|
|
28
|
+
* @param {string} [opts.kcVersion]
|
|
29
|
+
*/
|
|
30
|
+
constructor(workspace, { kcVersion = "0.5.1" } = {}) {
|
|
31
|
+
super();
|
|
32
|
+
this._workspace = workspace;
|
|
33
|
+
this._snapshot = new SnapshotTool(workspace);
|
|
34
|
+
this._kcVersion = kcVersion;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get name() { return "release"; }
|
|
38
|
+
|
|
39
|
+
get description() {
|
|
40
|
+
return (
|
|
41
|
+
"Bundle the current workspace into a portable release at " +
|
|
42
|
+
"output/releases/<slug>/. The bundle is self-contained — anyone with " +
|
|
43
|
+
"Python 3 and a worker LLM API key can run it via `python run.py <doc>`. " +
|
|
44
|
+
"Snapshots the workspace as a git tag (snap/release-<slug>) for reproducibility. " +
|
|
45
|
+
"Use when workflows have met accuracy thresholds and the system is ready " +
|
|
46
|
+
"to be handed off to end users or deployed for production runs."
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get inputSchema() {
|
|
51
|
+
return {
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: {
|
|
54
|
+
label: {
|
|
55
|
+
type: "string",
|
|
56
|
+
description: "Human-readable release name, e.g. 'v1' or 'q2-2026'. Slugified for the directory name.",
|
|
57
|
+
},
|
|
58
|
+
notes: {
|
|
59
|
+
type: "string",
|
|
60
|
+
description: "Optional release notes embedded in the manifest and README.",
|
|
61
|
+
},
|
|
62
|
+
include: {
|
|
63
|
+
type: "array",
|
|
64
|
+
items: { type: "string" },
|
|
65
|
+
description: "Optional rule-id allowlist. Default: all rules in catalog.json.",
|
|
66
|
+
},
|
|
67
|
+
fixtures: {
|
|
68
|
+
type: "array",
|
|
69
|
+
items: { type: "string" },
|
|
70
|
+
description: "Optional list of sample file paths (relative to samples/ or project) to bundle as fixtures/. KC should pick 1-3 representative samples.",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
required: ["label"],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async execute(input) {
|
|
78
|
+
const label = (input.label || "").trim();
|
|
79
|
+
if (!label) return new ToolResult("label required", true);
|
|
80
|
+
const slug = label.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
81
|
+
if (!slug) return new ToolResult("label produced empty slug", true);
|
|
82
|
+
|
|
83
|
+
if (!fs.existsSync(TEMPLATE_DIR)) {
|
|
84
|
+
return new ToolResult(`release template missing at ${TEMPLATE_DIR}`, true);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 1. Snapshot first — locks in commit + tag, regardless of whether bundle build succeeds
|
|
88
|
+
const snapResult = await this._snapshot.execute({
|
|
89
|
+
label: `release-${slug}`,
|
|
90
|
+
notes: `Release ${label} bundle source`,
|
|
91
|
+
});
|
|
92
|
+
if (snapResult.isError) return new ToolResult(`snapshot failed: ${snapResult.content}`, true);
|
|
93
|
+
const { tag: snapshotTag, commit: snapshotCommit } = this._readSnapshotMeta(`release-${slug}`);
|
|
94
|
+
|
|
95
|
+
// 2. Read catalog and filter
|
|
96
|
+
const catalogPath = path.join(this._workspace.cwd, "rules", "catalog.json");
|
|
97
|
+
if (!fs.existsSync(catalogPath)) {
|
|
98
|
+
return new ToolResult("rules/catalog.json not found — extract rules before releasing", true);
|
|
99
|
+
}
|
|
100
|
+
let catalog;
|
|
101
|
+
try { catalog = JSON.parse(fs.readFileSync(catalogPath, "utf-8")); }
|
|
102
|
+
catch (e) { return new ToolResult(`catalog.json invalid: ${e.message}`, true); }
|
|
103
|
+
if (!Array.isArray(catalog)) catalog = catalog.rules || [];
|
|
104
|
+
|
|
105
|
+
const includeSet = Array.isArray(input.include) && input.include.length > 0
|
|
106
|
+
? new Set(input.include) : null;
|
|
107
|
+
const selectedRules = catalog.filter((r) => !includeSet || includeSet.has(r.id));
|
|
108
|
+
if (selectedRules.length === 0) {
|
|
109
|
+
return new ToolResult("no rules selected for release (empty catalog or include filter matched nothing)", true);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 3. Build bundle dir
|
|
113
|
+
const bundleRel = path.join("output", "releases", slug);
|
|
114
|
+
const bundleAbs = this._workspace.resolvePath(bundleRel);
|
|
115
|
+
fs.mkdirSync(bundleAbs, { recursive: true });
|
|
116
|
+
|
|
117
|
+
// Copy Python runtime (run.py, render_dashboard.py, serve.sh, kc_runtime/)
|
|
118
|
+
this._copyDir(TEMPLATE_DIR, bundleAbs, { exclude: ["README.md.tmpl"] });
|
|
119
|
+
// Make .py executable, .sh executable
|
|
120
|
+
this._chmodPlusX(path.join(bundleAbs, "run.py"));
|
|
121
|
+
this._chmodPlusX(path.join(bundleAbs, "render_dashboard.py"));
|
|
122
|
+
this._chmodPlusX(path.join(bundleAbs, "serve.sh"));
|
|
123
|
+
|
|
124
|
+
// 4. Per-rule workflows
|
|
125
|
+
const ruleEntries = [];
|
|
126
|
+
const missingWorkflows = [];
|
|
127
|
+
for (const rule of selectedRules) {
|
|
128
|
+
const ruleId = rule.id;
|
|
129
|
+
const found = this._findLatestWorkflow(ruleId);
|
|
130
|
+
if (!found) {
|
|
131
|
+
missingWorkflows.push(ruleId);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const destRuleDir = path.join(bundleAbs, "workflows", ruleId);
|
|
135
|
+
fs.mkdirSync(destRuleDir, { recursive: true });
|
|
136
|
+
// Copy the workflow file
|
|
137
|
+
const wfFile = path.basename(found);
|
|
138
|
+
fs.copyFileSync(found, path.join(destRuleDir, wfFile));
|
|
139
|
+
this._chmodPlusX(path.join(destRuleDir, wfFile));
|
|
140
|
+
// Copy prompts/ if present
|
|
141
|
+
const promptsDir = path.join(this._workspace.cwd, "workflows", ruleId, "prompts");
|
|
142
|
+
if (fs.existsSync(promptsDir) && fs.statSync(promptsDir).isDirectory()) {
|
|
143
|
+
this._copyDir(promptsDir, path.join(destRuleDir, "prompts"));
|
|
144
|
+
}
|
|
145
|
+
ruleEntries.push({
|
|
146
|
+
id: ruleId,
|
|
147
|
+
title: rule.title || rule.description || "",
|
|
148
|
+
workflow: `workflows/${ruleId}/${wfFile}`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (ruleEntries.length === 0) {
|
|
153
|
+
return new ToolResult(
|
|
154
|
+
`no workflows found for any selected rule. Missing: ${missingWorkflows.join(", ")}`,
|
|
155
|
+
true,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 5. Frozen workspace artifacts
|
|
160
|
+
this._copyIfExists(catalogPath, path.join(bundleAbs, "catalog.json"));
|
|
161
|
+
this._copyIfExists(path.join(this._workspace.cwd, "rules", "glossary.json"),
|
|
162
|
+
path.join(bundleAbs, "glossary.json"), { fallback: '{"version":1,"entries":[]}\n' });
|
|
163
|
+
this._copyIfExists(path.join(this._workspace.cwd, "corner_cases.json"),
|
|
164
|
+
path.join(bundleAbs, "corner_cases.json"), { fallback: '[]\n' });
|
|
165
|
+
this._copyIfExists(path.join(this._workspace.cwd, "confidence_calibration.json"),
|
|
166
|
+
path.join(bundleAbs, "confidence_calibration.json"),
|
|
167
|
+
{ fallback: '{"historical_accuracy":{}}\n' });
|
|
168
|
+
|
|
169
|
+
// 6. models.json — pull tier mappings from workspace .env (worker tiers)
|
|
170
|
+
const models = this._readWorkerTiers();
|
|
171
|
+
fs.writeFileSync(path.join(bundleAbs, "models.json"),
|
|
172
|
+
JSON.stringify(models, null, 2) + "\n", "utf-8");
|
|
173
|
+
|
|
174
|
+
// 7. Optional fixtures
|
|
175
|
+
const fixtures = Array.isArray(input.fixtures) ? input.fixtures : [];
|
|
176
|
+
const bundledFixtures = [];
|
|
177
|
+
if (fixtures.length > 0) {
|
|
178
|
+
const fixDir = path.join(bundleAbs, "fixtures");
|
|
179
|
+
fs.mkdirSync(fixDir, { recursive: true });
|
|
180
|
+
for (const f of fixtures) {
|
|
181
|
+
const src = this._resolveFixture(f);
|
|
182
|
+
if (!src) continue;
|
|
183
|
+
const dst = path.join(fixDir, path.basename(src));
|
|
184
|
+
fs.copyFileSync(src, dst);
|
|
185
|
+
bundledFixtures.push(path.basename(src));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 8. Manifest
|
|
190
|
+
const manifest = {
|
|
191
|
+
label,
|
|
192
|
+
slug,
|
|
193
|
+
snapshot_tag: snapshotTag,
|
|
194
|
+
snapshot_commit: snapshotCommit,
|
|
195
|
+
created_at: new Date().toISOString(),
|
|
196
|
+
notes: input.notes || "",
|
|
197
|
+
rules: ruleEntries,
|
|
198
|
+
models,
|
|
199
|
+
fixtures: bundledFixtures,
|
|
200
|
+
kc_beta_version: this._kcVersion,
|
|
201
|
+
};
|
|
202
|
+
fs.writeFileSync(path.join(bundleAbs, "manifest.json"),
|
|
203
|
+
JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
204
|
+
|
|
205
|
+
// 9. README from template
|
|
206
|
+
const readmeTmpl = fs.readFileSync(path.join(TEMPLATE_DIR, "README.md.tmpl"), "utf-8");
|
|
207
|
+
const rulesList = ruleEntries.map((r) => `- \`${r.id}\` — ${r.title || "(no title)"}`).join("\n");
|
|
208
|
+
const notesBlock = input.notes ? `> ${input.notes}\n` : "";
|
|
209
|
+
const readme = readmeTmpl
|
|
210
|
+
.replace(/\{LABEL\}/g, label)
|
|
211
|
+
.replace(/\{SLUG\}/g, slug)
|
|
212
|
+
.replace(/\{CREATED_AT\}/g, manifest.created_at)
|
|
213
|
+
.replace(/\{SNAPSHOT_TAG\}/g, snapshotTag || "(no tag — git unavailable)")
|
|
214
|
+
.replace(/\{SNAPSHOT_COMMIT\}/g, snapshotCommit || "(unknown)")
|
|
215
|
+
.replace(/\{KC_VERSION\}/g, this._kcVersion)
|
|
216
|
+
.replace(/\{NOTES_BLOCK\}/g, notesBlock)
|
|
217
|
+
.replace(/\{RULES_LIST\}/g, rulesList);
|
|
218
|
+
fs.writeFileSync(path.join(bundleAbs, "README.md"), readme, "utf-8");
|
|
219
|
+
|
|
220
|
+
// Bundle dir is in output/ (gitignored). Snapshot manifest in snapshots/ IS tracked.
|
|
221
|
+
const lines = [
|
|
222
|
+
`Release '${label}' bundled at ${bundleRel}`,
|
|
223
|
+
` rules included: ${ruleEntries.length}` +
|
|
224
|
+
(missingWorkflows.length ? ` (skipped, no workflow: ${missingWorkflows.join(", ")})` : ""),
|
|
225
|
+
` snapshot tag: ${snapshotTag || "(none)"}`,
|
|
226
|
+
` fixtures: ${bundledFixtures.length > 0 ? bundledFixtures.join(", ") : "(none)"}`,
|
|
227
|
+
``,
|
|
228
|
+
`Run from anywhere:`,
|
|
229
|
+
` LLM_API_KEY=... TIER1=... python ${bundleRel}/run.py <doc>`,
|
|
230
|
+
`Open dashboards:`,
|
|
231
|
+
` ${bundleRel}/serve.sh && open http://localhost:8080/`,
|
|
232
|
+
``,
|
|
233
|
+
`Not in this release: HTTP serve framework, batch processor, sandboxing.`,
|
|
234
|
+
`Bundle is regenerable from the snapshot tag (output/releases/ is gitignored).`,
|
|
235
|
+
];
|
|
236
|
+
return new ToolResult(lines.join("\n"));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- helpers ---
|
|
240
|
+
|
|
241
|
+
_readSnapshotMeta(slug) {
|
|
242
|
+
const p = path.join(this._workspace.cwd, "snapshots", slug, "snapshot.json");
|
|
243
|
+
if (!fs.existsSync(p)) return { tag: null, commit: null };
|
|
244
|
+
try {
|
|
245
|
+
const d = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
246
|
+
return { tag: d.tag || null, commit: d.commit || null };
|
|
247
|
+
} catch {
|
|
248
|
+
return { tag: null, commit: null };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
_findLatestWorkflow(ruleId) {
|
|
253
|
+
const wfDir = path.join(this._workspace.cwd, "workflows", ruleId);
|
|
254
|
+
if (!fs.existsSync(wfDir) || !fs.statSync(wfDir).isDirectory()) return null;
|
|
255
|
+
const entries = fs.readdirSync(wfDir).sort();
|
|
256
|
+
const versioned = entries.filter((f) => /^workflow_v\d+\.py$/.test(f));
|
|
257
|
+
if (versioned.length > 0) return path.join(wfDir, versioned[versioned.length - 1]);
|
|
258
|
+
const any = entries.find((f) => f.endsWith(".py") && f.toLowerCase().includes("workflow"));
|
|
259
|
+
if (any) return path.join(wfDir, any);
|
|
260
|
+
const py = entries.find((f) => f.endsWith(".py"));
|
|
261
|
+
return py ? path.join(wfDir, py) : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
_resolveFixture(rel) {
|
|
265
|
+
// Try samples/ first (workspace, then project), then plain workspace path
|
|
266
|
+
const candidates = [];
|
|
267
|
+
candidates.push(path.join(this._workspace.cwd, "samples", rel));
|
|
268
|
+
if (this._workspace.projectDir) {
|
|
269
|
+
candidates.push(path.join(this._workspace.projectDir, "samples", rel));
|
|
270
|
+
candidates.push(path.join(this._workspace.projectDir, rel));
|
|
271
|
+
}
|
|
272
|
+
candidates.push(path.join(this._workspace.cwd, rel));
|
|
273
|
+
for (const c of candidates) {
|
|
274
|
+
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
_readWorkerTiers() {
|
|
280
|
+
const envPath = path.join(this._workspace.cwd, ".env");
|
|
281
|
+
const out = { tier1: "", tier2: "", tier3: "", tier4: "" };
|
|
282
|
+
if (!fs.existsSync(envPath)) return out;
|
|
283
|
+
const lines = fs.readFileSync(envPath, "utf-8").split("\n");
|
|
284
|
+
for (const line of lines) {
|
|
285
|
+
for (const t of ["TIER1", "TIER2", "TIER3", "TIER4"]) {
|
|
286
|
+
if (line.startsWith(`${t}=`)) {
|
|
287
|
+
out[t.toLowerCase()] = line.split("=").slice(1).join("=").trim();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
_copyDir(src, dst, { exclude = [] } = {}) {
|
|
295
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
296
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
297
|
+
if (exclude.includes(entry.name)) continue;
|
|
298
|
+
const srcPath = path.join(src, entry.name);
|
|
299
|
+
const dstPath = path.join(dst, entry.name);
|
|
300
|
+
if (entry.isDirectory()) {
|
|
301
|
+
this._copyDir(srcPath, dstPath, { exclude });
|
|
302
|
+
} else if (entry.isFile()) {
|
|
303
|
+
fs.copyFileSync(srcPath, dstPath);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
_copyIfExists(src, dst, { fallback = null } = {}) {
|
|
309
|
+
if (fs.existsSync(src) && fs.statSync(src).isFile()) {
|
|
310
|
+
fs.copyFileSync(src, dst);
|
|
311
|
+
} else if (fallback !== null) {
|
|
312
|
+
fs.writeFileSync(dst, fallback, "utf-8");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
_chmodPlusX(p) {
|
|
317
|
+
try {
|
|
318
|
+
const stat = fs.statSync(p);
|
|
319
|
+
fs.chmodSync(p, stat.mode | 0o111);
|
|
320
|
+
} catch { /* file may not exist; ignore */ }
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { BaseTool, ToolResult } from "./base.js";
|
|
2
|
+
import { Scheduler } from "../scheduler.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Manage scheduled document-ingestion jobs. Each job is a shell command that
|
|
6
|
+
* lands files in `input/`. KC writes a per-job wrapper script under
|
|
7
|
+
* `scripts/ingest/<id>.sh`; the user installs the script into their crontab
|
|
8
|
+
* (or other OS scheduler) themselves via `crontab -e`.
|
|
9
|
+
*
|
|
10
|
+
* KC does NOT run jobs itself — it generates the artifacts the OS scheduler
|
|
11
|
+
* needs. This means scheduled ingestion works while kc-beta is closed.
|
|
12
|
+
*/
|
|
13
|
+
export class ScheduleFetchTool extends BaseTool {
|
|
14
|
+
constructor(workspace) {
|
|
15
|
+
super();
|
|
16
|
+
this._workspace = workspace;
|
|
17
|
+
this._sched = new Scheduler(workspace);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get name() { return "schedule_fetch"; }
|
|
21
|
+
|
|
22
|
+
get description() {
|
|
23
|
+
return (
|
|
24
|
+
"Manage scheduled ingestion jobs that pull documents into input/. " +
|
|
25
|
+
"Each job is a shell command (e.g. rsync, curl, wget, custom script). " +
|
|
26
|
+
"KC writes a per-job wrapper script under scripts/ingest/<id>.sh; the user " +
|
|
27
|
+
"installs the script into their system cron (or launchd / Task Scheduler) " +
|
|
28
|
+
"via 'crontab -e'. Jobs run while kc-beta is closed. " +
|
|
29
|
+
"Files landing in input/ are auto-prefixed with <job-id>_<UTC-timestamp>_ " +
|
|
30
|
+
"so origin and ingest time are visible in the filename."
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get inputSchema() {
|
|
35
|
+
return {
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: {
|
|
38
|
+
operation: {
|
|
39
|
+
type: "string",
|
|
40
|
+
enum: ["add", "list", "remove", "enable", "disable", "print_crontab"],
|
|
41
|
+
description: "Operation: add (register), list, remove, enable, disable, print_crontab.",
|
|
42
|
+
},
|
|
43
|
+
id: {
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "Job identifier (alphanumeric + _-). Required for add/remove/enable/disable.",
|
|
46
|
+
},
|
|
47
|
+
command: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "Shell command for add. Available env vars: $WORKSPACE, $INPUT_DIR, $PROJECT_DIR. Example: 'rsync -av /shared/regs/ \"$INPUT_DIR\"/'.",
|
|
50
|
+
},
|
|
51
|
+
description: {
|
|
52
|
+
type: "string",
|
|
53
|
+
description: "Optional human-readable description of the job.",
|
|
54
|
+
},
|
|
55
|
+
cron_hint: {
|
|
56
|
+
type: "string",
|
|
57
|
+
description: "Optional cron expression (5 fields) to embed in the printed crontab line. KC does NOT evaluate it; the OS does. Example: '0 9 * * *' for daily at 09:00.",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
required: ["operation"],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async execute(input) {
|
|
65
|
+
const op = input.operation;
|
|
66
|
+
switch (op) {
|
|
67
|
+
case "add": {
|
|
68
|
+
const r = this._sched.add({
|
|
69
|
+
id: input.id,
|
|
70
|
+
command: input.command,
|
|
71
|
+
description: input.description,
|
|
72
|
+
cron_hint: input.cron_hint,
|
|
73
|
+
});
|
|
74
|
+
if (!r.ok) return new ToolResult(`add failed: ${r.reason}`, true);
|
|
75
|
+
const scriptRel = `scripts/ingest/${r.job.id}.sh`;
|
|
76
|
+
return new ToolResult(
|
|
77
|
+
`Job '${r.job.id}' registered. Wrapper script: ${scriptRel}\n` +
|
|
78
|
+
`Install via 'crontab -e' — use 'schedule_fetch print_crontab' to get the line.`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
case "list": {
|
|
82
|
+
const jobs = this._sched.list();
|
|
83
|
+
if (jobs.length === 0) return new ToolResult("(no jobs registered)");
|
|
84
|
+
const lines = jobs.map((j) => {
|
|
85
|
+
const status = j.enabled ? "enabled" : "disabled";
|
|
86
|
+
const hint = j.cron_hint ? ` cron_hint='${j.cron_hint}'` : "";
|
|
87
|
+
return ` ${j.id} [${status}]${hint}\n cmd: ${j.command}\n ${j.description ? `desc: ${j.description}` : ""}`;
|
|
88
|
+
});
|
|
89
|
+
const tail = this._sched.tailLog(5);
|
|
90
|
+
return new ToolResult(
|
|
91
|
+
lines.join("\n") +
|
|
92
|
+
(tail ? `\n\nlogs/ingest.log (last 5 lines):\n${tail}` : "")
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
case "remove": {
|
|
96
|
+
if (!input.id) return new ToolResult("id required for remove", true);
|
|
97
|
+
const r = this._sched.remove(input.id);
|
|
98
|
+
return new ToolResult(r.ok ? `Removed '${input.id}'.` : `remove failed: ${r.reason}`, !r.ok);
|
|
99
|
+
}
|
|
100
|
+
case "enable":
|
|
101
|
+
case "disable": {
|
|
102
|
+
if (!input.id) return new ToolResult(`id required for ${op}`, true);
|
|
103
|
+
const r = this._sched.setEnabled(input.id, op === "enable");
|
|
104
|
+
return new ToolResult(
|
|
105
|
+
r.ok
|
|
106
|
+
? `Job '${input.id}' ${op}d.${op === "disable" ? " Wrapper script removed; remember to remove the line from your crontab too." : ""}`
|
|
107
|
+
: `${op} failed: ${r.reason}`,
|
|
108
|
+
!r.ok,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
case "print_crontab": {
|
|
112
|
+
return new ToolResult(this._sched.formatCrontab());
|
|
113
|
+
}
|
|
114
|
+
default:
|
|
115
|
+
return new ToolResult(`Unknown operation: ${op}`, true);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { BaseTool, ToolResult } from "./base.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a named snapshot of the current workspace state.
|
|
8
|
+
* A snapshot is a git tag (`snap/<slug>`) plus a manifest at
|
|
9
|
+
* snapshots/<slug>/snapshot.json. Used for release bundles (Block 8) or
|
|
10
|
+
* before risky operations.
|
|
11
|
+
*
|
|
12
|
+
* Auto-commits any pending changes before tagging so the snapshot is always
|
|
13
|
+
* a valid commit. If git isn't available, the manifest is still written but
|
|
14
|
+
* no tag is created.
|
|
15
|
+
*/
|
|
16
|
+
export class SnapshotTool extends BaseTool {
|
|
17
|
+
constructor(workspace) {
|
|
18
|
+
super();
|
|
19
|
+
this._workspace = workspace;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get name() { return "snapshot"; }
|
|
23
|
+
|
|
24
|
+
get description() {
|
|
25
|
+
return (
|
|
26
|
+
"Create a named snapshot of the current workspace state (git tag + manifest). " +
|
|
27
|
+
"Use to freeze a moment for a release bundle or before a risky operation. " +
|
|
28
|
+
"Auto-commits any pending changes before tagging."
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get inputSchema() {
|
|
33
|
+
return {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
label: {
|
|
37
|
+
type: "string",
|
|
38
|
+
description: "Human-readable label, e.g. 'release-v1' or 'before-skill-rewrite'.",
|
|
39
|
+
},
|
|
40
|
+
notes: {
|
|
41
|
+
type: "string",
|
|
42
|
+
description: "Optional description recorded in the snapshot manifest.",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
required: ["label"],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async execute(input) {
|
|
50
|
+
const label = (input.label || "").trim();
|
|
51
|
+
if (!label) return new ToolResult("label required", true);
|
|
52
|
+
const notes = input.notes || "";
|
|
53
|
+
|
|
54
|
+
const slug = label.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "");
|
|
55
|
+
if (!slug) return new ToolResult("label produced empty slug", true);
|
|
56
|
+
|
|
57
|
+
let commitSha = null;
|
|
58
|
+
let tagName = null;
|
|
59
|
+
|
|
60
|
+
if (this._workspace.gitAvailable) {
|
|
61
|
+
// Auto-commit any pending changes (so the snapshot is reproducible)
|
|
62
|
+
spawnSync("git", ["add", "-A"], { cwd: this._workspace.cwd, stdio: "ignore" });
|
|
63
|
+
spawnSync("git", ["commit", "-m", `snapshot: ${label}`, "--allow-empty"], {
|
|
64
|
+
cwd: this._workspace.cwd, stdio: "ignore",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
tagName = `snap/${slug}`;
|
|
68
|
+
// Force the tag in case the same label is re-used
|
|
69
|
+
spawnSync("git", ["tag", "-f", tagName], { cwd: this._workspace.cwd, stdio: "ignore" });
|
|
70
|
+
|
|
71
|
+
const r = spawnSync("git", ["rev-parse", "HEAD"], {
|
|
72
|
+
cwd: this._workspace.cwd, encoding: "utf-8",
|
|
73
|
+
});
|
|
74
|
+
if (r.status === 0) commitSha = r.stdout.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const snapDirRel = path.join("snapshots", slug);
|
|
78
|
+
const snapDirAbs = this._workspace.resolvePath(snapDirRel);
|
|
79
|
+
fs.mkdirSync(snapDirAbs, { recursive: true });
|
|
80
|
+
const manifestRel = path.join(snapDirRel, "snapshot.json");
|
|
81
|
+
const manifestAbs = this._workspace.resolvePath(manifestRel);
|
|
82
|
+
const manifest = {
|
|
83
|
+
label,
|
|
84
|
+
slug,
|
|
85
|
+
tag: tagName,
|
|
86
|
+
commit: commitSha,
|
|
87
|
+
created_at: new Date().toISOString(),
|
|
88
|
+
notes: notes || null,
|
|
89
|
+
};
|
|
90
|
+
fs.writeFileSync(manifestAbs, JSON.stringify(manifest, null, 2), "utf-8");
|
|
91
|
+
this._workspace.autoCommit(manifestRel, "snapshot");
|
|
92
|
+
|
|
93
|
+
const lines = [
|
|
94
|
+
`Snapshot '${label}' created.`,
|
|
95
|
+
tagName ? ` tag: ${tagName}` : " tag: (skipped — git unavailable)",
|
|
96
|
+
commitSha ? ` commit: ${commitSha}` : "",
|
|
97
|
+
` manifest: ${manifestRel}`,
|
|
98
|
+
].filter(Boolean);
|
|
99
|
+
return new ToolResult(lines.join("\n"));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -7,17 +7,20 @@ const MAX_READ = 50_000;
|
|
|
7
7
|
/**
|
|
8
8
|
* Read, write, or list files in the workspace or project directory.
|
|
9
9
|
* All paths are resolved relative to the chosen scope with
|
|
10
|
-
* traversal protection.
|
|
10
|
+
* traversal protection. Workspace writes are auto-committed by Workspace.autoCommit
|
|
11
|
+
* (skips gitignored paths and silently no-ops if git is unavailable).
|
|
12
|
+
*
|
|
13
|
+
* The second `versionManager` arg is retained for back-compat with the engine
|
|
14
|
+
* constructor but is no longer required for any logic.
|
|
11
15
|
*/
|
|
12
16
|
export class WorkspaceFileTool extends BaseTool {
|
|
13
17
|
/**
|
|
14
18
|
* @param {import('../workspace.js').Workspace} workspace
|
|
15
|
-
* @param {import('../version-manager.js').VersionManager} [
|
|
19
|
+
* @param {import('../version-manager.js').VersionManager} [_versionManager] - unused, kept for back-compat
|
|
16
20
|
*/
|
|
17
|
-
constructor(workspace,
|
|
21
|
+
constructor(workspace, _versionManager) {
|
|
18
22
|
super();
|
|
19
23
|
this._workspace = workspace;
|
|
20
|
-
this._versionManager = versionManager || null;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
get name() { return "workspace_file"; }
|
|
@@ -112,10 +115,10 @@ export class WorkspaceFileTool extends BaseTool {
|
|
|
112
115
|
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
113
116
|
fs.writeFileSync(resolved, content, "utf-8");
|
|
114
117
|
|
|
115
|
-
//
|
|
118
|
+
// Auto-commit to git for workspace writes (silently no-ops if gitignored or git unavailable)
|
|
116
119
|
let traceId = null;
|
|
117
|
-
if (scope === "workspace"
|
|
118
|
-
traceId = this.
|
|
120
|
+
if (scope === "workspace") {
|
|
121
|
+
traceId = this._workspace.autoCommit(filePath, "update");
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
const label = scope === "project" ? `[project] ${filePath}` : filePath;
|