kc-beta 0.3.2 → 0.5.4
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-window.js +7 -2
- package/src/agent/context.js +25 -0
- package/src/agent/corner-case-registry.js +5 -0
- package/src/agent/engine.js +564 -76
- 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/rule-catalog-normalize.js +37 -0
- 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 +323 -0
- package/src/agent/tools/rule-catalog.js +56 -4
- 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 +68 -12
- package/src/cli/index.js +147 -15
- package/src/config.js +10 -1
- package/src/model-tiers.json +5 -5
- 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-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/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-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/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
package/src/agent/event-log.js
CHANGED
|
@@ -12,15 +12,28 @@ import { estimateTokens } from "./token-counter.js";
|
|
|
12
12
|
export class EventLog {
|
|
13
13
|
/**
|
|
14
14
|
* @param {string} workspacePath - Session workspace directory
|
|
15
|
+
* @param {object} [opts]
|
|
16
|
+
* @param {string} [opts.logDir] - Override absolute path for the events directory
|
|
17
|
+
* (used for sub-agent isolation, Bug 2)
|
|
15
18
|
*/
|
|
16
|
-
constructor(workspacePath) {
|
|
17
|
-
this._dir = path.join(workspacePath, "logs");
|
|
19
|
+
constructor(workspacePath, opts = {}) {
|
|
20
|
+
this._dir = opts.logDir || path.join(workspacePath, "logs");
|
|
18
21
|
this._logPath = path.join(this._dir, "events.jsonl");
|
|
19
22
|
this._seq = 0;
|
|
20
23
|
this._estimatedTokens = 0;
|
|
21
24
|
this._initFromExisting();
|
|
22
25
|
}
|
|
23
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Re-point this event log at a new directory. Used by
|
|
29
|
+
* `engine.renameSession()` (Bug 3).
|
|
30
|
+
*/
|
|
31
|
+
_setWorkspacePath(newWorkspacePath, opts = {}) {
|
|
32
|
+
this._dir = opts.logDir || path.join(newWorkspacePath, "logs");
|
|
33
|
+
this._logPath = path.join(this._dir, "events.jsonl");
|
|
34
|
+
// Sequence counter stays — we keep counting from where we left off.
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
/** Current sequence number */
|
|
25
38
|
get currentSeq() { return this._seq; }
|
|
26
39
|
|
package/src/agent/history.js
CHANGED
|
@@ -1,31 +1,42 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { estimateTokens } from "./token-counter.js";
|
|
4
|
+
|
|
5
|
+
// Belt-and-suspenders cap on any single message's content. Block 11 tool-call
|
|
6
|
+
// offloading already prevents tool outputs from getting this big in normal use,
|
|
7
|
+
// but old/migrated workspaces and edge cases benefit from a hard ceiling that
|
|
8
|
+
// keeps a single bloated message from blowing the model's context budget alone.
|
|
9
|
+
const DEFAULT_MAX_MESSAGE_TOKENS = 30000;
|
|
3
10
|
|
|
4
11
|
/**
|
|
5
12
|
* Manages the message list for the OpenAI-compatible API.
|
|
6
|
-
* Persists to
|
|
7
|
-
*
|
|
13
|
+
* Persists to <conversationDir>/ on every write.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} [workspacePath] - Workspace directory (default conversation dir is workspacePath/logs/conversation/)
|
|
16
|
+
* @param {object} [opts]
|
|
17
|
+
* @param {string} [opts.conversationDir] - Override absolute path (used for sub-agent isolation, Bug 2)
|
|
18
|
+
* @param {number} [opts.maxMessageTokens] - Per-message content cap (default 30000)
|
|
8
19
|
*/
|
|
9
20
|
export class ConversationHistory {
|
|
10
|
-
|
|
11
|
-
* @param {string} [workspacePath] - Workspace directory for persistence
|
|
12
|
-
*/
|
|
13
|
-
constructor(workspacePath) {
|
|
21
|
+
constructor(workspacePath, opts = {}) {
|
|
14
22
|
/** @type {Array<object>} API messages */
|
|
15
23
|
this._messages = [];
|
|
16
24
|
/** @type {Array<object>} Flat display log for replay */
|
|
17
25
|
this._displayLog = [];
|
|
18
26
|
this._workspacePath = workspacePath || null;
|
|
27
|
+
this._conversationDir = opts.conversationDir || (workspacePath ? path.join(workspacePath, "logs", "conversation") : null);
|
|
28
|
+
this._maxMessageTokens = opts.maxMessageTokens ?? DEFAULT_MAX_MESSAGE_TOKENS;
|
|
19
29
|
|
|
20
|
-
if (this.
|
|
30
|
+
if (this._conversationDir) this._load();
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
get messages() { return this._messages; }
|
|
24
34
|
get displayLog() { return this._displayLog; }
|
|
25
35
|
|
|
26
36
|
addUser(text) {
|
|
27
|
-
this.
|
|
28
|
-
this.
|
|
37
|
+
const capped = this._capContent(text);
|
|
38
|
+
this._messages.push({ role: "user", content: capped });
|
|
39
|
+
this._displayLog.push({ role: "user", content: capped });
|
|
29
40
|
this._save();
|
|
30
41
|
}
|
|
31
42
|
|
|
@@ -34,15 +45,19 @@ export class ConversationHistory {
|
|
|
34
45
|
* @param {object} message
|
|
35
46
|
*/
|
|
36
47
|
addRaw(message) {
|
|
37
|
-
|
|
48
|
+
const msg = { ...message };
|
|
49
|
+
if (typeof msg.content === "string") {
|
|
50
|
+
msg.content = this._capContent(msg.content);
|
|
51
|
+
}
|
|
52
|
+
this._messages.push(msg);
|
|
38
53
|
|
|
39
|
-
const role =
|
|
54
|
+
const role = msg.role || "";
|
|
40
55
|
if (role === "assistant") {
|
|
41
|
-
const content =
|
|
56
|
+
const content = msg.content || "";
|
|
42
57
|
if (content) {
|
|
43
58
|
this._displayLog.push({ role: "agent", content });
|
|
44
59
|
}
|
|
45
|
-
for (const tc of
|
|
60
|
+
for (const tc of msg.tool_calls || []) {
|
|
46
61
|
const fn = tc.function || {};
|
|
47
62
|
let toolInput = {};
|
|
48
63
|
try { toolInput = JSON.parse(fn.arguments || "{}"); } catch { /* ignore */ }
|
|
@@ -53,7 +68,7 @@ export class ConversationHistory {
|
|
|
53
68
|
});
|
|
54
69
|
}
|
|
55
70
|
} else if (role === "tool") {
|
|
56
|
-
const content =
|
|
71
|
+
const content = msg.content || "";
|
|
57
72
|
// Update the last tool entry with output
|
|
58
73
|
for (let i = this._displayLog.length - 1; i >= 0; i--) {
|
|
59
74
|
if (this._displayLog[i].role === "tool" && !("toolOutput" in this._displayLog[i])) {
|
|
@@ -66,33 +81,86 @@ export class ConversationHistory {
|
|
|
66
81
|
this._save();
|
|
67
82
|
}
|
|
68
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Cap a single message's content if it exceeds maxMessageTokens. Cuts the
|
|
86
|
+
* middle, keeps head + tail, leaves a marker pointing at logs/events.jsonl
|
|
87
|
+
* for the full content (event log keeps everything via appendFileSync).
|
|
88
|
+
*
|
|
89
|
+
* CJK-aware: derives the character budget from a sample-based chars/token
|
|
90
|
+
* ratio. Latin sample ≈ 4 chars/token, CJK ≈ 0.67 chars/token. Iterative
|
|
91
|
+
* tightening converges on the cap; clamping prevents accidental doubling
|
|
92
|
+
* if the heuristic is ever wrong.
|
|
93
|
+
*/
|
|
94
|
+
_capContent(content) {
|
|
95
|
+
if (typeof content !== "string" || !content) return content;
|
|
96
|
+
const tokens = estimateTokens(content);
|
|
97
|
+
if (tokens <= this._maxMessageTokens) return content;
|
|
98
|
+
|
|
99
|
+
const target = Math.max(100, this._maxMessageTokens - 50); // reserve for marker
|
|
100
|
+
const ratio = this._charsPerToken(content);
|
|
101
|
+
let charBudget = Math.max(100, Math.floor(target * ratio));
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < 5; i++) {
|
|
104
|
+
const head = Math.min(Math.floor(charBudget * 0.6), content.length);
|
|
105
|
+
const tail = Math.min(Math.floor(charBudget * 0.3), Math.max(0, content.length - head));
|
|
106
|
+
// If proposed slices would cover the whole input, truncating creates
|
|
107
|
+
// no savings — return original (the safety-net cap rather doubles than truncates).
|
|
108
|
+
if (head + tail >= content.length) return content;
|
|
109
|
+
const result = content.slice(0, head) + this._truncMarker(tokens) + content.slice(-tail);
|
|
110
|
+
if (estimateTokens(result) <= this._maxMessageTokens) return result;
|
|
111
|
+
charBudget = Math.floor(charBudget * 0.7);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Last resort after 5 iterations: head only, no tail.
|
|
115
|
+
const headOnly = Math.min(Math.max(charBudget, 100), content.length);
|
|
116
|
+
return content.slice(0, headOnly) + this._truncMarker(tokens);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Sample-based chars-per-token ratio. Inverse of estimateTokens rate. */
|
|
120
|
+
_charsPerToken(content) {
|
|
121
|
+
const sample = content.slice(0, 2000);
|
|
122
|
+
const t = estimateTokens(sample) || 1;
|
|
123
|
+
return Math.max(0.5, sample.length / t);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_truncMarker(originalTokens) {
|
|
127
|
+
return `\n\n[…truncated, ${originalTokens} tokens; full content in logs/events.jsonl…]\n\n`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Re-point this history at a new conversation directory. Used by
|
|
132
|
+
* `engine.renameSession()` (Bug 3) when the workspace is renamed.
|
|
133
|
+
*/
|
|
134
|
+
_setWorkspacePath(newWorkspacePath, opts = {}) {
|
|
135
|
+
this._workspacePath = newWorkspacePath;
|
|
136
|
+
this._conversationDir = opts.conversationDir || path.join(newWorkspacePath, "logs", "conversation");
|
|
137
|
+
}
|
|
138
|
+
|
|
69
139
|
_save() {
|
|
70
|
-
if (!this.
|
|
71
|
-
|
|
72
|
-
fs.mkdirSync(convDir, { recursive: true });
|
|
140
|
+
if (!this._conversationDir) return;
|
|
141
|
+
fs.mkdirSync(this._conversationDir, { recursive: true });
|
|
73
142
|
fs.writeFileSync(
|
|
74
|
-
path.join(
|
|
143
|
+
path.join(this._conversationDir, "messages.json"),
|
|
75
144
|
JSON.stringify(this._messages, null, 2),
|
|
76
145
|
"utf-8",
|
|
77
146
|
);
|
|
78
147
|
fs.writeFileSync(
|
|
79
|
-
path.join(
|
|
148
|
+
path.join(this._conversationDir, "display.json"),
|
|
80
149
|
JSON.stringify(this._displayLog, null, 2),
|
|
81
150
|
"utf-8",
|
|
82
151
|
);
|
|
83
152
|
}
|
|
84
153
|
|
|
85
154
|
_load() {
|
|
86
|
-
if (!this.
|
|
87
|
-
const convDir = path.join(this._workspacePath, "logs", "conversation");
|
|
155
|
+
if (!this._conversationDir) return;
|
|
88
156
|
|
|
89
|
-
const msgPath = path.join(
|
|
157
|
+
const msgPath = path.join(this._conversationDir, "messages.json");
|
|
90
158
|
if (fs.existsSync(msgPath)) {
|
|
91
159
|
try { this._messages = JSON.parse(fs.readFileSync(msgPath, "utf-8")); }
|
|
92
160
|
catch { this._messages = []; }
|
|
93
161
|
}
|
|
94
162
|
|
|
95
|
-
const displayPath = path.join(
|
|
163
|
+
const displayPath = path.join(this._conversationDir, "display.json");
|
|
96
164
|
if (fs.existsSync(displayPath)) {
|
|
97
165
|
try { this._displayLog = JSON.parse(fs.readFileSync(displayPath, "utf-8")); }
|
|
98
166
|
catch { this._displayLog = []; }
|
|
@@ -73,15 +73,12 @@ export class ProjectInitializer extends Pipeline {
|
|
|
73
73
|
fs.writeFileSync(envPath, envContent, "utf-8");
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
fs.writeFileSync(manifestPath, JSON.stringify({ version: "0.1.0", entries: [] }, null, 2), "utf-8");
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// AGENT.md — per-project context (agent can modify)
|
|
76
|
+
// AGENT.md — per-project context (agent can modify). Auto-commit so
|
|
77
|
+
// git captures the seed.
|
|
82
78
|
const agentMdPath = path.join(this._workspace.cwd, "AGENT.md");
|
|
83
79
|
if (!fs.existsSync(agentMdPath) && fs.existsSync(AGENT_MD_TEMPLATE)) {
|
|
84
80
|
fs.copyFileSync(AGENT_MD_TEMPLATE, agentMdPath);
|
|
81
|
+
this._workspace.autoCommit?.("AGENT.md", "seed");
|
|
85
82
|
}
|
|
86
83
|
|
|
87
84
|
this.workspaceCreated = true;
|
package/src/agent/retry.js
CHANGED
|
@@ -12,18 +12,26 @@ const JITTER_FRACTION = 0.2;
|
|
|
12
12
|
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504, 520, 522, 524]);
|
|
13
13
|
const NON_RETRYABLE_STATUS = new Set([400, 401, 403, 404, 422]);
|
|
14
14
|
|
|
15
|
+
// Phrases that indicate the request itself is too large for the model.
|
|
16
|
+
// Some providers return these as 400 (correct), others as 500/503 (incorrect),
|
|
17
|
+
// but in either case retrying just delays the inevitable failure.
|
|
18
|
+
const CONTEXT_LENGTH_PATTERNS = /context_length|context length|maximum context|too many tokens|prompt is too long|input length|input is too long|exceeds.{0,20}context|exceeded the max|reduce the length/i;
|
|
19
|
+
|
|
15
20
|
/**
|
|
16
21
|
* Determine if an error is retryable.
|
|
17
22
|
* @param {Error} err
|
|
18
23
|
* @returns {boolean}
|
|
19
24
|
*/
|
|
20
25
|
function isRetryable(err) {
|
|
26
|
+
const msg = err.message || "";
|
|
27
|
+
// Context-length errors are non-retryable regardless of status code
|
|
28
|
+
if (CONTEXT_LENGTH_PATTERNS.test(msg)) return false;
|
|
29
|
+
|
|
21
30
|
if (err.status) {
|
|
22
31
|
if (NON_RETRYABLE_STATUS.has(err.status)) return false;
|
|
23
32
|
if (RETRYABLE_STATUS.has(err.status)) return true;
|
|
24
33
|
}
|
|
25
34
|
// Network errors (ECONNRESET, ETIMEDOUT, fetch TypeError, AbortError)
|
|
26
|
-
const msg = err.message || "";
|
|
27
35
|
if (/ECONNRESET|ETIMEDOUT|ENOTFOUND|ECONNREFUSED|UND_ERR|fetch failed|network|socket hang up/i.test(msg)) {
|
|
28
36
|
return true;
|
|
29
37
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize a catalog.json payload into a flat array of rule records.
|
|
3
|
+
*
|
|
4
|
+
* KC the agent has historically produced catalog.json in at least four shapes,
|
|
5
|
+
* and earlier code paths assumed a flat array — silently dropping everything
|
|
6
|
+
* when the catalog was object-shaped. This helper unifies the handling so
|
|
7
|
+
* the engine, the rule_catalog tool, and the release tool all see the same
|
|
8
|
+
* list of rules regardless of how the file was written.
|
|
9
|
+
*
|
|
10
|
+
* Accepted shapes:
|
|
11
|
+
* 1. [rule, rule, ...] flat array (original)
|
|
12
|
+
* 2. { rules: [...] } wrapper object
|
|
13
|
+
* 3. { categories: { A: [...], B: [...] }, ... } grouped by category
|
|
14
|
+
* 4. { categories: { A: { rules: [...] }, ... } } nested category objects
|
|
15
|
+
*
|
|
16
|
+
* Anything else (null, wrong shape, throws) returns [].
|
|
17
|
+
*/
|
|
18
|
+
export function normalizeRuleCatalog(catalog) {
|
|
19
|
+
if (Array.isArray(catalog)) return catalog;
|
|
20
|
+
if (!catalog || typeof catalog !== "object") return [];
|
|
21
|
+
|
|
22
|
+
if (Array.isArray(catalog.rules)) return catalog.rules;
|
|
23
|
+
|
|
24
|
+
if (catalog.categories && typeof catalog.categories === "object") {
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const group of Object.values(catalog.categories)) {
|
|
27
|
+
if (Array.isArray(group)) {
|
|
28
|
+
out.push(...group);
|
|
29
|
+
} else if (group && Array.isArray(group.rules)) {
|
|
30
|
+
out.push(...group.rules);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (out.length > 0) return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const SCHEDULES_REL = "schedules.json";
|
|
5
|
+
const WRAPPERS_DIR_REL = path.join("scripts", "ingest");
|
|
6
|
+
const INGEST_LOG_REL = path.join("logs", "ingest.log");
|
|
7
|
+
const VALID_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Per-session schedule registry + wrapper-script generator.
|
|
11
|
+
*
|
|
12
|
+
* KC defines fetch jobs (each is a shell command). For each enabled job,
|
|
13
|
+
* Scheduler renders a self-contained wrapper script under
|
|
14
|
+
* `workspace/scripts/ingest/<id>.sh`. The user installs the wrapper into
|
|
15
|
+
* their crontab via `crontab -e`. Cron invokes the script directly — no
|
|
16
|
+
* kc-beta runtime dependency, works while kc-beta is closed.
|
|
17
|
+
*
|
|
18
|
+
* The wrapper:
|
|
19
|
+
* - exports WORKSPACE / INPUT_DIR / PROJECT_DIR env vars
|
|
20
|
+
* - snapshots input/ filenames before running
|
|
21
|
+
* - runs the user's command
|
|
22
|
+
* - prefixes any new files in input/ with `<job-id>_<YYYYMMDD-HHMMSS>_`
|
|
23
|
+
* - appends to logs/ingest.log
|
|
24
|
+
*/
|
|
25
|
+
export class Scheduler {
|
|
26
|
+
/**
|
|
27
|
+
* @param {import('./workspace.js').Workspace} workspace
|
|
28
|
+
*/
|
|
29
|
+
constructor(workspace) {
|
|
30
|
+
this._workspace = workspace;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- registry ---
|
|
34
|
+
|
|
35
|
+
load() {
|
|
36
|
+
const p = this._abs(SCHEDULES_REL);
|
|
37
|
+
if (!fs.existsSync(p)) return { version: 1, jobs: [] };
|
|
38
|
+
try {
|
|
39
|
+
const data = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
40
|
+
if (!data.jobs) return { version: 1, jobs: [] };
|
|
41
|
+
return data;
|
|
42
|
+
} catch {
|
|
43
|
+
return { version: 1, jobs: [] };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
save(state) {
|
|
48
|
+
const p = this._abs(SCHEDULES_REL);
|
|
49
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
50
|
+
fs.writeFileSync(p, JSON.stringify(state, null, 2), "utf-8");
|
|
51
|
+
this._workspace.autoCommit?.(SCHEDULES_REL, "schedules");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
list() {
|
|
55
|
+
return this.load().jobs;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Add a job. Returns { ok: true, job } or { ok: false, reason }.
|
|
60
|
+
* @param {{id:string, command:string, description?:string, cron_hint?:string, enabled?:boolean}} input
|
|
61
|
+
*/
|
|
62
|
+
add(input) {
|
|
63
|
+
const id = (input.id || "").trim();
|
|
64
|
+
const command = (input.command || "").trim();
|
|
65
|
+
if (!VALID_ID.test(id)) return { ok: false, reason: `id must match ${VALID_ID} (got '${id}')` };
|
|
66
|
+
if (!command) return { ok: false, reason: "command is required" };
|
|
67
|
+
|
|
68
|
+
const state = this.load();
|
|
69
|
+
if (state.jobs.find((j) => j.id === id)) {
|
|
70
|
+
return { ok: false, reason: `job '${id}' already exists; remove first or pick another id` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const job = {
|
|
74
|
+
id,
|
|
75
|
+
type: "shell",
|
|
76
|
+
command,
|
|
77
|
+
description: input.description || "",
|
|
78
|
+
cron_hint: input.cron_hint || "",
|
|
79
|
+
enabled: input.enabled !== false,
|
|
80
|
+
created_at: new Date().toISOString(),
|
|
81
|
+
};
|
|
82
|
+
state.jobs.push(job);
|
|
83
|
+
this.save(state);
|
|
84
|
+
if (job.enabled) this.renderWrapper(job);
|
|
85
|
+
return { ok: true, job };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
remove(id) {
|
|
89
|
+
const state = this.load();
|
|
90
|
+
const before = state.jobs.length;
|
|
91
|
+
state.jobs = state.jobs.filter((j) => j.id !== id);
|
|
92
|
+
if (state.jobs.length === before) return { ok: false, reason: `no job '${id}'` };
|
|
93
|
+
this.save(state);
|
|
94
|
+
this._removeWrapper(id);
|
|
95
|
+
return { ok: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setEnabled(id, enabled) {
|
|
99
|
+
const state = this.load();
|
|
100
|
+
const job = state.jobs.find((j) => j.id === id);
|
|
101
|
+
if (!job) return { ok: false, reason: `no job '${id}'` };
|
|
102
|
+
job.enabled = !!enabled;
|
|
103
|
+
this.save(state);
|
|
104
|
+
if (job.enabled) this.renderWrapper(job);
|
|
105
|
+
else this._removeWrapper(id);
|
|
106
|
+
return { ok: true, job };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- wrapper script ---
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Render the per-job wrapper script. Returns absolute path.
|
|
113
|
+
* @param {object} job
|
|
114
|
+
*/
|
|
115
|
+
renderWrapper(job) {
|
|
116
|
+
const wrappersDirAbs = this._abs(WRAPPERS_DIR_REL);
|
|
117
|
+
fs.mkdirSync(wrappersDirAbs, { recursive: true });
|
|
118
|
+
const scriptRel = path.join(WRAPPERS_DIR_REL, `${job.id}.sh`);
|
|
119
|
+
const scriptAbs = this._abs(scriptRel);
|
|
120
|
+
|
|
121
|
+
const wsAbs = this._workspace.cwd;
|
|
122
|
+
const projectAbs = this._workspace.projectDir || "";
|
|
123
|
+
const ingestLogAbs = this._abs(INGEST_LOG_REL);
|
|
124
|
+
const inputDirAbs = path.join(wsAbs, "input");
|
|
125
|
+
|
|
126
|
+
// Uses `find -newer` against a sentinel file to detect new arrivals.
|
|
127
|
+
// Portable across macOS BSD find and GNU find (no -printf, no comm/process-sub).
|
|
128
|
+
const body = [
|
|
129
|
+
"#!/bin/sh",
|
|
130
|
+
"# KC Agent ingestion wrapper",
|
|
131
|
+
`# Job: ${job.id}`,
|
|
132
|
+
`# Description: ${(job.description || "").replace(/[\r\n]/g, " ")}`,
|
|
133
|
+
`# Generated: ${new Date().toISOString()}`,
|
|
134
|
+
"# Do not edit by hand — regenerated by `schedule_fetch`.",
|
|
135
|
+
"",
|
|
136
|
+
"set -u",
|
|
137
|
+
`WORKSPACE=${shQuote(wsAbs)}`,
|
|
138
|
+
`INPUT_DIR=${shQuote(inputDirAbs)}`,
|
|
139
|
+
`PROJECT_DIR=${shQuote(projectAbs)}`,
|
|
140
|
+
`LOG_FILE=${shQuote(ingestLogAbs)}`,
|
|
141
|
+
`JOB_ID=${shQuote(job.id)}`,
|
|
142
|
+
'export WORKSPACE INPUT_DIR PROJECT_DIR',
|
|
143
|
+
"",
|
|
144
|
+
'mkdir -p "$INPUT_DIR" "$(dirname "$LOG_FILE")"',
|
|
145
|
+
'TS=$(date -u +%Y%m%d-%H%M%S)',
|
|
146
|
+
"",
|
|
147
|
+
'# Sentinel file for `find -newer` to detect arrivals',
|
|
148
|
+
'SENTINEL=$(mktemp -t kc_ingest_sentinel.XXXXXX) || exit 1',
|
|
149
|
+
'sleep 1 # ensure mtime granularity',
|
|
150
|
+
"",
|
|
151
|
+
`echo "[$TS] job=$JOB_ID start" >> "$LOG_FILE"`,
|
|
152
|
+
`{ ${job.command}; } >> "$LOG_FILE" 2>&1`,
|
|
153
|
+
'EXIT_CODE=$?',
|
|
154
|
+
"",
|
|
155
|
+
"# Rename any files newer than the sentinel: prepend job id + timestamp.",
|
|
156
|
+
"# Skip files already prefixed with this job id (idempotent re-runs).",
|
|
157
|
+
'find "$INPUT_DIR" -maxdepth 1 -type f -newer "$SENTINEL" 2>/dev/null | while IFS= read -r f; do',
|
|
158
|
+
' base=$(basename "$f")',
|
|
159
|
+
' case "$base" in',
|
|
160
|
+
` "$\{JOB_ID\}_"*) continue ;;`,
|
|
161
|
+
' esac',
|
|
162
|
+
' mv "$f" "$INPUT_DIR/${JOB_ID}_${TS}_${base}"',
|
|
163
|
+
'done',
|
|
164
|
+
"",
|
|
165
|
+
'rm -f "$SENTINEL"',
|
|
166
|
+
`echo "[$TS] job=$JOB_ID exit=$EXIT_CODE" >> "$LOG_FILE"`,
|
|
167
|
+
'exit $EXIT_CODE',
|
|
168
|
+
"",
|
|
169
|
+
].join("\n");
|
|
170
|
+
|
|
171
|
+
fs.writeFileSync(scriptAbs, body, "utf-8");
|
|
172
|
+
fs.chmodSync(scriptAbs, 0o755);
|
|
173
|
+
this._workspace.autoCommit?.(scriptRel, "wrapper");
|
|
174
|
+
return scriptAbs;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
_removeWrapper(id) {
|
|
178
|
+
const scriptRel = path.join(WRAPPERS_DIR_REL, `${id}.sh`);
|
|
179
|
+
const scriptAbs = this._abs(scriptRel);
|
|
180
|
+
if (fs.existsSync(scriptAbs)) {
|
|
181
|
+
fs.unlinkSync(scriptAbs);
|
|
182
|
+
this._workspace.autoCommit?.(scriptRel, "wrapper-remove");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Generate paste-ready crontab lines for all enabled jobs.
|
|
188
|
+
* If a job lacks `cron_hint`, the line is commented out with a hint to fill it in.
|
|
189
|
+
* @returns {string}
|
|
190
|
+
*/
|
|
191
|
+
formatCrontab() {
|
|
192
|
+
const jobs = this.list().filter((j) => j.enabled);
|
|
193
|
+
if (jobs.length === 0) return "(no enabled jobs to install)";
|
|
194
|
+
|
|
195
|
+
const lines = [
|
|
196
|
+
"# KC Agent ingestion jobs — install with `crontab -e` then paste these lines.",
|
|
197
|
+
`# Generated ${new Date().toISOString()}`,
|
|
198
|
+
"",
|
|
199
|
+
];
|
|
200
|
+
for (const j of jobs) {
|
|
201
|
+
const scriptAbs = this._abs(path.join(WRAPPERS_DIR_REL, `${j.id}.sh`));
|
|
202
|
+
if (j.cron_hint) {
|
|
203
|
+
lines.push(`# ${j.description || j.id}`);
|
|
204
|
+
lines.push(`${j.cron_hint} ${scriptAbs}`);
|
|
205
|
+
} else {
|
|
206
|
+
lines.push(`# ${j.description || j.id} — set the schedule yourself (5 cron fields)`);
|
|
207
|
+
lines.push(`# <minute> <hour> <day-of-month> <month> <day-of-week> ${scriptAbs}`);
|
|
208
|
+
}
|
|
209
|
+
lines.push("");
|
|
210
|
+
}
|
|
211
|
+
return lines.join("\n").trimEnd();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Tail of logs/ingest.log (last N lines), or empty string if missing.
|
|
216
|
+
*/
|
|
217
|
+
tailLog(lines = 20) {
|
|
218
|
+
const p = this._abs(INGEST_LOG_REL);
|
|
219
|
+
if (!fs.existsSync(p)) return "";
|
|
220
|
+
const body = fs.readFileSync(p, "utf-8");
|
|
221
|
+
return body.split("\n").filter(Boolean).slice(-lines).join("\n");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Count of files directly under input/ (excluding subdirs like archived/).
|
|
226
|
+
*/
|
|
227
|
+
pendingInputCount() {
|
|
228
|
+
const dir = path.join(this._workspace.cwd, "input");
|
|
229
|
+
if (!fs.existsSync(dir)) return 0;
|
|
230
|
+
try {
|
|
231
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
232
|
+
.filter((e) => e.isFile())
|
|
233
|
+
.length;
|
|
234
|
+
} catch {
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Regenerate wrapper scripts for all enabled jobs. Used by
|
|
241
|
+
* `engine.renameSession()` (Bug 3) — wrappers bake in absolute paths to
|
|
242
|
+
* the workspace, so a rename needs them re-rendered with the new paths.
|
|
243
|
+
*
|
|
244
|
+
* Returns `{ regenerated, disabled, failed }`:
|
|
245
|
+
* - `regenerated`: ids of jobs whose wrappers were successfully re-rendered
|
|
246
|
+
* - `disabled`: ids of jobs we skipped because they're disabled (no wrapper expected)
|
|
247
|
+
* - `failed`: list of `{id, error}` for jobs whose render call threw
|
|
248
|
+
* Splitting "skipped" into disabled vs failed lets the CLI surface render
|
|
249
|
+
* failures (e.g. disk full) instead of silently swallowing them.
|
|
250
|
+
*/
|
|
251
|
+
regenerateAllWrappers() {
|
|
252
|
+
const out = { regenerated: [], disabled: [], failed: [] };
|
|
253
|
+
for (const job of this.list()) {
|
|
254
|
+
if (!job.enabled) { out.disabled.push(job.id); continue; }
|
|
255
|
+
try {
|
|
256
|
+
this.renderWrapper(job);
|
|
257
|
+
out.regenerated.push(job.id);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
out.failed.push({ id: job.id, error: e?.message || String(e) });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --- helpers ---
|
|
266
|
+
|
|
267
|
+
_abs(rel) {
|
|
268
|
+
return path.join(this._workspace.cwd, rel);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** POSIX shell-quote a string. */
|
|
273
|
+
function shQuote(s) {
|
|
274
|
+
if (s === "") return "''";
|
|
275
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
276
|
+
}
|
|
@@ -10,9 +10,18 @@ import path from "node:path";
|
|
|
10
10
|
export class SessionState {
|
|
11
11
|
/**
|
|
12
12
|
* @param {string} workspacePath - Session workspace directory
|
|
13
|
+
* @param {object} [opts]
|
|
14
|
+
* @param {string} [opts.statePath] - Override absolute path (used for sub-agent isolation, Bug 2)
|
|
13
15
|
*/
|
|
14
|
-
constructor(workspacePath) {
|
|
15
|
-
this._path = path.join(workspacePath, "session-state.json");
|
|
16
|
+
constructor(workspacePath, opts = {}) {
|
|
17
|
+
this._path = opts.statePath || path.join(workspacePath, "session-state.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Re-point at a new state file. Used by `engine.renameSession()` (Bug 3).
|
|
22
|
+
*/
|
|
23
|
+
_setWorkspacePath(newWorkspacePath, opts = {}) {
|
|
24
|
+
this._path = opts.statePath || path.join(newWorkspacePath, "session-state.json");
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
/** Whether a session state file exists */
|
|
@@ -16,6 +16,11 @@ export class TaskManager {
|
|
|
16
16
|
this._load();
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** Re-point at a new tasks.json. Used by `engine.renameSession()` (Bug 3). */
|
|
20
|
+
_setWorkspacePath(newWorkspacePath) {
|
|
21
|
+
this._path = path.join(newWorkspacePath, "tasks.json");
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
// --- Task CRUD ---
|
|
20
25
|
|
|
21
26
|
/**
|