@toddzheng024/dscode-bundle 0.7.23 → 0.7.24
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/THIRD_PARTY_NOTICES.md +3 -0
- package/cordis.patch.yml +2 -0
- package/package.json +4 -2
- package/plugins/dscode/index.mjs +1 -1
- package/plugins/openrouter/adapter.mjs +23 -2
- package/plugins/openrouter/wire.mjs +2 -1
- package/plugins/triggers/cli.mjs +439 -0
- package/plugins/triggers/config.mjs +245 -0
- package/plugins/triggers/host.mjs +138 -0
- package/plugins/triggers/index.mjs +57 -0
- package/plugins/triggers/launchd.mjs +101 -0
- package/plugins/triggers/log.mjs +104 -0
- package/plugins/triggers/options.mjs +109 -0
- package/plugins/triggers/overlay.mjs +7 -0
- package/plugins/triggers/poll.mjs +57 -0
- package/plugins/triggers/run.mjs +156 -0
- package/plugins/triggers/spool.mjs +128 -0
- package/presets/dscode/agent.cordis.yml +1 -1
- package/vendor/command-goal/LICENSE +21 -0
- package/vendor/command-goal/index.js +208 -0
- package/vendor/command-goal/types/index.d.ts +10 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The run spec and run result: the two files that cross the process boundary
|
|
2
|
+
// between `dscode trigger run` (which owns the lock, the limits and the record)
|
|
3
|
+
// and the Host it spawns (which owns the agent, the goal and the transcript).
|
|
4
|
+
// Keeping them small and validated is what lets either half be tested alone.
|
|
5
|
+
|
|
6
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
const isPlainObject = value => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
9
|
+
|
|
10
|
+
class TriggerIoError extends Error {}
|
|
11
|
+
|
|
12
|
+
const fail = message => { throw new TriggerIoError(message); };
|
|
13
|
+
|
|
14
|
+
/** Write one JSON file atomically enough for a spawned child to read it once. */
|
|
15
|
+
function writeJson(path, value) {
|
|
16
|
+
writeFileSync(path, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
|
|
17
|
+
return path;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read a JSON file that must exist and must be an object. */
|
|
21
|
+
function readJson(path, label) {
|
|
22
|
+
let raw;
|
|
23
|
+
try {
|
|
24
|
+
raw = readFileSync(path, 'utf8');
|
|
25
|
+
} catch (error) {
|
|
26
|
+
fail(`${label} is unreadable: ${error?.code ?? error?.message ?? error}`);
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const value = JSON.parse(raw);
|
|
30
|
+
if (!isPlainObject(value)) fail(`${label} must be an object`);
|
|
31
|
+
return value;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
fail(`${label} is not valid JSON: ${error?.message ?? error}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Render the prompt one run sends: the definition's template with the event's
|
|
39
|
+
* values substituted. A template that never mentions the event text gets it
|
|
40
|
+
* appended, so an event can never be silently dropped by a forgetting template.
|
|
41
|
+
* @param template - the definition's prompt.
|
|
42
|
+
* @param event - the event that fired the run, when there is one.
|
|
43
|
+
* @returns the prompt text.
|
|
44
|
+
*/
|
|
45
|
+
export function renderPrompt(template, event) {
|
|
46
|
+
const text = typeof template === 'string' ? template : '';
|
|
47
|
+
if (event === undefined || event === null) return text;
|
|
48
|
+
let rendered = text
|
|
49
|
+
.replace(/\{\{event\.text\}\}/gu, event.text ?? '')
|
|
50
|
+
.replace(/\{\{event\.title\}\}/gu, event.title ?? '')
|
|
51
|
+
.replace(/\{\{event\.source\}\}/gu, event.source ?? '')
|
|
52
|
+
.replace(/\{\{event\.eventId\}\}/gu, event.eventId ?? '')
|
|
53
|
+
.replace(/\{\{event\.fields\.([A-Za-z0-9_.-]+)\}\}/gu, (_whole, key) => {
|
|
54
|
+
const value = isPlainObject(event.fields) ? event.fields[key] : undefined;
|
|
55
|
+
return value === undefined ? '' : String(value);
|
|
56
|
+
});
|
|
57
|
+
if (!/\{\{event\./u.test(text) && String(event.text ?? '').trim() !== '') {
|
|
58
|
+
const title = event.title === undefined ? '' : `${event.title}: `;
|
|
59
|
+
const block = `Event (${event.source ?? 'unspecified'}): ${title}${event.text}`;
|
|
60
|
+
rendered = [rendered.trimEnd(), block].filter(part => part !== '').join('\n\n');
|
|
61
|
+
}
|
|
62
|
+
return rendered;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Write the spec the spawned Host reads.
|
|
67
|
+
* @param path - where to write it.
|
|
68
|
+
* @param spec - `{ triggerId, runId, workspace, prompt, preset, permission, model?, effort?, goal, limits }`.
|
|
69
|
+
* @returns the path.
|
|
70
|
+
*/
|
|
71
|
+
export function writeRunSpec(path, spec) {
|
|
72
|
+
if (!isPlainObject(spec)) fail('a run spec must be an object');
|
|
73
|
+
for (const field of ['triggerId', 'runId', 'workspace', 'prompt', 'goal']) {
|
|
74
|
+
if (spec[field] === undefined) fail(`a run spec needs ${field}`);
|
|
75
|
+
}
|
|
76
|
+
if (!isPlainObject(spec.goal) || typeof spec.goal.objective !== 'string' || spec.goal.objective.trim() === '') fail('a run spec needs goal.objective');
|
|
77
|
+
if (!Number.isSafeInteger(spec.goal.maxRounds) || spec.goal.maxRounds <= 0) fail('a run spec needs a positive goal.maxRounds');
|
|
78
|
+
return writeJson(path, spec);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Read a run spec written by {@link writeRunSpec}. */
|
|
82
|
+
export function readRunSpec(path) {
|
|
83
|
+
const spec = readJson(path, 'the run spec');
|
|
84
|
+
for (const field of ['triggerId', 'runId', 'workspace', 'prompt', 'goal']) {
|
|
85
|
+
if (spec[field] === undefined) fail(`the run spec is missing ${field}`);
|
|
86
|
+
}
|
|
87
|
+
return spec;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Write the result the Host reports back, which the parent turns into a record. */
|
|
91
|
+
export function writeRunResult(path, result) {
|
|
92
|
+
if (!isPlainObject(result)) fail('a run result must be an object');
|
|
93
|
+
if (typeof result.outcome !== 'string') fail('a run result needs an outcome');
|
|
94
|
+
if (!Number.isSafeInteger(result.exitCode)) fail('a run result needs an exitCode');
|
|
95
|
+
return writeJson(path, result);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Read the Host's result.
|
|
100
|
+
* @param path - the result file.
|
|
101
|
+
* @returns the result, or undefined when the child died before writing one.
|
|
102
|
+
*/
|
|
103
|
+
export function readRunResult(path) {
|
|
104
|
+
try {
|
|
105
|
+
return readJson(path, 'the run result');
|
|
106
|
+
} catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The composition overlay that turns a terminal profile into one triggered run:
|
|
2
|
+
// the terminal rows are disabled and the trigger host is inserted instead. It is
|
|
3
|
+
// a pure string builder on purpose — the published launcher ships this file to
|
|
4
|
+
// compose the same overlay, and it must not pull the host's dependencies in.
|
|
5
|
+
export function triggerOverlay(pluginPath) {
|
|
6
|
+
return `- id: tui-startup\n disabled: true\n- id: tui-runner\n disabled: true\n- id: dscode-session-cards\n config:\n enabled: false\n- insert:\n - id: dscode-trigger-host\n name: ${JSON.stringify(pluginPath)}\n`;
|
|
7
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The poll predicate: with no resident listener, "watch an external thing" is a
|
|
2
|
+
// scheduled cheap check that only starts a session when something changed. The
|
|
3
|
+
// check is a shell command run in the definition's workspace; exit 0 means
|
|
4
|
+
// "there is work", anything else means "nothing to do".
|
|
5
|
+
//
|
|
6
|
+
// A predicate that cannot run must not fire a run: a spawn failure or a timeout
|
|
7
|
+
// is reported as not matched, never as a match.
|
|
8
|
+
|
|
9
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
/** Output cap for the check's own text, so a chatty check cannot flood a record. */
|
|
12
|
+
export const MAX_CHECK_OUTPUT_CHARS = 2000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Run one poll predicate.
|
|
16
|
+
* @param check - the shell command from `source.check`.
|
|
17
|
+
* @param options - `{ cwd, timeoutMs, spawn }`; `spawn` is injectable for tests.
|
|
18
|
+
* @returns `{ matched, code, output }`: `matched` is true only on exit 0.
|
|
19
|
+
*/
|
|
20
|
+
export function evaluateCheck(check, { cwd, timeoutMs = 60000, spawn = nodeSpawn } = {}) {
|
|
21
|
+
return new Promise(resolveCheck => {
|
|
22
|
+
let child;
|
|
23
|
+
try {
|
|
24
|
+
child = spawn('/bin/sh', ['-c', check], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
25
|
+
} catch (error) {
|
|
26
|
+
resolveCheck({ matched: false, code: null, output: `could not start the check: ${error?.message ?? error}` });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
let output = '';
|
|
30
|
+
let settled = false;
|
|
31
|
+
const collect = chunk => {
|
|
32
|
+
if (output.length < MAX_CHECK_OUTPUT_CHARS) output += String(chunk);
|
|
33
|
+
};
|
|
34
|
+
child.stdout?.on('data', collect);
|
|
35
|
+
child.stderr?.on('data', collect);
|
|
36
|
+
const timer = setTimeout(() => {
|
|
37
|
+
if (settled) return;
|
|
38
|
+
settled = true;
|
|
39
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
40
|
+
resolveCheck({ matched: false, code: null, output: `${output}\n(the check exceeded ${timeoutMs}ms and was stopped)`.trim().slice(0, MAX_CHECK_OUTPUT_CHARS) });
|
|
41
|
+
}, timeoutMs);
|
|
42
|
+
timer.unref?.();
|
|
43
|
+
const finish = code => {
|
|
44
|
+
if (settled) return;
|
|
45
|
+
settled = true;
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
resolveCheck({ matched: code === 0, code, output: output.replace(/\s+/gu, ' ').trim().slice(0, MAX_CHECK_OUTPUT_CHARS) });
|
|
48
|
+
};
|
|
49
|
+
child.once('error', error => {
|
|
50
|
+
if (settled) return;
|
|
51
|
+
settled = true;
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
resolveCheck({ matched: false, code: null, output: `the check failed to start: ${error?.message ?? error}` });
|
|
54
|
+
});
|
|
55
|
+
child.once('exit', code => finish(code));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The run decision: everything that must be true before an event may start a
|
|
2
|
+
// session, and the record left afterwards. This is the safety half of the
|
|
3
|
+
// mechanism, so it is deliberately small and entirely testable: an unattended
|
|
4
|
+
// run must never overlap its own previous run, must never fire twice for one
|
|
5
|
+
// event, and must never exceed the limits its definition declares.
|
|
6
|
+
//
|
|
7
|
+
// It does NOT run an agent: executing the session (Host plugin, `dscode trigger
|
|
8
|
+
// run`, the goal service) is separate work. `planTriggerRun` answers "may this
|
|
9
|
+
// run start, and with what identity", `finishTriggerRun` records how it ended.
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { appendRun, readRuns } from './log.mjs';
|
|
15
|
+
|
|
16
|
+
/** Grace added to a run's timeout before its lock is treated as abandoned. */
|
|
17
|
+
export const LOCK_STALE_GRACE_MS = 60000;
|
|
18
|
+
|
|
19
|
+
/** One trigger's single-flight lock. */
|
|
20
|
+
export const lockPath = (home, triggerId) => join(home, 'triggers', 'locks', `${triggerId}.json`);
|
|
21
|
+
|
|
22
|
+
/** True when a process id exists; EPERM means it exists but belongs to someone else. */
|
|
23
|
+
export function alivePid(pid) {
|
|
24
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
25
|
+
try {
|
|
26
|
+
process.kill(pid, 0);
|
|
27
|
+
return true;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return error?.code === 'EPERM';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readLock(home, triggerId) {
|
|
34
|
+
try {
|
|
35
|
+
const lock = JSON.parse(readFileSync(lockPath(home, triggerId), 'utf8'));
|
|
36
|
+
return typeof lock?.runId === 'string' ? lock : undefined;
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeLock(home, handle) {
|
|
43
|
+
const path = lockPath(home, handle.triggerId);
|
|
44
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
45
|
+
writeFileSync(path, JSON.stringify(handle) + '\n', { mode: 0o600 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Decide whether one event may start a run, and take the lock when it may.
|
|
50
|
+
*
|
|
51
|
+
* Order matters: a disabled trigger never runs; a repeated event is recognised
|
|
52
|
+
* before any lock is touched; the rolling day and the minimum interval are
|
|
53
|
+
* checked against recorded runs; and only then does a live lock stop the run.
|
|
54
|
+
* An identity is REQUIRED: without one the duplicate check is inert, so a
|
|
55
|
+
* re-drained event would start a second session. A scheduled source passes the
|
|
56
|
+
* planned instant, an external producer passes its own event id.
|
|
57
|
+
* @param definition - a normalized definition (plugins/triggers/config.mjs).
|
|
58
|
+
* @param options - `{ home, now, eventId, isAlive, runId }`; `eventId` is required.
|
|
59
|
+
* @returns `{ action: 'run', handle }` or `{ action: 'skip', reason }`.
|
|
60
|
+
* @throws {Error} when `eventId` is missing or empty.
|
|
61
|
+
*/
|
|
62
|
+
export function planTriggerRun(definition, { home, now = Date.now(), eventId, isAlive = alivePid, runId = randomUUID() } = {}) {
|
|
63
|
+
if (typeof eventId !== 'string' || eventId.trim() === '') throw new Error('planTriggerRun needs an eventId: it is how a repeated firing is recognised');
|
|
64
|
+
if (!definition.enabled) return { action: 'skip', reason: 'disabled' };
|
|
65
|
+
if (!existsSync(definition.workspace)) return { action: 'skip', reason: 'workspace_missing' };
|
|
66
|
+
const recorded = readRuns(home, { triggerId: definition.id, limit: 0 }).filter(entry => entry.outcome !== 'skipped');
|
|
67
|
+
if (recorded.some(entry => entry.eventId === eventId)) return { action: 'skip', reason: 'duplicate' };
|
|
68
|
+
const lastDay = recorded.filter(entry => now - entry.startedAt < 24 * 60 * 60 * 1000);
|
|
69
|
+
if (lastDay.length >= definition.limits.maxRunsPerDay) return { action: 'skip', reason: 'over_daily_limit' };
|
|
70
|
+
const newest = recorded[0];
|
|
71
|
+
if (newest !== undefined && now - newest.startedAt < definition.limits.minIntervalSeconds * 1000) return { action: 'skip', reason: 'too_soon' };
|
|
72
|
+
|
|
73
|
+
const lock = readLock(home, definition.id);
|
|
74
|
+
if (lock !== undefined) {
|
|
75
|
+
// A dead holder is reclaimed at once. A live pid is normally the run itself,
|
|
76
|
+
// but a recycled pid would look alive forever, so a lock older than the run's
|
|
77
|
+
// own timeout (plus a grace) is abandoned regardless.
|
|
78
|
+
const abandoned = now - lock.startedAt > definition.limits.timeoutSeconds * 1000 + LOCK_STALE_GRACE_MS;
|
|
79
|
+
if (isAlive(lock.pid) && !abandoned) return { action: 'skip', reason: 'already_running' };
|
|
80
|
+
}
|
|
81
|
+
const handle = { triggerId: definition.id, runId, startedAt: now, pid: process.pid, eventId };
|
|
82
|
+
writeLock(home, handle);
|
|
83
|
+
return { action: 'run', handle };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Drop a lock this run owns, without recording anything.
|
|
88
|
+
* A lock whose contents cannot be read is left in place rather than deleted: an
|
|
89
|
+
* unreadable lock may belong to another run, and deleting it would let two
|
|
90
|
+
* sessions run for one trigger. It ages out on its own.
|
|
91
|
+
*/
|
|
92
|
+
export function releaseTriggerRun(home, handle) {
|
|
93
|
+
const lock = readLock(home, handle.triggerId);
|
|
94
|
+
if (lock === undefined || lock.runId !== handle.runId) return false;
|
|
95
|
+
try {
|
|
96
|
+
rmSync(lockPath(home, handle.triggerId));
|
|
97
|
+
return true;
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Decide whether a run in flight should stop, and how it ended. Called after
|
|
105
|
+
* every turn and by the timeout, so it only reads durable goal state plus the
|
|
106
|
+
* spend the metrics ledger reports.
|
|
107
|
+
*
|
|
108
|
+
* Completion wins over a rejected approval: the goal was reached, and the
|
|
109
|
+
* rejection is recorded in the run tail. When a cap or the timeout stops a run
|
|
110
|
+
* that needed a human, `approval_required` is the reason — that is the failure an
|
|
111
|
+
* operator has to fix.
|
|
112
|
+
* @param state - `{ goal, costUsd, limits, approvalsRejected }`.
|
|
113
|
+
* @returns `{ stop: false }` or `{ stop: true, outcome, reason, exitCode }`.
|
|
114
|
+
*/
|
|
115
|
+
export function decideStop({ goal, costUsd, limits, approvalsRejected = false } = {}) {
|
|
116
|
+
const capped = approvalsRejected ? 'approval_required' : undefined;
|
|
117
|
+
// A goal that is gone was cleared: nothing left to continue for.
|
|
118
|
+
if (goal === undefined || goal === null) return { stop: true, outcome: 'completed', reason: null, exitCode: 0 };
|
|
119
|
+
if (goal.phase === 'complete') return { stop: true, outcome: 'completed', reason: null, exitCode: 0 };
|
|
120
|
+
if (goal.phase === 'blocked') return { stop: true, outcome: 'blocked', reason: 'goal_blocked', exitCode: 3 };
|
|
121
|
+
if (goal.phase === 'paused') return { stop: true, outcome: 'failed', reason: 'goal_paused', exitCode: 3 };
|
|
122
|
+
if (Number.isFinite(limits?.maxCostUsd) && Number.isFinite(costUsd) && costUsd >= limits.maxCostUsd) {
|
|
123
|
+
return { stop: true, outcome: 'overrun', reason: capped ?? 'cost_cap', exitCode: 2 };
|
|
124
|
+
}
|
|
125
|
+
if (Number.isFinite(goal.maxGoalRounds) && goal.roundsStarted >= goal.maxGoalRounds) {
|
|
126
|
+
return { stop: true, outcome: 'overrun', reason: capped ?? 'round_cap', exitCode: 2 };
|
|
127
|
+
}
|
|
128
|
+
return { stop: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Record how a run ended and release its lock.
|
|
133
|
+
* @param home - the state directory.
|
|
134
|
+
* @param handle - the handle `planTriggerRun` returned.
|
|
135
|
+
* @param result - `{ outcome, reason?, exitCode, sessionId?, cost?, rounds?, cwd?, endedAt?, eventId? }`.
|
|
136
|
+
* @returns the appended record.
|
|
137
|
+
*/
|
|
138
|
+
export function finishTriggerRun(home, handle, result) {
|
|
139
|
+
const record = appendRun(home, {
|
|
140
|
+
triggerId: handle.triggerId,
|
|
141
|
+
runId: handle.runId,
|
|
142
|
+
startedAt: handle.startedAt,
|
|
143
|
+
endedAt: result.endedAt ?? Date.now(),
|
|
144
|
+
eventId: result.eventId ?? handle.eventId,
|
|
145
|
+
source: result.source ?? null,
|
|
146
|
+
outcome: result.outcome,
|
|
147
|
+
reason: result.reason ?? null,
|
|
148
|
+
exitCode: result.exitCode,
|
|
149
|
+
sessionId: result.sessionId ?? null,
|
|
150
|
+
cost: result.cost ?? null,
|
|
151
|
+
rounds: result.rounds ?? null,
|
|
152
|
+
cwd: result.cwd ?? handle.cwd ?? null,
|
|
153
|
+
});
|
|
154
|
+
releaseTriggerRun(home, handle);
|
|
155
|
+
return record;
|
|
156
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The ingress: one cheap, uniform way to post an event into a trigger. A producer
|
|
2
|
+
// drops a JSON file and returns; a later drain runs it. This is the whole public
|
|
3
|
+
// surface of the mechanism (docs/triggers-design.md), so it stays dumb: it
|
|
4
|
+
// validates that the payload is *data* and never carries authority, and it does
|
|
5
|
+
// not know what a session, a goal or a model is.
|
|
6
|
+
//
|
|
7
|
+
// Delivery is at-most-once: an event file is consumed when a run is started, and
|
|
8
|
+
// the run record carries its identity, so a re-emitted event needs a new id.
|
|
9
|
+
|
|
10
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
/** The only keys an event body may carry; anything else is a producer mistake. */
|
|
14
|
+
export const EVENT_FIELDS = Object.freeze(['source', 'title', 'text', 'fields']);
|
|
15
|
+
|
|
16
|
+
/** Message body cap, matching what one session input accepts. */
|
|
17
|
+
export const MAX_EVENT_TEXT_BYTES = 64000;
|
|
18
|
+
|
|
19
|
+
/** Event identity cap: it names a spool file and a run record, not a payload. */
|
|
20
|
+
export const MAX_EVENT_ID_CHARS = 200;
|
|
21
|
+
|
|
22
|
+
const TRIGGER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/u;
|
|
23
|
+
|
|
24
|
+
class SpoolError extends Error {}
|
|
25
|
+
|
|
26
|
+
const fail = message => { throw new SpoolError(message); };
|
|
27
|
+
|
|
28
|
+
const isPlainObject = value => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
29
|
+
|
|
30
|
+
/** The spool directory of one trigger. */
|
|
31
|
+
export const spoolPath = (home, triggerId) => join(home, 'triggers', 'spool', triggerId);
|
|
32
|
+
|
|
33
|
+
/** One event's file name: the id is reversible, so a listing can name the event. */
|
|
34
|
+
const eventFile = (triggerId, eventId) => `${encodeURIComponent(eventId)}.json`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Post one event.
|
|
38
|
+
* @param home - the state directory.
|
|
39
|
+
* @param triggerId - the target trigger.
|
|
40
|
+
* @param payload - `{ source?, title?, text?, fields? }`; every value is data.
|
|
41
|
+
* @param options - `{ eventId, now }`; `eventId` is the de-duplication identity and is required.
|
|
42
|
+
* @returns the stored event.
|
|
43
|
+
* @throws {SpoolError} on an unknown field, a non-scalar value, an oversized body or a missing id.
|
|
44
|
+
*/
|
|
45
|
+
export function emitEvent(home, triggerId, payload, { eventId, now = Date.now() } = {}) {
|
|
46
|
+
if (typeof triggerId !== 'string' || !TRIGGER_ID_PATTERN.test(triggerId)) fail('triggerId must be the definition id');
|
|
47
|
+
if (typeof eventId !== 'string' || eventId.trim() === '') fail('eventId is required: it is how a repeated delivery is recognised');
|
|
48
|
+
if (eventId.trim().length > MAX_EVENT_ID_CHARS) fail(`eventId is longer than ${MAX_EVENT_ID_CHARS} characters`);
|
|
49
|
+
if (/[\u0000-\u001f\u007f]/u.test(eventId)) fail('eventId must not contain control characters');
|
|
50
|
+
if (!isPlainObject(payload)) fail('an event must be an object');
|
|
51
|
+
const unknown = Object.keys(payload).filter(key => !EVENT_FIELDS.includes(key));
|
|
52
|
+
// This is the authority boundary: an event is data, so a payload may not name a
|
|
53
|
+
// permission, a session or a tool. A field that is not understood is refused
|
|
54
|
+
// rather than ignored, so a producer cannot believe it granted something.
|
|
55
|
+
if (unknown.length > 0) fail(`an event carries no such field: ${unknown.join(', ')} (accepted: ${EVENT_FIELDS.join(', ')})`);
|
|
56
|
+
const text = payload.text ?? '';
|
|
57
|
+
if (typeof text !== 'string') fail('event.text must be a string');
|
|
58
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_EVENT_TEXT_BYTES) fail(`event.text is longer than ${MAX_EVENT_TEXT_BYTES} bytes`);
|
|
59
|
+
if (payload.source !== undefined && typeof payload.source !== 'string') fail('event.source must be a string');
|
|
60
|
+
if (payload.title !== undefined && typeof payload.title !== 'string') fail('event.title must be a string');
|
|
61
|
+
if (payload.fields !== undefined) {
|
|
62
|
+
if (!isPlainObject(payload.fields)) fail('event.fields must be an object of scalar values');
|
|
63
|
+
for (const [key, value] of Object.entries(payload.fields)) {
|
|
64
|
+
if (!['string', 'number', 'boolean'].includes(typeof value)) fail(`event.fields.${key} must be a string, number or boolean`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const event = {
|
|
68
|
+
triggerId,
|
|
69
|
+
eventId: eventId.trim(),
|
|
70
|
+
...(payload.source === undefined ? {} : { source: payload.source }),
|
|
71
|
+
...(payload.title === undefined ? {} : { title: payload.title }),
|
|
72
|
+
text,
|
|
73
|
+
...(payload.fields === undefined ? {} : { fields: payload.fields }),
|
|
74
|
+
receivedAt: now,
|
|
75
|
+
};
|
|
76
|
+
const directory = spoolPath(home, triggerId);
|
|
77
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
78
|
+
writeFileSync(join(directory, eventFile(triggerId, event.eventId)), JSON.stringify(event) + '\n', { mode: 0o600 });
|
|
79
|
+
return event;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* List one trigger's pending events, oldest first.
|
|
84
|
+
* @param home - the state directory.
|
|
85
|
+
* @param triggerId - the trigger.
|
|
86
|
+
* @param options - `{ limit }`.
|
|
87
|
+
* @returns the parsed events, unreadable files skipped.
|
|
88
|
+
*/
|
|
89
|
+
export function listEvents(home, triggerId, { limit = 50 } = {}) {
|
|
90
|
+
let entries;
|
|
91
|
+
try {
|
|
92
|
+
entries = readdirSync(spoolPath(home, triggerId)).filter(name => name.endsWith('.json'));
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const events = [];
|
|
97
|
+
for (const name of entries.sort()) {
|
|
98
|
+
try {
|
|
99
|
+
events.push(JSON.parse(readFileSync(join(spoolPath(home, triggerId), name), 'utf8')));
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
events.sort((left, right) => (left.receivedAt ?? 0) - (right.receivedAt ?? 0) || String(left.eventId).localeCompare(String(right.eventId)));
|
|
105
|
+
return limit === 0 ? events : events.slice(0, limit);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Consume one event, so a drain cannot start it twice.
|
|
110
|
+
* @returns true when a pending file was removed.
|
|
111
|
+
*/
|
|
112
|
+
export function consumeEvent(home, triggerId, eventId) {
|
|
113
|
+
try {
|
|
114
|
+
rmSync(join(spoolPath(home, triggerId), eventFile(triggerId, eventId)));
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** One event as a single listing line. */
|
|
122
|
+
export function formatEvent(event) {
|
|
123
|
+
const when = new Date(event.receivedAt ?? 0).toISOString().replace('T', ' ').slice(0, 19);
|
|
124
|
+
const source = event.source === undefined ? '' : ` [${event.source}]`;
|
|
125
|
+
const title = event.title === undefined ? '' : `${event.title}: `;
|
|
126
|
+
const text = String(event.text ?? '').replace(/\s+/gu, ' ').trim();
|
|
127
|
+
return `${when}${source} ${event.eventId} — ${title}${text.length > 80 ? `${text.slice(0, 79)}…` : text}`;
|
|
128
|
+
}
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
# can resolve them. The human command and model-facing tool register into this
|
|
150
150
|
# preset's scoped layers.
|
|
151
151
|
- id: command-goal
|
|
152
|
-
name: '@
|
|
152
|
+
name: '@toddzheng024/dscode-bundle/command-goal'
|
|
153
153
|
|
|
154
154
|
- id: tool-goal
|
|
155
155
|
name: '@deepseek-ai/dsh-tool-goal'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|