@getbourdon/participants 0.2.3 → 0.2.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.
Files changed (43) hide show
  1. package/dist/base.d.ts +109 -0
  2. package/dist/base.js +88 -0
  3. package/dist/index.d.ts +42 -1083
  4. package/dist/index.js +53 -6590
  5. package/dist/network-base.d.ts +108 -0
  6. package/dist/network-base.js +248 -0
  7. package/dist/readers/cascade.d.ts +49 -0
  8. package/dist/readers/cascade.js +428 -0
  9. package/dist/readers/claude-code-automations.d.ts +62 -0
  10. package/dist/readers/claude-code-automations.js +693 -0
  11. package/dist/readers/claude-code.d.ts +50 -0
  12. package/dist/readers/claude-code.js +583 -0
  13. package/dist/readers/claude-desktop-code.d.ts +54 -0
  14. package/dist/readers/claude-desktop-code.js +526 -0
  15. package/dist/readers/claude-desktop-cowork.d.ts +47 -0
  16. package/dist/readers/claude-desktop-cowork.js +659 -0
  17. package/dist/readers/codex-automations.d.ts +55 -0
  18. package/dist/readers/codex-automations.js +519 -0
  19. package/dist/readers/codex-sessions.d.ts +88 -0
  20. package/dist/readers/codex-sessions.js +194 -0
  21. package/dist/readers/copilot-automations.d.ts +64 -0
  22. package/dist/readers/copilot-automations.js +530 -0
  23. package/dist/readers/copilot-cli.d.ts +45 -0
  24. package/dist/readers/copilot-cli.js +416 -0
  25. package/dist/readers/copilot-vscode.d.ts +54 -0
  26. package/dist/readers/copilot-vscode.js +553 -0
  27. package/dist/readers/copilot.d.ts +59 -0
  28. package/dist/readers/copilot.js +413 -0
  29. package/dist/readers/cursor-automations.d.ts +57 -0
  30. package/dist/readers/cursor-automations.js +685 -0
  31. package/dist/readers/cursor.d.ts +50 -0
  32. package/dist/readers/cursor.js +670 -0
  33. package/dist/readers/github-copilot.d.ts +34 -0
  34. package/dist/readers/github-copilot.js +242 -0
  35. package/dist/readers/hermes.d.ts +37 -0
  36. package/dist/readers/hermes.js +426 -0
  37. package/dist/readers/openclaw.d.ts +78 -0
  38. package/dist/readers/openclaw.js +386 -0
  39. package/dist/registry.d.ts +39 -0
  40. package/dist/registry.js +98 -0
  41. package/dist/sqlite-base.d.ts +65 -0
  42. package/dist/sqlite-base.js +163 -0
  43. package/package.json +6 -6
@@ -0,0 +1,685 @@
1
+ /**
2
+ * Cursor automations participant — publish background agent run memory as L5
3
+ * evidence. Port of `participants/cursor_automations.py`.
4
+ *
5
+ * Reads the `~/.cursor/automations/<id>/` CONVENTION (file-based, no SQLite):
6
+ *
7
+ * automation.toml — id, name, status, schedule (rrule), kind, cwds
8
+ * memory.md — dated bullet entries, one block per run
9
+ *
10
+ * Each `automation.toml` becomes a known Entity; each dated section of
11
+ * `memory.md` becomes a recent Session. This covers the federation gap the
12
+ * interactive-only `cursor` participant leaves behind: Cursor Cloud Agent
13
+ * background tasks whose work never touches the interactive SQLite state.
14
+ *
15
+ * Path resolution (default dir):
16
+ * 1. explicit `cursorHome` ctor arg → `<cursorHome>/automations`
17
+ * 2. `CURSOR_DIR` or `CURSOR_HOME` env → `<env>/automations`
18
+ * 3. `~/.cursor/automations`
19
+ *
20
+ * Redaction: every native action string passes through the redaction SSOT
21
+ * (`redactText(v, 180)` == Python `_safe_native_memory_text`). Visibility policy
22
+ * filters PRIVATE entities BEFORE emission. `healthCheck` never throws.
23
+ */
24
+ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
25
+ import { homedir, hostname } from "node:os";
26
+ import { dirname, join } from "node:path";
27
+ import { parse as parseToml } from "smol-toml";
28
+ import { Visibility, filterForFederation, makeAgentInfo, makeEntity, makeManifest, makeSession, makeVisibilityPolicy, } from "@getbourdon/l5";
29
+ import { redactText } from "@getbourdon/redaction";
30
+ import { ParticipantDiscoveryError, SPEC_VERSION, loadProjectHints, } from "../base.js";
31
+ // -- Constants -----------------------------------------------------------------
32
+ const AGENT_ID = "cursor-automations";
33
+ const AGENT_TYPE = "other";
34
+ const ROLE_NARRATIVE = "Publishes read-only Cursor Cloud Agent automation run memory into Bourdon " +
35
+ "so background PR reviews, code generation tasks, and scheduled agent runs " +
36
+ "become visible alongside interactive Cursor IDE sessions.";
37
+ const DEFAULT_POLICY = makeVisibilityPolicy({
38
+ default: Visibility.TEAM,
39
+ private_tags: ["personal", "financial", "credential", "health", "family", "legal"],
40
+ team_tags: ["cursor-automation", "automation", "workspace"],
41
+ });
42
+ const AUTOMATIONS_DIR_NAME = "automations";
43
+ const AUTOMATION_TOML = "automation.toml";
44
+ const MEMORY_MD = "memory.md";
45
+ const MAX_MEMORY_CHARS = 160_000;
46
+ const MAX_KEY_ACTIONS_PER_RUN = 6;
47
+ const MAX_KEY_ACTION_CHARS = 280;
48
+ /** `^(YYYY-MM-DD)(?:\b|$)(rest)$` — matches a run-header line (post `.strip()`). */
49
+ const RUN_HEADER_RE = /^(\d{4}-\d{2}-\d{2})(?:\b|$)(.*)$/;
50
+ /** Chars stripped from a run-header suffix (space, hyphen, colon, em-dash). */
51
+ const HEADER_SUFFIX_STRIP = " -:—";
52
+ // Product-neutral defaults only: this product plus the agent surfaces Bourdon
53
+ // federates. A deployment adds its own project roster via the
54
+ // `BOURDON_PROJECT_HINTS` env var (comma-separated) — see `loadProjectHints`.
55
+ const DEFAULT_PROJECT_HINTS = [
56
+ "Bourdon",
57
+ "Cursor",
58
+ "Copilot",
59
+ "Codex",
60
+ "Cascade",
61
+ ];
62
+ const SIGNAL_PATTERNS = [
63
+ ["human-dashboard-action", /\b(human|dashboard|manual)\b/i],
64
+ ["release-gate", /\b(release|store|app review|play console|testflight)\b/i],
65
+ ["billing-drift", /\b(billing|stripe|revenuecat|iap|subscription)\b/i],
66
+ ["memory-coverage-gap", /\b(memory|l5|manifest|federated|bourdon)\b/i],
67
+ ["launch-decision", /\b(launch|go-live|pricing|prod|production)\b/i],
68
+ ["ci-signal", /\b(github action|workflow run|ci|cd|deploy|pipeline)\b/i],
69
+ ];
70
+ /** `^[-*]\s+(rest)$` — a markdown bullet line (used by the merge path). */
71
+ const BULLET_RE = /^[-*]\s+(.*)$/;
72
+ /** `^[A-Za-z0-9._-]+$` — a filesystem-safe automation id (merge validation). */
73
+ const VALID_ID_RE = /^[A-Za-z0-9._-]+$/;
74
+ // -- Minimal logger ------------------------------------------------------------
75
+ const logger = {
76
+ warn(msg) {
77
+ // eslint-disable-next-line no-console
78
+ console.warn(`[bourdon.participants] ${msg}`);
79
+ },
80
+ };
81
+ // -- fs helpers ----------------------------------------------------------------
82
+ function homeDir() {
83
+ // os.homedir() can throw under some sandboxes; stay defensive so import +
84
+ // construction never crash even when HOME points at an unreadable path.
85
+ try {
86
+ return homedir();
87
+ }
88
+ catch {
89
+ return process.env.HOME || process.env.USERPROFILE || ".";
90
+ }
91
+ }
92
+ function isDir(p) {
93
+ try {
94
+ return statSync(p).isDirectory();
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ }
100
+ function isFile(p) {
101
+ try {
102
+ return statSync(p).isFile();
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ // -- Python-idiom helpers ------------------------------------------------------
109
+ /** Python truthiness for the scalar types read here (str / int / None). */
110
+ function pyFalsy(v) {
111
+ return v === undefined || v === null || v === "" || v === 0 || v === false;
112
+ }
113
+ /** Mirror Python `str(raw.get(key) or fallback)`. */
114
+ function strOr(v, fallback) {
115
+ return String(pyFalsy(v) ? fallback : v);
116
+ }
117
+ /** Mirror Python `str.strip(chars)` — strip a fixed char SET from both ends. */
118
+ function stripChars(s, chars) {
119
+ let start = 0;
120
+ let end = s.length;
121
+ while (start < end && chars.includes(s[start]))
122
+ start++;
123
+ while (end > start && chars.includes(s[end - 1]))
124
+ end--;
125
+ return s.slice(start, end);
126
+ }
127
+ /** Mirror Python `str.rstrip()` (trailing whitespace only). */
128
+ function rstrip(s) {
129
+ return s.replace(/\s+$/, "");
130
+ }
131
+ /**
132
+ * Mirror Python `_bounded`: collapse all whitespace to single spaces, trim, and
133
+ * cap at `limit` with a trailing ellipsis. NOTE the exact truncation form —
134
+ * `normalized[: limit - 1].rstrip() + "..."` — which is DISTINCT from
135
+ * `redactText`'s `limit - 3` tail. Do not conflate the two.
136
+ */
137
+ function bounded(value, limit) {
138
+ const trimmed = value.trim();
139
+ const normalized = trimmed === "" ? "" : trimmed.split(/\s+/).join(" ");
140
+ if (normalized.length <= limit)
141
+ return normalized;
142
+ return rstrip(normalized.slice(0, limit - 1)) + "...";
143
+ }
144
+ /** `_safe_native_memory_text(v)` == `redactText(v, 180)` (positional). */
145
+ function safeNativeMemoryText(value) {
146
+ return redactText(value, 180);
147
+ }
148
+ // -- Path helpers --------------------------------------------------------------
149
+ /** Return the default Cursor automations directory. */
150
+ export function defaultCursorAutomationsDir(cursorHome) {
151
+ if (cursorHome !== undefined && cursorHome !== null) {
152
+ return join(cursorHome, AUTOMATIONS_DIR_NAME);
153
+ }
154
+ const env = process.env.CURSOR_DIR || process.env.CURSOR_HOME;
155
+ if (env)
156
+ return join(env, AUTOMATIONS_DIR_NAME);
157
+ return join(homeDir(), ".cursor", AUTOMATIONS_DIR_NAME);
158
+ }
159
+ // -- TOML + memory parsing -----------------------------------------------------
160
+ function readAutomationToml(path) {
161
+ let text;
162
+ try {
163
+ text = readFileSync(path, "utf8");
164
+ }
165
+ catch (exc) {
166
+ logger.warn(`CursorAutomationsParticipant: cannot parse ${path}: ${String(exc)}`);
167
+ return {};
168
+ }
169
+ try {
170
+ const parsed = parseToml(text);
171
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
172
+ ? parsed
173
+ : {};
174
+ }
175
+ catch (exc) {
176
+ logger.warn(`CursorAutomationsParticipant: cannot parse ${path}: ${String(exc)}`);
177
+ return {};
178
+ }
179
+ }
180
+ function buildConfig(tomlPath) {
181
+ const raw = readAutomationToml(tomlPath);
182
+ const parentName = basenameOf(dirname(tomlPath));
183
+ const automationId = strOr(raw["id"], parentName).trim();
184
+ if (!automationId)
185
+ return null;
186
+ const name = strOr(raw["name"], automationId).trim();
187
+ const status = strOr(raw["status"], "UNKNOWN").trim().toUpperCase();
188
+ const rrule = strOr(raw["rrule"], "").trim();
189
+ const kind = strOr(raw["kind"], "").trim();
190
+ const cwdsRaw = raw["cwds"];
191
+ const cwds = [];
192
+ if (Array.isArray(cwdsRaw)) {
193
+ for (const cwd of cwdsRaw) {
194
+ if (typeof cwd === "string" && cwd.trim())
195
+ cwds.push(cwd);
196
+ }
197
+ }
198
+ else if (typeof cwdsRaw === "string") {
199
+ for (const ch of cwdsRaw) {
200
+ if (ch.trim())
201
+ cwds.push(ch);
202
+ }
203
+ }
204
+ const memoryPath = join(dirname(tomlPath), MEMORY_MD);
205
+ return {
206
+ automationId,
207
+ name,
208
+ status,
209
+ rrule,
210
+ kind,
211
+ cwds,
212
+ path: tomlPath,
213
+ memoryPath: isFile(memoryPath) ? memoryPath : null,
214
+ };
215
+ }
216
+ /** `os.path.basename` on a (possibly trailing-slash-free) dir path. */
217
+ function basenameOf(p) {
218
+ const parts = p.split(/[\\/]+/).filter((x) => x !== "");
219
+ return parts.length ? parts[parts.length - 1] : "";
220
+ }
221
+ /**
222
+ * Enumerate `<dir>/*​/automation.toml`, sorted by full path. Mirrors Python's
223
+ * `sorted(dir.glob("*​/automation.toml"))` — pathlib's `*` does NOT match names
224
+ * beginning with a dot, so dot-prefixed automation dirs are skipped.
225
+ */
226
+ function iterConfigs(automationsDir) {
227
+ const configs = [];
228
+ if (!isDir(automationsDir))
229
+ return configs;
230
+ let names;
231
+ try {
232
+ names = readdirSync(automationsDir);
233
+ }
234
+ catch {
235
+ return configs;
236
+ }
237
+ const tomlPaths = [];
238
+ for (const name of names) {
239
+ // pathlib `Path.glob("*")` MATCHES dot-prefixed dirs — do NOT skip them.
240
+ const tomlPath = join(automationsDir, name, AUTOMATION_TOML);
241
+ if (isFile(tomlPath))
242
+ tomlPaths.push(tomlPath);
243
+ }
244
+ tomlPaths.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
245
+ for (const tomlPath of tomlPaths) {
246
+ const config = buildConfig(tomlPath);
247
+ if (config !== null)
248
+ configs.push(config);
249
+ }
250
+ return configs;
251
+ }
252
+ function readMemoryText(path) {
253
+ if (path === null)
254
+ return "";
255
+ let text;
256
+ try {
257
+ text = readFileSync(path, "utf8");
258
+ }
259
+ catch (exc) {
260
+ logger.warn(`CursorAutomationsParticipant: cannot read ${path}: ${String(exc)}`);
261
+ return "";
262
+ }
263
+ return text.slice(-MAX_MEMORY_CHARS);
264
+ }
265
+ function extractMemoryRuns(config) {
266
+ const text = readMemoryText(config.memoryPath);
267
+ if (!text)
268
+ return [];
269
+ const chunks = [];
270
+ let currentDate = "";
271
+ let currentLines = [];
272
+ for (const rawLine of text.split(/\r?\n/)) {
273
+ const line = rstrip(rawLine);
274
+ const match = RUN_HEADER_RE.exec(line.trim());
275
+ if (match) {
276
+ if (currentDate)
277
+ chunks.push([currentDate, currentLines]);
278
+ currentDate = match[1];
279
+ const suffix = stripChars(match[2].trim(), HEADER_SUFFIX_STRIP);
280
+ currentLines = suffix ? [suffix] : [];
281
+ continue;
282
+ }
283
+ if (currentDate)
284
+ currentLines.push(line);
285
+ }
286
+ if (currentDate)
287
+ chunks.push([currentDate, currentLines]);
288
+ const runs = [];
289
+ for (const [runDate, lines] of chunks) {
290
+ const actions = actionsFromLines(lines);
291
+ if (actions.length === 0)
292
+ continue;
293
+ const body = actions.join(" ");
294
+ const title = titleFromActions(config, actions);
295
+ runs.push({
296
+ automation: config,
297
+ date: runDate,
298
+ title,
299
+ keyActions: actions,
300
+ projects: inferProjects(body),
301
+ signals: inferSignals(body),
302
+ });
303
+ }
304
+ return runs;
305
+ }
306
+ function actionsFromLines(lines) {
307
+ const actions = [];
308
+ for (const line of lines) {
309
+ let cleaned = line.trim();
310
+ if (!cleaned)
311
+ continue;
312
+ cleaned = (cleaned.startsWith("- ") ? cleaned.slice(2) : cleaned).trim();
313
+ const lowered = cleaned.toLowerCase();
314
+ if (!cleaned || lowered.startsWith("runtime"))
315
+ continue;
316
+ if (lowered === "first run" || lowered === "follow-up")
317
+ continue;
318
+ if (lowered.startsWith("run:"))
319
+ cleaned = cleaned.slice(4).trim();
320
+ const safe = bounded(safeNativeMemoryText(cleaned), MAX_KEY_ACTION_CHARS);
321
+ if (safe && !actions.includes(safe))
322
+ actions.push(safe);
323
+ if (actions.length >= MAX_KEY_ACTIONS_PER_RUN)
324
+ break;
325
+ }
326
+ return actions;
327
+ }
328
+ function titleFromActions(config, actions) {
329
+ if (actions.length === 0)
330
+ return config.name;
331
+ const first = actions[0];
332
+ const prefix = `${config.name}: `;
333
+ if (first.startsWith(prefix))
334
+ return bounded(first, 120);
335
+ return bounded(prefix + first, 120);
336
+ }
337
+ function inferProjects(text) {
338
+ const projects = [];
339
+ const lowered = text.toLowerCase();
340
+ const seen = new Set();
341
+ for (const project of loadProjectHints(DEFAULT_PROJECT_HINTS)) {
342
+ const key = project.toLowerCase();
343
+ if (lowered.includes(key) && !seen.has(key)) {
344
+ projects.push(project);
345
+ seen.add(key);
346
+ }
347
+ }
348
+ return projects;
349
+ }
350
+ function inferSignals(text) {
351
+ const signals = [];
352
+ for (const [name, pattern] of SIGNAL_PATTERNS) {
353
+ if (pattern.test(text))
354
+ signals.push(name);
355
+ }
356
+ return signals;
357
+ }
358
+ // -- Merge / ingest ------------------------------------------------------------
359
+ /** Split memory.md text into (date_header, list_of_bullets) sections. */
360
+ function parseMemorySections(text) {
361
+ const sections = [];
362
+ let currentDate = "";
363
+ let currentBullets = [];
364
+ for (const rawLine of text.split(/\r?\n/)) {
365
+ const line = rstrip(rawLine);
366
+ const match = RUN_HEADER_RE.exec(line.trim());
367
+ if (match) {
368
+ if (currentDate)
369
+ sections.push([currentDate, currentBullets]);
370
+ currentDate = match[1];
371
+ const suffix = stripChars(match[2].trim(), HEADER_SUFFIX_STRIP);
372
+ currentBullets = [];
373
+ if (suffix)
374
+ currentBullets.push(suffix);
375
+ continue;
376
+ }
377
+ if (!currentDate)
378
+ continue;
379
+ const bulletMatch = BULLET_RE.exec(line.trim());
380
+ if (bulletMatch)
381
+ currentBullets.push(bulletMatch[1].trim());
382
+ }
383
+ if (currentDate)
384
+ sections.push([currentDate, currentBullets]);
385
+ return sections;
386
+ }
387
+ /** Render a list of (date, bullets) back to memory.md form. */
388
+ function serializeSections(sections) {
389
+ const blocks = [];
390
+ for (const [dateStr, bullets] of sections) {
391
+ const lines = [dateStr, ...bullets.map((b) => `- ${b}`)];
392
+ blocks.push(lines.join("\n"));
393
+ }
394
+ return blocks.join("\n\n") + "\n";
395
+ }
396
+ /**
397
+ * Merge an automations tree from `sourceDir` into `destDir`. Idempotent —
398
+ * calling twice on the same source is a no-op. Throws when `sourceDir` is not a
399
+ * directory (Python `FileNotFoundError`).
400
+ */
401
+ export function mergeAutomationTree(sourceDir, destDir, defaultKind = "cursor-cloud-agent") {
402
+ if (!isDir(sourceDir)) {
403
+ throw new Error(`merge source not found: ${sourceDir}`);
404
+ }
405
+ mkdirSync(destDir, { recursive: true });
406
+ let seen = 0;
407
+ let created = 0;
408
+ let bulletsAdded = 0;
409
+ let sectionsCreated = 0;
410
+ const skipped = [];
411
+ const entries = readdirSync(sourceDir).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
412
+ for (const entryName of entries) {
413
+ const srcIdDir = join(sourceDir, entryName);
414
+ if (!isDir(srcIdDir))
415
+ continue;
416
+ seen += 1;
417
+ const automationId = entryName;
418
+ if (!VALID_ID_RE.test(automationId)) {
419
+ skipped.push(automationId);
420
+ continue;
421
+ }
422
+ const destIdDir = join(destDir, automationId);
423
+ const srcToml = join(srcIdDir, AUTOMATION_TOML);
424
+ const srcMemory = join(srcIdDir, MEMORY_MD);
425
+ if (!existsPath(destIdDir)) {
426
+ mkdirSync(destIdDir, { recursive: true });
427
+ created += 1;
428
+ if (isFile(srcToml)) {
429
+ writeFileSync(join(destIdDir, AUTOMATION_TOML), readFileSync(srcToml, "utf8"), "utf8");
430
+ }
431
+ else {
432
+ writeFileSync(join(destIdDir, AUTOMATION_TOML), `version = 1\nid = "${automationId}"\n` +
433
+ `name = "${automationId}"\nstatus = "ACTIVE"\n` +
434
+ `kind = "${defaultKind}"\nrrule = ""\ncwds = []\n`, "utf8");
435
+ }
436
+ }
437
+ if (!isFile(srcMemory))
438
+ continue;
439
+ const srcSections = parseMemorySections(readFileSync(srcMemory, "utf8"));
440
+ const destMemory = join(destIdDir, MEMORY_MD);
441
+ const destSections = isFile(destMemory)
442
+ ? parseMemorySections(readFileSync(destMemory, "utf8"))
443
+ : [];
444
+ // Map date -> bullets list (SAME array refs as destSections; last wins on
445
+ // duplicate dates, matching Python `dict(dest_sections)`).
446
+ const destByDate = new Map();
447
+ for (const [d, b] of destSections)
448
+ destByDate.set(d, b);
449
+ for (const [dateStr, bullets] of srcSections) {
450
+ if (!destByDate.has(dateStr)) {
451
+ const fresh = [];
452
+ destByDate.set(dateStr, fresh);
453
+ destSections.push([dateStr, fresh]);
454
+ sectionsCreated += 1;
455
+ }
456
+ const existing = destByDate.get(dateStr);
457
+ for (const bullet of bullets) {
458
+ if (!existing.includes(bullet)) {
459
+ existing.push(bullet);
460
+ bulletsAdded += 1;
461
+ }
462
+ }
463
+ }
464
+ destSections.sort((x, y) => (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0));
465
+ writeFileSync(destMemory, serializeSections(destSections), "utf8");
466
+ }
467
+ return {
468
+ automationsSeen: seen,
469
+ automationsCreated: created,
470
+ bulletsAdded,
471
+ sectionsCreated,
472
+ skipped,
473
+ };
474
+ }
475
+ function existsPath(p) {
476
+ try {
477
+ statSync(p);
478
+ return true;
479
+ }
480
+ catch {
481
+ return false;
482
+ }
483
+ }
484
+ // -- Init helper ---------------------------------------------------------------
485
+ const INIT_TOML_TEMPLATE = `id = "{automation_id}"
486
+ name = "{automation_id}"
487
+ status = "ACTIVE"
488
+ rrule = ""
489
+ kind = ""
490
+ cwds = []
491
+ `;
492
+ const INIT_MEMORY_TEMPLATE = `# {automation_id}
493
+ #
494
+ # Append dated sections below. Each YYYY-MM-DD header starts a new run;
495
+ # bullets under it become key_actions in the L5 manifest.
496
+ #
497
+ # Example:
498
+ # 2026-06-01
499
+ # - Reviewed open PRs in Bourdon and Northwind.
500
+ # - No critical issues found.
501
+ `;
502
+ /** Create a starter automation directory with toml + memory.md. */
503
+ export function initAutomationsDir(automationsDir, automationId = "cursor-cloud-agent", force = false) {
504
+ const base = automationsDir || defaultCursorAutomationsDir();
505
+ const target = join(base, automationId);
506
+ if (existsPath(target) && !force) {
507
+ throw new Error(`${target} already exists. Pass --force to overwrite.`);
508
+ }
509
+ mkdirSync(target, { recursive: true });
510
+ writeFileSync(join(target, AUTOMATION_TOML), INIT_TOML_TEMPLATE.split("{automation_id}").join(automationId), "utf8");
511
+ writeFileSync(join(target, MEMORY_MD), INIT_MEMORY_TEMPLATE.split("{automation_id}").join(automationId), "utf8");
512
+ return target;
513
+ }
514
+ // -- L5 helpers ----------------------------------------------------------------
515
+ function sessionFromRun(run) {
516
+ const config = run.automation;
517
+ const keyActions = [
518
+ `automation_id: ${config.automationId}`,
519
+ `run: ${run.title}`,
520
+ ...run.keyActions,
521
+ ];
522
+ const filesTouched = [config.path];
523
+ if (config.memoryPath !== null)
524
+ filesTouched.push(config.memoryPath);
525
+ return makeSession({
526
+ date: run.date,
527
+ cwd: config.cwds.length ? config.cwds[0] : dirname(config.path),
528
+ project_focus: [...run.projects],
529
+ key_actions: keyActions.slice(0, MAX_KEY_ACTIONS_PER_RUN + 2),
530
+ files_touched: filesTouched,
531
+ visibility: Visibility.TEAM,
532
+ });
533
+ }
534
+ function entitiesFromConfigsAndRuns(configs, runs) {
535
+ const entitiesByName = new Map();
536
+ for (const config of configs) {
537
+ entitiesByName.set(config.automationId, makeEntity({
538
+ name: config.automationId,
539
+ type: "automation",
540
+ summary: bounded(`Cursor automation '${config.name}' (${config.status}). ` +
541
+ `Schedule: ${config.rrule || "unspecified"}.`, 260),
542
+ last_touched: null,
543
+ tags: ["cursor-automation", "automation", config.status.toLowerCase()],
544
+ visibility: Visibility.TEAM,
545
+ }));
546
+ }
547
+ for (const run of runs) {
548
+ for (const project of run.projects) {
549
+ if (!entitiesByName.has(project)) {
550
+ entitiesByName.set(project, makeEntity({
551
+ name: project,
552
+ type: "project",
553
+ summary: "Project mentioned by Cursor automation run memory.",
554
+ last_touched: run.date,
555
+ tags: ["cursor-automation", "automation-evidence"],
556
+ visibility: Visibility.TEAM,
557
+ }));
558
+ }
559
+ }
560
+ for (const signal of run.signals) {
561
+ if (!entitiesByName.has(signal)) {
562
+ entitiesByName.set(signal, makeEntity({
563
+ name: signal,
564
+ type: "automation-signal",
565
+ summary: "Signal class inferred from Cursor automation run memory.",
566
+ last_touched: run.date,
567
+ tags: ["cursor-automation", "automation-signal"],
568
+ visibility: Visibility.TEAM,
569
+ }));
570
+ }
571
+ }
572
+ }
573
+ return [...entitiesByName.values()];
574
+ }
575
+ /** `datetime.now(timezone.utc).isoformat(timespec="seconds")` form. */
576
+ function nowIsoSeconds() {
577
+ return new Date().toISOString().replace(/\.\d+Z$/, "+00:00");
578
+ }
579
+ // -- Participant ---------------------------------------------------------------
580
+ /** External participant for Cursor Cloud Agent automation memory artifacts. */
581
+ export class CursorAutomationsParticipant {
582
+ agentId = AGENT_ID;
583
+ agentType = AGENT_TYPE;
584
+ automationsDir;
585
+ policy;
586
+ constructor(automationsDir, cursorHome) {
587
+ this.automationsDir =
588
+ automationsDir || defaultCursorAutomationsDir(cursorHome ?? undefined);
589
+ this.policy = DEFAULT_POLICY;
590
+ }
591
+ get nativePath() {
592
+ return this.automationsDir;
593
+ }
594
+ discover() {
595
+ if (!isDir(this.automationsDir)) {
596
+ throw new ParticipantDiscoveryError(`Cursor automations directory not found at ${this.automationsDir}.`);
597
+ }
598
+ const configs = iterConfigs(this.automationsDir);
599
+ return {
600
+ path: this.automationsDir,
601
+ version: "unknown",
602
+ metadata: {
603
+ automations: configs.length,
604
+ with_memory: configs.filter((c) => c.memoryPath !== null).length,
605
+ },
606
+ };
607
+ }
608
+ exportSessions(since, limit = 100) {
609
+ const sessions = this.runs(undefined, since).map(sessionFromRun);
610
+ sessions.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
611
+ return sessions.slice(0, limit);
612
+ }
613
+ exportL5(since) {
614
+ const configs = iterConfigs(this.automationsDir);
615
+ const runs = this.runs(configs, since);
616
+ const sessions = runs.map(sessionFromRun);
617
+ sessions.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
618
+ const entities = entitiesFromConfigsAndRuns(configs, runs);
619
+ const visibleEntities = filterForFederation(entities, this.policy);
620
+ return makeManifest({
621
+ spec_version: SPEC_VERSION,
622
+ agent: makeAgentInfo({
623
+ id: AGENT_ID,
624
+ type: AGENT_TYPE,
625
+ instance: hostname(),
626
+ spec_version_compat: SPEC_VERSION,
627
+ role_narrative: ROLE_NARRATIVE,
628
+ }),
629
+ last_updated: nowIsoSeconds(),
630
+ capabilities: ["cursor-automation-memory", "run-summary-publication"],
631
+ recent_sessions: sessions,
632
+ known_entities: visibleEntities,
633
+ visibility_policy: this.policy,
634
+ });
635
+ }
636
+ healthCheck() {
637
+ if (!isDir(this.automationsDir)) {
638
+ return {
639
+ status: "blocked",
640
+ reason: `Cursor automations directory not found at ${this.automationsDir}.`,
641
+ details: { automations_dir: this.automationsDir },
642
+ proposedFix: "Create Cursor automations or pass --automations-dir.",
643
+ };
644
+ }
645
+ const configs = iterConfigs(this.automationsDir);
646
+ const runs = this.runs(configs);
647
+ const hasConfigs = configs.length > 0;
648
+ const details = {
649
+ automations_dir: this.automationsDir,
650
+ automation_count: configs.length,
651
+ memory_files: configs.filter((c) => c.memoryPath !== null).length,
652
+ runs_extracted: runs.length,
653
+ active_automations: configs.filter((c) => c.status === "ACTIVE").length,
654
+ };
655
+ if (hasConfigs) {
656
+ return { status: "ok", details };
657
+ }
658
+ return {
659
+ status: "degraded",
660
+ reason: "No automation.toml files found.",
661
+ details,
662
+ proposedFix: "Add Cursor automation.toml files.",
663
+ };
664
+ }
665
+ runs(configs, since) {
666
+ const runCutoff = since ? since.toISOString().slice(0, 10) : null;
667
+ const runs = [];
668
+ for (const config of configs ?? iterConfigs(this.automationsDir)) {
669
+ for (const run of extractMemoryRuns(config)) {
670
+ if (runCutoff && run.date < runCutoff)
671
+ continue;
672
+ runs.push(run);
673
+ }
674
+ }
675
+ // Sort by (date, automationId) DESCENDING (Python reverse=True on the tuple).
676
+ runs.sort((a, b) => {
677
+ if (a.date !== b.date)
678
+ return a.date > b.date ? -1 : 1;
679
+ const ai = a.automation.automationId;
680
+ const bi = b.automation.automationId;
681
+ return ai > bi ? -1 : ai < bi ? 1 : 0;
682
+ });
683
+ return runs;
684
+ }
685
+ }