chainlesschain 0.45.81 → 0.47.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.
Files changed (71) hide show
  1. package/README.md +10 -0
  2. package/bin/chainlesschain.js +0 -0
  3. package/package.json +1 -1
  4. package/src/assets/web-panel/.build-hash +1 -1
  5. package/src/assets/web-panel/assets/{Analytics-C1AnPdMx.js → Analytics-DgypYeUB.js} +2 -2
  6. package/src/assets/web-panel/assets/AppLayout-Bzf3mSZI.js +1 -0
  7. package/src/assets/web-panel/assets/AppLayout-DQyDwGut.css +1 -0
  8. package/src/assets/web-panel/assets/{Backup-D31iZX3l.js → Backup-Ba9UybpT.js} +1 -1
  9. package/src/assets/web-panel/assets/{Chat-DiXJ3TuK.js → Chat-BwXskT21.js} +1 -1
  10. package/src/assets/web-panel/assets/Cowork-CXuhlHew.css +1 -0
  11. package/src/assets/web-panel/assets/Cowork-UmOe7qvE.js +7 -0
  12. package/src/assets/web-panel/assets/{Cron-DBt1ueXh.js → Cron-JHS-rc-4.js} +2 -2
  13. package/src/assets/web-panel/assets/{Dashboard-HPh9FcPt.js → Dashboard-B95cMCO7.js} +2 -2
  14. package/src/assets/web-panel/assets/Dashboard-CKeMmCoT.css +1 -0
  15. package/src/assets/web-panel/assets/{Git-hwQ1oZHj.js → Git-CSYO0_zk.js} +2 -2
  16. package/src/assets/web-panel/assets/{Logs-4D9p6PRM.js → Logs-Hxw_K0km.js} +2 -2
  17. package/src/assets/web-panel/assets/{McpTools-CyAUjbbs.js → McpTools-DIE75TrB.js} +2 -2
  18. package/src/assets/web-panel/assets/{Memory-BMqOR7S-.js → Memory-C4KVnLlp.js} +2 -2
  19. package/src/assets/web-panel/assets/{Notes-Cmas8i4E.js → Notes-DuzrHMAk.js} +2 -2
  20. package/src/assets/web-panel/assets/{Organization-DnSa58Tl.js → Organization-DTq6uF82.js} +4 -4
  21. package/src/assets/web-panel/assets/{P2P-BxksIBWs.js → P2P-C0hjlhsR.js} +2 -2
  22. package/src/assets/web-panel/assets/{Permissions-Bq5Qn2s3.js → Permissions-Ec0NH-xC.js} +4 -4
  23. package/src/assets/web-panel/assets/{Projects-B7EM0uPg.js → Projects-U8D0asCS.js} +2 -2
  24. package/src/assets/web-panel/assets/{Providers-DAwgG5KV.js → Providers-BngtTLvJ.js} +2 -2
  25. package/src/assets/web-panel/assets/{RssFeed-HSZoRXvS.js → RssFeed-B9NbwCKM.js} +3 -3
  26. package/src/assets/web-panel/assets/{Security-Cz17qBny.js → Security-BL5Rkr1T.js} +3 -3
  27. package/src/assets/web-panel/assets/{Services-D2EsLq-v.js → Services-D4MJzLld.js} +2 -2
  28. package/src/assets/web-panel/assets/{Skills-C9v-f3vZ.js → Skills-CQTOMDwF.js} +1 -1
  29. package/src/assets/web-panel/assets/{Tasks-yMEcU0n7.js → Tasks-DepbJMnL.js} +1 -1
  30. package/src/assets/web-panel/assets/{Templates-l7SvlKuB.js → Templates-C24PVZPu.js} +1 -1
  31. package/src/assets/web-panel/assets/{Wallet-BHWhLWn9.js → Wallet-PQoSpN_P.js} +3 -3
  32. package/src/assets/web-panel/assets/{WebAuthn-kWhFYaUK.js → WebAuthn-BcuyQ4Lr.js} +4 -4
  33. package/src/assets/web-panel/assets/WorkflowEditor-C-SvXbHW.js +1 -0
  34. package/src/assets/web-panel/assets/WorkflowEditor-D5bX6woe.css +1 -0
  35. package/src/assets/web-panel/assets/{antd-D6h4fDFf.js → antd-DEjZPGMj.js} +82 -82
  36. package/src/assets/web-panel/assets/index-CwvzTTw_.js +2 -0
  37. package/src/assets/web-panel/assets/{markdown-BZsB-Dsv.js → markdown-CusdXFxb.js} +1 -1
  38. package/src/assets/web-panel/index.html +2 -2
  39. package/src/commands/cowork.js +867 -0
  40. package/src/gateways/ws/action-protocol.js +182 -2
  41. package/src/gateways/ws/message-dispatcher.js +5 -0
  42. package/src/gateways/ws/ws-server.js +21 -0
  43. package/src/lib/cowork-cron.js +474 -0
  44. package/src/lib/cowork-evomap-adapter.js +121 -0
  45. package/src/lib/cowork-learning.js +438 -0
  46. package/src/lib/cowork-mcp-tools.js +182 -0
  47. package/src/lib/cowork-observe-html.js +108 -0
  48. package/src/lib/cowork-observe.js +160 -0
  49. package/src/lib/cowork-share.js +322 -0
  50. package/src/lib/cowork-task-runner.js +317 -3
  51. package/src/lib/cowork-task-templates.js +101 -13
  52. package/src/lib/cowork-template-marketplace.js +205 -0
  53. package/src/lib/cowork-workflow.js +571 -0
  54. package/src/lib/provider-options.js +133 -0
  55. package/src/lib/skill-loader.js +65 -0
  56. package/src/lib/sub-agent-context.js +54 -2
  57. package/src/lib/sub-agent-profiles.js +164 -0
  58. package/src/lib/todo-manager.js +108 -0
  59. package/src/lib/turn-context.js +95 -0
  60. package/src/lib/web-fetch.js +224 -0
  61. package/src/lib/workflow-expr.js +318 -0
  62. package/src/repl/agent-repl.js +4 -0
  63. package/src/runtime/agent-core.js +135 -3
  64. package/src/runtime/coding-agent-contract-shared.cjs +131 -0
  65. package/src/runtime/coding-agent-policy.cjs +30 -0
  66. package/src/assets/web-panel/assets/AppLayout-YdvJBMHH.js +0 -1
  67. package/src/assets/web-panel/assets/AppLayout-cxfKLu-m.css +0 -1
  68. package/src/assets/web-panel/assets/Cowork-BnrHWwZw.js +0 -7
  69. package/src/assets/web-panel/assets/Cowork-CcSoS3eX.css +0 -1
  70. package/src/assets/web-panel/assets/Dashboard-BS-tzGNj.css +0 -1
  71. package/src/assets/web-panel/assets/index-ByUk2Wmr.js +0 -2
@@ -0,0 +1,474 @@
1
+ /**
2
+ * Cowork Cron — schedule and run daily Cowork tasks on a cron.
3
+ *
4
+ * Persists schedules to `.chainlesschain/cowork/schedules.jsonl` (one JSON
5
+ * object per line). Schedules have shape:
6
+ *
7
+ * {
8
+ * id: "sch-...",
9
+ * cron: "0 9 * * 1-5", // 5-field POSIX cron
10
+ * templateId: "doc-convert", // or null for free mode
11
+ * userMessage: "...", // task prompt
12
+ * files: ["/abs/path/..."], // optional
13
+ * enabled: true,
14
+ * createdAt: ISO,
15
+ * lastRunAt: ISO|null,
16
+ * lastStatus: "completed"|"failed"|null,
17
+ * }
18
+ *
19
+ * The scheduler ticks every 60s by default. If any schedule uses a 6-field
20
+ * (seconds-aware) cron expression, the tick rate auto-adapts to 1s.
21
+ * Each tick reloads schedules and runs any whose cron matches the current
22
+ * minute (or second, if 6-field). A schedule only runs once per fire-window.
23
+ *
24
+ * Supported cron syntax:
25
+ * - 5 fields: minute hour dom month dow (POSIX)
26
+ * - 6 fields: second minute hour dom month dow (Quartz-like, seconds first)
27
+ * - Aliases: @yearly @annually @monthly @weekly @daily @midnight @hourly
28
+ *
29
+ * @module cowork-cron
30
+ */
31
+
32
+ import crypto from "node:crypto";
33
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
34
+ import { join } from "node:path";
35
+
36
+ export const _deps = {
37
+ existsSync,
38
+ mkdirSync,
39
+ readFileSync,
40
+ writeFileSync,
41
+ now: () => new Date(),
42
+ runTask: null, // injected at runtime to avoid circular import
43
+ };
44
+
45
+ // ─── Cron parser ─────────────────────────────────────────────────────────────
46
+
47
+ const FIELD_RANGES = [
48
+ [0, 59], // minute
49
+ [0, 23], // hour
50
+ [1, 31], // day-of-month
51
+ [1, 12], // month
52
+ [0, 6], // day-of-week (0=Sun, 6=Sat). 7 also maps to 0.
53
+ ];
54
+
55
+ const SECOND_RANGE = [0, 59];
56
+
57
+ /**
58
+ * Non-standard alias → 5-field expression. Aliases never carry seconds.
59
+ */
60
+ export const ALIASES = Object.freeze({
61
+ "@yearly": "0 0 1 1 *",
62
+ "@annually": "0 0 1 1 *",
63
+ "@monthly": "0 0 1 * *",
64
+ "@weekly": "0 0 * * 0",
65
+ "@daily": "0 0 * * *",
66
+ "@midnight": "0 0 * * *",
67
+ "@hourly": "0 * * * *",
68
+ });
69
+
70
+ /**
71
+ * Expand a cron alias (e.g. "@daily") into its 5-field equivalent.
72
+ * Returns the original string unchanged if not an alias.
73
+ */
74
+ export function _expandExpr(expr) {
75
+ if (typeof expr !== "string") return expr;
76
+ const trimmed = expr.trim();
77
+ if (trimmed.startsWith("@")) {
78
+ const alias = ALIASES[trimmed.toLowerCase()];
79
+ if (!alias) throw new Error(`unknown cron alias: ${trimmed}`);
80
+ return alias;
81
+ }
82
+ return trimmed;
83
+ }
84
+
85
+ /**
86
+ * Parse a single cron field into a Set of matching integers.
87
+ * Supports:
88
+ * * — every value in range
89
+ * N — single number
90
+ * A-B — inclusive range
91
+ * *\/N or A-B/N — step (every Nth value)
92
+ * a,b,c — comma-separated list (any combination of the above)
93
+ */
94
+ export function parseCronField(field, [min, max]) {
95
+ if (typeof field !== "string" || field.length === 0) {
96
+ throw new Error("empty cron field");
97
+ }
98
+ const values = new Set();
99
+ for (const part of field.split(",")) {
100
+ const slashIdx = part.indexOf("/");
101
+ const stepStr = slashIdx >= 0 ? part.slice(slashIdx + 1) : null;
102
+ const base = slashIdx >= 0 ? part.slice(0, slashIdx) : part;
103
+ const step = stepStr === null ? 1 : parseInt(stepStr, 10);
104
+ if (!Number.isFinite(step) || step < 1) {
105
+ throw new Error(`invalid cron step: ${part}`);
106
+ }
107
+ let lo, hi;
108
+ if (base === "*") {
109
+ lo = min;
110
+ hi = max;
111
+ } else if (base.includes("-")) {
112
+ const [a, b] = base.split("-").map((x) => parseInt(x, 10));
113
+ if (!Number.isFinite(a) || !Number.isFinite(b)) {
114
+ throw new Error(`invalid cron range: ${part}`);
115
+ }
116
+ lo = a;
117
+ hi = b;
118
+ } else {
119
+ const n = parseInt(base, 10);
120
+ if (!Number.isFinite(n)) {
121
+ throw new Error(`invalid cron value: ${part}`);
122
+ }
123
+ lo = n;
124
+ hi = n;
125
+ }
126
+ // Normalize day-of-week 7 → 0
127
+ if (max === 6 && lo === 7) lo = 0;
128
+ if (max === 6 && hi === 7) hi = 0;
129
+ if (lo < min || hi > max || lo > hi) {
130
+ throw new Error(`cron value out of range ${min}-${max}: ${part}`);
131
+ }
132
+ for (let v = lo; v <= hi; v += step) {
133
+ values.add(v);
134
+ }
135
+ }
136
+ return values;
137
+ }
138
+
139
+ /**
140
+ * Parse a 5- or 6-field cron expression (or alias). Returns a match function
141
+ * with `.hasSeconds` boolean property indicating whether the expression
142
+ * carries seconds resolution.
143
+ *
144
+ * @param {string} expr
145
+ * @returns {((date: Date) => boolean) & { hasSeconds: boolean }}
146
+ */
147
+ export function parseCron(expr) {
148
+ if (typeof expr !== "string") {
149
+ throw new Error("cron expression must be a string");
150
+ }
151
+ const expanded = _expandExpr(expr);
152
+ const parts = expanded.split(/\s+/);
153
+ if (parts.length !== 5 && parts.length !== 6) {
154
+ throw new Error(
155
+ `cron must have 5 or 6 fields (got ${parts.length}); use 6-field for seconds resolution or alias like @daily`,
156
+ );
157
+ }
158
+ const hasSeconds = parts.length === 6;
159
+ let second = null;
160
+ let minute, hour, dom, month, dow;
161
+ if (hasSeconds) {
162
+ second = parseCronField(parts[0], SECOND_RANGE);
163
+ [minute, hour, dom, month, dow] = parts
164
+ .slice(1)
165
+ .map((p, i) => parseCronField(p, FIELD_RANGES[i]));
166
+ } else {
167
+ [minute, hour, dom, month, dow] = parts.map((p, i) =>
168
+ parseCronField(p, FIELD_RANGES[i]),
169
+ );
170
+ }
171
+
172
+ // Pre-compute restriction flags for POSIX dom/dow OR-semantics
173
+ const domField = hasSeconds ? parts[3] : parts[2];
174
+ const dowField = hasSeconds ? parts[5] : parts[4];
175
+ const domRestricted = domField !== "*";
176
+ const dowRestricted = dowField !== "*";
177
+
178
+ function matches(date) {
179
+ const s = date.getSeconds();
180
+ const m = date.getMinutes();
181
+ const h = date.getHours();
182
+ const D = date.getDate();
183
+ const M = date.getMonth() + 1; // JS month is 0-based
184
+ const W = date.getDay();
185
+ if (hasSeconds && !second.has(s)) return false;
186
+ if (!minute.has(m)) return false;
187
+ if (!hour.has(h)) return false;
188
+ if (!month.has(M)) return false;
189
+ // POSIX: if both dom and dow are restricted (not *), match is OR.
190
+ if (domRestricted && dowRestricted) {
191
+ return dom.has(D) || dow.has(W);
192
+ }
193
+ if (domRestricted) return dom.has(D);
194
+ if (dowRestricted) return dow.has(W);
195
+ return true;
196
+ }
197
+ matches.hasSeconds = hasSeconds;
198
+ return matches;
199
+ }
200
+
201
+ /** Returns true if the cron expression carries seconds resolution. */
202
+ export function hasSecondsResolution(expr) {
203
+ try {
204
+ return parseCron(expr).hasSeconds;
205
+ } catch (_e) {
206
+ return false;
207
+ }
208
+ }
209
+
210
+ /** Validate a cron expression — returns null if valid, error string otherwise. */
211
+ export function validateCron(expr) {
212
+ try {
213
+ parseCron(expr);
214
+ return null;
215
+ } catch (err) {
216
+ return err.message;
217
+ }
218
+ }
219
+
220
+ // ─── Persistence ─────────────────────────────────────────────────────────────
221
+
222
+ function _scheduleFile(cwd) {
223
+ return join(cwd, ".chainlesschain", "cowork", "schedules.jsonl");
224
+ }
225
+
226
+ /** Load all schedules from disk. Returns [] if the file doesn't exist. */
227
+ export function loadSchedules(cwd) {
228
+ const file = _scheduleFile(cwd);
229
+ if (!_deps.existsSync(file)) return [];
230
+ const raw = _deps.readFileSync(file, "utf-8");
231
+ const out = [];
232
+ for (const line of raw.split("\n")) {
233
+ const trimmed = line.trim();
234
+ if (!trimmed) continue;
235
+ try {
236
+ out.push(JSON.parse(trimmed));
237
+ } catch (_e) {
238
+ // Skip malformed lines — don't let one bad record break the rest
239
+ }
240
+ }
241
+ return out;
242
+ }
243
+
244
+ /** Write the full schedule list back to disk, overwriting. */
245
+ export function saveSchedules(cwd, schedules) {
246
+ const dir = join(cwd, ".chainlesschain", "cowork");
247
+ _deps.mkdirSync(dir, { recursive: true });
248
+ const file = _scheduleFile(cwd);
249
+ const body = schedules.map((s) => JSON.stringify(s)).join("\n");
250
+ _deps.writeFileSync(file, body ? body + "\n" : "", "utf-8");
251
+ }
252
+
253
+ // ─── CRUD ────────────────────────────────────────────────────────────────────
254
+
255
+ export function addSchedule(cwd, input) {
256
+ const { cron, templateId = null, userMessage, files = [] } = input || {};
257
+ if (!userMessage || typeof userMessage !== "string") {
258
+ throw new Error("userMessage is required");
259
+ }
260
+ const err = validateCron(cron);
261
+ if (err) throw new Error(`invalid cron: ${err}`);
262
+
263
+ const schedules = loadSchedules(cwd);
264
+ const entry = {
265
+ id: `sch-${crypto.randomUUID().slice(0, 12)}`,
266
+ cron: cron.trim(),
267
+ templateId,
268
+ userMessage,
269
+ files: Array.isArray(files) ? files : [],
270
+ enabled: true,
271
+ createdAt: _deps.now().toISOString(),
272
+ lastRunAt: null,
273
+ lastStatus: null,
274
+ };
275
+ schedules.push(entry);
276
+ saveSchedules(cwd, schedules);
277
+ return entry;
278
+ }
279
+
280
+ export function removeSchedule(cwd, id) {
281
+ const schedules = loadSchedules(cwd);
282
+ const idx = schedules.findIndex((s) => s.id === id);
283
+ if (idx === -1) return false;
284
+ schedules.splice(idx, 1);
285
+ saveSchedules(cwd, schedules);
286
+ return true;
287
+ }
288
+
289
+ export function setScheduleEnabled(cwd, id, enabled) {
290
+ const schedules = loadSchedules(cwd);
291
+ const s = schedules.find((x) => x.id === id);
292
+ if (!s) return false;
293
+ s.enabled = !!enabled;
294
+ saveSchedules(cwd, schedules);
295
+ return true;
296
+ }
297
+
298
+ export function updateScheduleRunState(cwd, id, { lastRunAt, lastStatus }) {
299
+ const schedules = loadSchedules(cwd);
300
+ const s = schedules.find((x) => x.id === id);
301
+ if (!s) return false;
302
+ if (lastRunAt) s.lastRunAt = lastRunAt;
303
+ if (lastStatus) s.lastStatus = lastStatus;
304
+ saveSchedules(cwd, schedules);
305
+ return true;
306
+ }
307
+
308
+ // ─── Scheduler ───────────────────────────────────────────────────────────────
309
+
310
+ /**
311
+ * Background scheduler that checks schedules every minute and runs due ones.
312
+ * Enforces once-per-minute-per-schedule via `_firedKeys` (schedule.id + minute).
313
+ */
314
+ export class CoworkCronScheduler {
315
+ constructor(options = {}) {
316
+ this.cwd = options.cwd || process.cwd();
317
+ // If caller pins intervalMs we honor it; else auto-adapt based on schedules.
318
+ this._intervalPinned = typeof options.intervalMs === "number";
319
+ this.intervalMs = options.intervalMs || 60_000;
320
+ this.onEvent = options.onEvent || null; // (event) => void
321
+ this._timer = null;
322
+ this._firedKeys = new Set();
323
+ this._running = new Set();
324
+ }
325
+
326
+ start() {
327
+ if (this._timer) return;
328
+ this._adaptInterval(); // pick 1s vs 60s based on current schedules
329
+ this._tick(); // immediate first tick so tests don't wait
330
+ this._timer = setInterval(() => this._tick(), this.intervalMs);
331
+ this._emit({ type: "scheduler-started", intervalMs: this.intervalMs });
332
+ }
333
+
334
+ /**
335
+ * Re-evaluate desired tick rate. If any active schedule uses seconds, drop
336
+ * to 1s; else use 60s. No-op if caller pinned intervalMs.
337
+ */
338
+ _adaptInterval() {
339
+ if (this._intervalPinned) return;
340
+ let schedules = [];
341
+ try {
342
+ schedules = loadSchedules(this.cwd);
343
+ } catch (_e) {
344
+ // ignore — keep current interval
345
+ }
346
+ const wantSeconds = schedules.some(
347
+ (s) => s.enabled !== false && hasSecondsResolution(s.cron),
348
+ );
349
+ const desired = wantSeconds ? 1000 : 60_000;
350
+ if (desired !== this.intervalMs) {
351
+ this.intervalMs = desired;
352
+ if (this._timer) {
353
+ clearInterval(this._timer);
354
+ this._timer = setInterval(() => this._tick(), this.intervalMs);
355
+ this._emit({ type: "scheduler-retuned", intervalMs: this.intervalMs });
356
+ }
357
+ }
358
+ }
359
+
360
+ stop() {
361
+ if (this._timer) {
362
+ clearInterval(this._timer);
363
+ this._timer = null;
364
+ }
365
+ this._emit({ type: "scheduler-stopped" });
366
+ }
367
+
368
+ _emit(event) {
369
+ if (typeof this.onEvent === "function") {
370
+ try {
371
+ this.onEvent(event);
372
+ } catch (_e) {
373
+ // Never let observer errors break the scheduler
374
+ }
375
+ }
376
+ }
377
+
378
+ async _tick() {
379
+ const now = _deps.now();
380
+ let schedules;
381
+ try {
382
+ schedules = loadSchedules(this.cwd);
383
+ } catch (err) {
384
+ this._emit({ type: "load-error", error: err.message });
385
+ return;
386
+ }
387
+
388
+ // Re-adapt interval if schedule set changed (added/removed seconds-aware)
389
+ this._adaptInterval();
390
+
391
+ for (const s of schedules) {
392
+ if (!s.enabled) continue;
393
+ let matcher;
394
+ try {
395
+ matcher = parseCron(s.cron);
396
+ } catch (err) {
397
+ this._emit({ type: "invalid-cron", id: s.id, error: err.message });
398
+ continue;
399
+ }
400
+ const fireKey = `${s.id}:${
401
+ matcher.hasSeconds ? _secondKey(now) : _minuteKey(now)
402
+ }`;
403
+ if (this._firedKeys.has(fireKey)) continue;
404
+ if (this._running.has(s.id)) continue;
405
+ const isDue = matcher(now);
406
+ if (!isDue) continue;
407
+
408
+ this._firedKeys.add(fireKey);
409
+ this._running.add(s.id);
410
+ this._runSchedule(s).finally(() => {
411
+ this._running.delete(s.id);
412
+ });
413
+ }
414
+
415
+ // Prevent unbounded growth of _firedKeys — keep only recent minute keys
416
+ if (this._firedKeys.size > 10_000) {
417
+ this._firedKeys.clear();
418
+ }
419
+ }
420
+
421
+ async _runSchedule(schedule) {
422
+ const runTask = _deps.runTask;
423
+ if (typeof runTask !== "function") {
424
+ this._emit({
425
+ type: "run-error",
426
+ id: schedule.id,
427
+ error: "runTask not injected",
428
+ });
429
+ return;
430
+ }
431
+ this._emit({
432
+ type: "schedule-fired",
433
+ id: schedule.id,
434
+ cron: schedule.cron,
435
+ templateId: schedule.templateId,
436
+ });
437
+ try {
438
+ const result = await runTask({
439
+ templateId: schedule.templateId,
440
+ userMessage: schedule.userMessage,
441
+ files: schedule.files,
442
+ cwd: this.cwd,
443
+ });
444
+ updateScheduleRunState(this.cwd, schedule.id, {
445
+ lastRunAt: _deps.now().toISOString(),
446
+ lastStatus: result?.status || "completed",
447
+ });
448
+ this._emit({
449
+ type: "schedule-completed",
450
+ id: schedule.id,
451
+ taskId: result?.taskId,
452
+ status: result?.status,
453
+ });
454
+ } catch (err) {
455
+ updateScheduleRunState(this.cwd, schedule.id, {
456
+ lastRunAt: _deps.now().toISOString(),
457
+ lastStatus: "failed",
458
+ });
459
+ this._emit({
460
+ type: "schedule-failed",
461
+ id: schedule.id,
462
+ error: err.message,
463
+ });
464
+ }
465
+ }
466
+ }
467
+
468
+ function _minuteKey(date) {
469
+ return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}-${date.getHours()}-${date.getMinutes()}`;
470
+ }
471
+
472
+ function _secondKey(date) {
473
+ return `${_minuteKey(date)}-${date.getSeconds()}`;
474
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Cowork ↔ EvoMap adapter — publish Cowork templates as EvoMap "genes" and
3
+ * pull them back for local install. Thin wrapper over `evomap-client.js`
4
+ * that fixes `kind = "cowork-template"` and carries N4 signatures through.
5
+ *
6
+ * Pure glue: all I/O is delegated to EvoMapClient (network) and the
7
+ * marketplace/share modules (disk). Injectable via `_deps.createClient`.
8
+ *
9
+ * @module cowork-evomap-adapter
10
+ */
11
+
12
+ import { EvoMapClient } from "./evomap-client.js";
13
+ import {
14
+ toShareableTemplate,
15
+ saveUserTemplate,
16
+ } from "./cowork-template-marketplace.js";
17
+ import { buildPacket } from "./cowork-share.js";
18
+
19
+ export const _deps = {
20
+ createClient: (opts) => new EvoMapClient(opts),
21
+ };
22
+
23
+ const KIND = "cowork-template";
24
+
25
+ function _wrapGene(template, signer) {
26
+ const payload = toShareableTemplate(template);
27
+ // Reuse share-packet builder so signed genes land on the hub with the same
28
+ // canonical shape as file-based packets (checksum + optional signature).
29
+ const packet = buildPacket({
30
+ kind: "template",
31
+ payload,
32
+ author: signer?.did || "anonymous",
33
+ cliVersion: undefined,
34
+ signer: signer || undefined,
35
+ });
36
+ return {
37
+ id: payload.id,
38
+ name: payload.name || payload.id,
39
+ description: payload.description || "",
40
+ kind: KIND,
41
+ version: payload.version || "1.0.0",
42
+ packet,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Publish a template to a hub. Requires API key on the client.
48
+ * @returns {Promise<object>} hub response (typically { id, ... })
49
+ */
50
+ export async function publishTemplateToHub(
51
+ template,
52
+ { hubUrl, apiKey, signer } = {},
53
+ ) {
54
+ if (!template || !template.id) {
55
+ throw new Error("template.id required");
56
+ }
57
+ const client = _deps.createClient({ hubUrl, apiKey });
58
+ const gene = _wrapGene(template, signer);
59
+ return client.publish(gene);
60
+ }
61
+
62
+ /**
63
+ * Search templates on a hub. Degrades to [] on network error unless
64
+ * `strict: true` is passed.
65
+ * @returns {Promise<Array>} annotated with `_hubMeta`
66
+ */
67
+ export async function searchTemplatesInHub(
68
+ query,
69
+ { hubUrl, limit = 20, strict = false } = {},
70
+ ) {
71
+ const client = _deps.createClient({ hubUrl });
72
+ try {
73
+ const results = await client.search(query || "", {
74
+ category: KIND,
75
+ limit,
76
+ });
77
+ return (results || []).map((r) => ({
78
+ ...r,
79
+ _hubMeta: {
80
+ hubUrl: client.hubUrl,
81
+ downloads: r.downloads || 0,
82
+ rating: r.rating || null,
83
+ },
84
+ }));
85
+ } catch (err) {
86
+ if (strict) throw err;
87
+ return [];
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Fetch a gene by id and install its template into the local marketplace.
93
+ * Returns the saved template object.
94
+ */
95
+ export async function installTemplateFromHub(
96
+ cwd,
97
+ geneId,
98
+ { hubUrl, requireSigned = false, trustedDids = null } = {},
99
+ ) {
100
+ const client = _deps.createClient({ hubUrl });
101
+ const data = await client.download(geneId);
102
+ // Hub may return { gene: { packet } } or { packet } directly
103
+ const gene = data?.gene || data;
104
+ const packet = gene?.packet || data?.packet;
105
+ if (!packet || packet.kind !== "template" || !packet.payload) {
106
+ throw new Error("Hub response missing template packet");
107
+ }
108
+ if (requireSigned && !packet.signature) {
109
+ throw new Error("Gene is not signed and --require-signed was set");
110
+ }
111
+ if (
112
+ Array.isArray(trustedDids) &&
113
+ trustedDids.length > 0 &&
114
+ (!packet.signature || !trustedDids.includes(packet.signature.did))
115
+ ) {
116
+ throw new Error(
117
+ `Gene signer not in trusted list${packet.signature ? ` (${packet.signature.did})` : ""}`,
118
+ );
119
+ }
120
+ return saveUserTemplate(cwd, packet.payload);
121
+ }