@joenandez/academy 0.4.0 → 0.5.12

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 (121) hide show
  1. package/.claude-plugin/marketplace.json +5 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +390 -0
  4. package/README.md +67 -122
  5. package/hooks/backstory/.claude-plugin/plugin.json +11 -0
  6. package/hooks/backstory/SOURCE +2 -0
  7. package/hooks/backstory/bin/backstory +9 -0
  8. package/hooks/backstory/hooks/hooks.json +45 -0
  9. package/hooks/backstory/scripts/knowledge.mjs +87 -0
  10. package/hooks/backstory/scripts/memory.mjs +685 -0
  11. package/hooks/backstory/scripts/tool-tick.mjs +1 -0
  12. package/hooks/backstory/skills/capture/SKILL.md +71 -0
  13. package/hooks/backstory/skills/capture/references/knowledge-capture-input.json +16 -0
  14. package/hooks/backstory/skills/capture/references/tagging-policy.md +29 -0
  15. package/hooks/backstory/skills/memory/SKILL.md +70 -0
  16. package/hooks/backstory/skills/setup/SKILL.md +85 -0
  17. package/hooks/context_metrics.mjs +67 -0
  18. package/hooks/hook_runtime.mjs +43 -2
  19. package/hooks/memory_bridge.mjs +286 -52
  20. package/hooks/memory_store.mjs +78 -26
  21. package/hooks/register_session.mjs +76 -14
  22. package/hooks/roque/.claude-plugin/plugin.json +6 -0
  23. package/hooks/roque/LICENSE +21 -0
  24. package/hooks/roque/SOURCE +1 -0
  25. package/hooks/roque/hooks.json +38 -0
  26. package/hooks/roque/package.json +3 -0
  27. package/hooks/roque/roque.js +1690 -0
  28. package/hooks/sync_memory.mjs +11 -7
  29. package/package.json +7 -3
  30. package/scripts/cli/agent-gate.mjs +70 -0
  31. package/scripts/cli/agent-id.mjs +37 -0
  32. package/scripts/cli/archive.mjs +39 -38
  33. package/scripts/cli/archived.mjs +4 -2
  34. package/scripts/cli/args-kinds.mjs +33 -0
  35. package/scripts/cli/args-notes.mjs +42 -0
  36. package/scripts/cli/args-owner.mjs +37 -0
  37. package/scripts/cli/args-run.mjs +38 -0
  38. package/scripts/cli/args.mjs +47 -104
  39. package/scripts/cli/beads.mjs +57 -0
  40. package/scripts/cli/codex.mjs +66 -12
  41. package/scripts/cli/core.mjs +117 -41
  42. package/scripts/cli/create.mjs +224 -76
  43. package/scripts/cli/curator-feed-codex.mjs +81 -0
  44. package/scripts/cli/curator-feed-observations.mjs +97 -0
  45. package/scripts/cli/curator-feed-scope.mjs +124 -0
  46. package/scripts/cli/curator-feed-sources.mjs +172 -0
  47. package/scripts/cli/curator-feed.mjs +318 -0
  48. package/scripts/cli/default-name.mjs +73 -0
  49. package/scripts/cli/doctor.mjs +67 -2
  50. package/scripts/cli/eventlog.mjs +6 -2
  51. package/scripts/cli/exit-code.mjs +9 -0
  52. package/scripts/cli/first-run.mjs +115 -0
  53. package/scripts/cli/heartbeat.mjs +135 -0
  54. package/scripts/cli/helm-skip.mjs +20 -0
  55. package/scripts/cli/hire-headless.mjs +37 -22
  56. package/scripts/cli/hire.mjs +8 -2
  57. package/scripts/cli/inspect.mjs +36 -18
  58. package/scripts/cli/lifecycle.mjs +95 -49
  59. package/scripts/cli/main.mjs +32 -2
  60. package/scripts/cli/memory-env.mjs +124 -0
  61. package/scripts/cli/migrate.mjs +13 -7
  62. package/scripts/cli/next-steps.mjs +13 -0
  63. package/scripts/cli/nightly-launch.mjs +118 -0
  64. package/scripts/cli/nightly.mjs +228 -0
  65. package/scripts/cli/notes.mjs +58 -8
  66. package/scripts/cli/owner-config.mjs +181 -0
  67. package/scripts/cli/owner-jobs.mjs +201 -0
  68. package/scripts/cli/prompt-append.mjs +135 -0
  69. package/scripts/cli/rename.mjs +90 -109
  70. package/scripts/cli/run-context.mjs +50 -0
  71. package/scripts/cli/run.mjs +236 -79
  72. package/scripts/cli/scaffold.mjs +119 -126
  73. package/scripts/cli/schedule-errors.mjs +70 -0
  74. package/scripts/cli/sessions.mjs +10 -3
  75. package/scripts/cli/skills-check.mjs +147 -0
  76. package/scripts/cli/skills.mjs +170 -0
  77. package/scripts/cli/start.mjs +201 -0
  78. package/scripts/cli/templates.mjs +119 -45
  79. package/scripts/cli/tldr.mjs +184 -0
  80. package/scripts/cli/toml-string.mjs +76 -0
  81. package/scripts/cli/user-work-view.mjs +48 -0
  82. package/scripts/cli/writable-dirs.mjs +23 -0
  83. package/scripts/cli/yaml.mjs +1 -1
  84. package/skills/hire/SKILL.md +112 -100
  85. package/skills/hire/references/owner.md +68 -0
  86. package/templates/agents/claude-code/knowledge-curator.md +5 -2
  87. package/templates/agents/codex/knowledge-curator.toml +2 -1
  88. package/templates/operating-model/base.md +78 -0
  89. package/templates/operating-model/owner.md +11 -0
  90. package/templates/operating-model/principal.md +9 -0
  91. package/templates/operating-model/specialist.md +9 -0
  92. package/templates/packs/owner/craft.md +32 -0
  93. package/templates/packs/owner/identity.md +13 -0
  94. package/templates/packs/owner/mandate.md +26 -0
  95. package/templates/packs/owner/owner/SKILL.md +118 -0
  96. package/templates/packs/owner/owner-feedback/SKILL.md +49 -0
  97. package/templates/packs/owner/owner-heartbeat/SKILL.md +35 -0
  98. package/templates/packs/owner/owner-nightly/SKILL.md +114 -0
  99. package/templates/packs/owner/owner-review/SKILL.md +70 -0
  100. package/templates/packs/owner/owner-work/SKILL.md +53 -0
  101. package/templates/packs/principal/craft.md +27 -0
  102. package/templates/packs/principal/identity.md +13 -0
  103. package/templates/packs/principal/mandate.md +23 -0
  104. package/templates/packs/principal/principal-brief/SKILL.md +44 -0
  105. package/templates/packs/principal/principal-intro/SKILL.md +95 -0
  106. package/templates/packs/principal/principal-nightly/SKILL.md +152 -0
  107. package/templates/skills/check-in/SKILL.md +34 -24
  108. package/templates/skills/knowledge-curation/SKILL.md +89 -48
  109. package/templates/skills/local-rules/SKILL.md +86 -0
  110. package/templates/skills/nightly-consolidation/SKILL.md +131 -100
  111. package/templates/skills/skill-method/SKILL.md +79 -0
  112. package/templates/skills/user-nightly/SKILL.md +143 -0
  113. package/templates/skills/work-graph/SKILL.md +75 -0
  114. package/conformance/README.md +0 -60
  115. package/conformance/discovery.test.mjs +0 -140
  116. package/conformance/envelope.test.mjs +0 -185
  117. package/conformance/error-codes.test.mjs +0 -125
  118. package/conformance/harness.mjs +0 -180
  119. package/conformance/identity.test.mjs +0 -125
  120. package/docs/integration-guide.md +0 -1042
  121. package/templates/skills/self-update/SKILL.md +0 -121
@@ -0,0 +1,124 @@
1
+ // Arguments, scope, filters, the token cap, and the text layout of the curator feed. Pure functions; the script wires them.
2
+ import { resolve } from 'node:path';
3
+ import { DAY } from './curator-feed-sources.mjs';
4
+
5
+ const die = (message) => {
6
+ process.stderr.write(`${JSON.stringify({ event: 'curator_feed.bad_args', error: message })}\n`);
7
+ process.exit(1);
8
+ };
9
+
10
+ export function parseArgs(argv) {
11
+ const get = (flag) => {
12
+ const i = argv.indexOf(flag);
13
+ return i >= 0 ? argv[i + 1] : undefined;
14
+ };
15
+ const since = Date.parse(get('--since') ?? '');
16
+ const until = get('--until') === undefined ? Date.now() : Date.parse(get('--until'));
17
+ const out = get('--out');
18
+ if (Number.isNaN(since)) die('--since must be an ISO timestamp');
19
+ if (Number.isNaN(until)) die('--until must be an ISO timestamp');
20
+ if (!out) die('--out is required');
21
+ const maxTokens = Number(get('--max-tokens') ?? 25000);
22
+ if (!(Number.isFinite(maxTokens) && maxTokens > 0))
23
+ die('--max-tokens must be a finite positive number');
24
+ return {
25
+ since: Math.max(since, until - 7 * DAY),
26
+ until,
27
+ out: resolve(out),
28
+ all: argv.includes('--all'),
29
+ project: get('--project'),
30
+ topic: get('--topic'),
31
+ branch: get('--branch'),
32
+ maxTokens,
33
+ };
34
+ }
35
+
36
+ // --- Output ---
37
+ const tilde = (p, home) => (p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p);
38
+ const stamp = (ms) => new Date(ms).toISOString().slice(0, 16).replace('T', ' ') + 'Z';
39
+
40
+ export function render({ win, counts, tokens, sources, obsHomes, entries, home, notes }) {
41
+ const sourceLine = Object.entries(sources)
42
+ .map(([n, s]) =>
43
+ n === 'observations' && obsHomes.length
44
+ ? `${n} ${s} (${obsHomes.map((h) => tilde(h, home)).join(', ')})`
45
+ : `${n} ${s}`,
46
+ )
47
+ .join(' · ');
48
+ const byProject = new Map();
49
+ for (const e of entries) byProject.set(e.project, [...(byProject.get(e.project) ?? []), e]);
50
+ const sections = [...byProject.keys()].sort().map(
51
+ (p) =>
52
+ `## ${p}\n${byProject
53
+ .get(p)
54
+ .sort((a, b) => a.ts - b.ts)
55
+ .map((e) => e.md)
56
+ .join('\n\n')}`,
57
+ );
58
+ return (
59
+ `# Curator feed ${stamp(win.since)} → ${stamp(win.until)}\n` +
60
+ `Prompts ${counts.prompts} · Emails ${counts.emails} · Observations ${counts.observations} · Cut ${counts.cut} · ~${Math.round(tokens / 1000)}k tokens\n` +
61
+ `Sources: ${sourceLine}\n${notes.map((n) => `${n}\n`).join('')}\n${sections.join('\n\n')}\n`
62
+ );
63
+ }
64
+
65
+ // Scope, then filters. A header line counts every entry a filter leaves out, and those without the field.
66
+ export function applyScope(all, win, agentName) {
67
+ const notes = [];
68
+ let kept = all;
69
+ if (!win.all) {
70
+ if (agentName) {
71
+ kept = kept.filter(({ entry }) => entry.agent === agentName);
72
+ notes.push(`Scope: agent ${agentName}`);
73
+ } else {
74
+ notes.push('Scope: ACADEMY_AGENT_NAME is not set, so all agents are shown (same as --all)');
75
+ }
76
+ }
77
+ for (const field of ['project', 'topic', 'branch']) {
78
+ if (win[field] === undefined) continue;
79
+ const has = kept.filter(({ entry }) => entry[field]);
80
+ const match = has.filter(({ entry }) => entry[field] === win[field]);
81
+ notes.push(
82
+ `Filter ${field}=${win[field]}: ${kept.length - match.length} entries left out (${kept.length - has.length} have no ${field})`,
83
+ );
84
+ kept = match;
85
+ }
86
+ return { kept, notes };
87
+ }
88
+
89
+ // Over the cap, drop the oldest entries; the cut note counts toward the estimate.
90
+ // `build(rest, lines)` returns { counts, final }.
91
+ export function fitToCap({ kept, win, notes, build }) {
92
+ const oldestFirst = [...kept].sort((a, b) => a.entry.ts - b.entry.ts);
93
+ const buildCut = (cut) => {
94
+ const dropped = new Set(oldestFirst.slice(0, cut));
95
+ const before = stamp(oldestFirst[cut]?.entry.ts ?? win.until);
96
+ const lines = cut
97
+ ? [
98
+ ...notes,
99
+ `Trimmed: ${cut} oldest entries before ${before} to stay under ${win.maxTokens} tokens. Narrow with --since, --project, --topic, --branch, or a higher --max-tokens.`,
100
+ ]
101
+ : notes;
102
+ return build(
103
+ kept.filter((k) => !dropped.has(k)),
104
+ lines,
105
+ );
106
+ };
107
+ const fits = (b) => Math.round(b.final.length / 4) <= win.maxTokens;
108
+ const whole = buildCut(0);
109
+ if (fits(whole)) return whole;
110
+ // From one cut on, each extra cut only shrinks the text, so bisect for the smallest that fits.
111
+ let [lo, hi] = [1, kept.length];
112
+ while (lo < hi) {
113
+ const mid = (lo + hi) >> 1;
114
+ if (fits(buildCut(mid))) hi = mid;
115
+ else lo = mid + 1;
116
+ }
117
+ const best = kept.length ? buildCut(lo) : whole;
118
+ // Even with every entry cut, the header can be over a tiny cap: refuse rather than exceed it.
119
+ if (!fits(best))
120
+ die(
121
+ `--max-tokens ${win.maxTokens} is below the feed header (~${Math.round(best.final.length / 4)} tokens); raise it`,
122
+ );
123
+ return best;
124
+ }
@@ -0,0 +1,172 @@
1
+ // Shared readers for curator-feed.mjs and its source modules, plus the Claude prompts. Read-only.
2
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
3
+ import { basename, join } from 'node:path';
4
+ import { bridgeWorkspaceOf, stripBridgeContext } from '../../hooks/memory_bridge.mjs';
5
+ import { NIGHTLY_SKILL_BY_KIND, nightlyRoute } from './nightly.mjs';
6
+ import { nightlyPrompt } from './nightly-launch.mjs';
7
+
8
+ export const DAY = 86_400_000;
9
+ // Academy's generated nightly prompts, matched whole and only as a session's first entry:
10
+ // a typed prompt that starts the same way stays.
11
+ const NIGHTLY = new Set(
12
+ Object.entries(NIGHTLY_SKILL_BY_KIND).flatMap(([kind, skill]) => [
13
+ nightlyPrompt(skill),
14
+ nightlyRoute(kind),
15
+ ]),
16
+ );
17
+ // Generated envelopes (Claude and Codex), removed only when complete: opening tag through its
18
+ // closing tag. What is left is the typed prompt; nothing left means automation. An opening tag
19
+ // with no closing tag is typed text. Codex wraps AGENTS.md as a header line plus <INSTRUCTIONS>.
20
+ // A Helm job entry is its envelope plus the job prompt Helm wrote after it, so both go.
21
+ // Skill bodies, image notes, re-invocations, and compact summaries carry isMeta/isCompactSummary.
22
+ const ENVELOPE = new RegExp(
23
+ '# AGENTS\\.md instructions for [^\\n]+\\n\\n<INSTRUCTIONS>[\\s\\S]*?</INSTRUCTIONS>|' +
24
+ '<helm-context>[\\s\\S]*?</helm-context>[\\s\\S]*|' +
25
+ '<(task-notification|tldr_messages|local-command-[a-z]+|system-reminder|' +
26
+ 'environment_context|user_instructions|INSTRUCTIONS|turn_aborted|skill|subagent_notification|' +
27
+ 'permissions instructions)(?:\\s[^>]*)?>[\\s\\S]*?</\\1>',
28
+ 'g',
29
+ );
30
+ // The built-in slash command wrapper (name, message, args in any order) is an envelope too.
31
+ const BUILTIN_COMMAND =
32
+ /<command-name>\/(?:clear|compact|exit|model|config|resume|status|cost|mcp|plugin|login)<\/command-name>/;
33
+ // Claude Code writes these interrupt notes unflagged, so they match whole.
34
+ const INTERRUPTS = new Set([
35
+ '[Request interrupted by user]',
36
+ '[Request interrupted by user for tool use]',
37
+ ]);
38
+ // Spectre's plan-review receipt template with <placeholder> hashes, anywhere in an entry. The
39
+ // entry stays and its heading is marked as a likely agent-written brief: a pasted or quoted
40
+ // receipt may carry the user's words, so this rule never drops. A real receipt (hex hashes) is unmarked.
41
+ // shortcut: coupled to Spectre's receipt (spectre-execute/references/plan-review.md step 4);
42
+ // revisit when that format changes or Spectre flags its headless entries.
43
+ export const REVIEW_BRIEF =
44
+ /^REVIEW_COMPLETE\nROUTE .+\nREPORT_SHA256 sha256:<.+\nPLAN_SHA256 sha256:<.+\nDISPOSITION .+\nPLAN_REVIEW_[A-Z]+_OK$/m;
45
+ const absent = (e) => e.code === 'ENOENT' || e.code === 'ENOTDIR';
46
+
47
+ // ENOENT/ENOTDIR -> fallback (absent); any other error is a real failure.
48
+ export function ifPresent(read, fallback) {
49
+ try {
50
+ return read();
51
+ } catch (e) {
52
+ if (absent(e)) return fallback;
53
+ throw e;
54
+ }
55
+ }
56
+ export const ls = (dir) => ifPresent(() => readdirSync(dir), null);
57
+ export const jsonLines = (file) =>
58
+ readFileSync(file, 'utf8')
59
+ .split('\n')
60
+ .flatMap((l) => {
61
+ try {
62
+ return l ? [JSON.parse(l)] : [];
63
+ } catch {
64
+ return [];
65
+ }
66
+ });
67
+ export const mtimeMs = (f) => statSync(f).mtimeMs;
68
+ export const oneLine = (s) => s.replace(/\s+/g, ' ').trim();
69
+ export const textOf = (content) => {
70
+ if (typeof content === 'string') return content;
71
+ if (!Array.isArray(content)) return null;
72
+ const texts = content.filter((b) => b?.type === 'text').map((b) => b.text ?? '');
73
+ return texts.length ? texts.join('\n') : null;
74
+ };
75
+ export const inWindow = (ts, win) => ts >= win.since && ts < win.until;
76
+ export const stripEnvelopes = (t) =>
77
+ (BUILTIN_COMMAND.test(t)
78
+ ? t.replace(/<command-(message|name|args)>[\s\S]*?<\/command-\1>/g, '')
79
+ : t
80
+ )
81
+ .replace(ENVELOPE, '')
82
+ .trim();
83
+
84
+ export function projectOf(cwd) {
85
+ const ws = bridgeWorkspaceOf(cwd);
86
+ return ws
87
+ ? { project: ws.project, topic: ws.workspace }
88
+ : { project: basename(cwd || '') || 'unknown', topic: null };
89
+ }
90
+
91
+ // --- A. Claude prompts ---
92
+ function assistantText(e) {
93
+ if (!Array.isArray(e.message?.content)) return '';
94
+ const texts = e.message.content.filter((b) => b?.type === 'text').map((b) => b.text ?? '');
95
+ return texts.join(' ').trim();
96
+ }
97
+
98
+ function clean(raw) {
99
+ let t = raw;
100
+ const m =
101
+ /<command-message>([^<]*)<\/command-message>\s*<command-name>[^<]*<\/command-name>(?:\s*<command-args>(.*?)<\/command-args>)?/s.exec(
102
+ t,
103
+ );
104
+ if (m) t = `/${m[1]} ${m[2] ?? ''}`.trim();
105
+ return stripBridgeContext(stripEnvelopes(t)).trim();
106
+ }
107
+
108
+ // Temp and eval folders, matched as whole path prefixes: /tmpfoo is not /tmp.
109
+ export const isTempCwd = (cw) =>
110
+ ['/tmp', '/private', '/var/folders'].some((d) => cw === d || cw.startsWith(`${d}/`));
111
+ const isAutoEntry = (e, raw, first) =>
112
+ e.isMeta || e.isCompactSummary || (first && NIGHTLY.has(raw)) || INTERRUPTS.has(raw);
113
+
114
+ // One user entry -> a prompt, or null when it is outside the window, automatic, or noise.
115
+ function claudePrompt(e, raw, ctx) {
116
+ const ts = Date.parse(e.timestamp);
117
+ if (!inWindow(ts, ctx.win) || isAutoEntry(e, raw, ctx.first)) return null;
118
+ const cw = e.cwd || ctx.cwd || '';
119
+ if (isTempCwd(cw)) return null;
120
+ const text = clean(raw);
121
+ if (!text || text === 'Reply READY') return null;
122
+ return {
123
+ runtime: 'claude',
124
+ ts,
125
+ cwd: cw,
126
+ branch: e.gitBranch || null,
127
+ sessionId: ctx.sessionId,
128
+ turnId: e.promptId ?? null,
129
+ text,
130
+ prev: ctx.lastAsst,
131
+ };
132
+ }
133
+
134
+ // One transcript entry -> a prompt or null; assistant entries only feed the next prompt's `prev`.
135
+ function claudeEntryPrompt(e, ctx) {
136
+ if (e.cwd && !ctx.cwd) ctx.cwd = e.cwd;
137
+ if (e.type === 'assistant') {
138
+ ctx.lastAsst = assistantText(e) || ctx.lastAsst;
139
+ return null;
140
+ }
141
+ if (e.type !== 'user') return null;
142
+ const raw = textOf(e.message?.content)?.trim();
143
+ if (raw == null) return null;
144
+ // Per entry, not per session: the user can type follow-ups into a Helm-started session.
145
+ const p = claudePrompt(e, raw, ctx);
146
+ ctx.first = false;
147
+ return p;
148
+ }
149
+
150
+ function claudeFilePrompts(file, sessionId, win) {
151
+ const prompts = [];
152
+ const ctx = { win, sessionId, cwd: null, lastAsst: '', first: true };
153
+ for (const e of jsonLines(file)) {
154
+ const p = claudeEntryPrompt(e, ctx);
155
+ if (p) prompts.push(p);
156
+ }
157
+ return prompts;
158
+ }
159
+
160
+ export function claudePrompts(home, win) {
161
+ const base = join(home, '.claude', 'projects');
162
+ const prompts = [];
163
+ for (const dir of ls(base) ?? []) {
164
+ if (dir === 'subagents') continue;
165
+ for (const f of ls(join(base, dir)) ?? []) {
166
+ const file = join(base, dir, f);
167
+ if (!f.endsWith('.jsonl') || mtimeMs(file) < win.since) continue;
168
+ prompts.push(...claudeFilePrompts(file, f.slice(0, -6), win));
169
+ }
170
+ }
171
+ return prompts;
172
+ }
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env node
2
+ // Writes the curator's daily input feed: the user's own prompts (Claude + Codex), their inbound
3
+ // emails, and the memory-bridge observation for each prompt. A plain script, not an `academy`
4
+ // command. Usage: curator-feed.mjs --since <ISO> [--until <ISO>] --out <path>
5
+ // [--all] [--project <p>] [--topic <t>] [--branch <b>] [--max-tokens <n>]
6
+ // Without --all the feed holds only the entries of ACADEMY_AGENT_NAME.
7
+ // Prints one JSON line. Exit 0 when the file is written, 1 when it cannot be.
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import { homedir } from 'node:os';
11
+ import { DatabaseSync } from 'node:sqlite';
12
+ import {
13
+ REVIEW_BRIEF,
14
+ claudePrompts,
15
+ ifPresent,
16
+ jsonLines,
17
+ ls,
18
+ oneLine,
19
+ projectOf,
20
+ } from './curator-feed-sources.mjs';
21
+ import { codexPrompts } from './curator-feed-codex.mjs';
22
+ import { checkedObservation, observations, previousRun } from './curator-feed-observations.mjs';
23
+ import { applyScope, fitToCap, parseArgs, render } from './curator-feed-scope.mjs';
24
+
25
+ const CUT_AT = 2000;
26
+ const SHORT = 60;
27
+ const BRIEF_MARK = "[likely agent-written brief, not the user's words]";
28
+ const PT = new Intl.DateTimeFormat('en-GB', {
29
+ timeZone: 'America/Los_Angeles',
30
+ hour: '2-digit',
31
+ minute: '2-digit',
32
+ hourCycle: 'h23',
33
+ });
34
+ const ptTime = (ms) => PT.format(new Date(ms));
35
+
36
+ const fail = (source, error, id) =>
37
+ process.stderr.write(
38
+ `${JSON.stringify({ event: 'curator_feed.source_failed', source, ...(id !== undefined && { id }), error: String(error?.message ?? error) })}\n`,
39
+ );
40
+
41
+ const NO_AGENTS = {
42
+ ofSession: () => 'direct chat',
43
+ ofCwd: () => 'direct chat',
44
+ };
45
+
46
+ function indexAgentRows(rows, name, { bySession, byDir, byCwd }) {
47
+ for (const r of rows) {
48
+ if (r.sessionId) bySession.set(r.sessionId, name);
49
+ if (r.agentDir) byDir.set(r.agentDir, name);
50
+ if (r.cwd) byCwd.push({ cwd: r.cwd, at: Date.parse(r.timestamp), name });
51
+ }
52
+ }
53
+
54
+ function loadAgents(root) {
55
+ const bySession = new Map();
56
+ const byDir = new Map();
57
+ const byCwd = [];
58
+ for (const id of ls(root) ?? []) {
59
+ try {
60
+ const yaml = ifPresent(() => readFileSync(join(root, id, 'agent.yaml'), 'utf8'), '');
61
+ const name = /^name:\s*["']?([^"'\n]+?)["']?\s*$/m.exec(yaml)?.[1] ?? id;
62
+ const rows = ifPresent(() => jsonLines(join(root, id, 'memory', 'sessions.jsonl')), []);
63
+ indexAgentRows(rows, name, { bySession, byDir, byCwd });
64
+ } catch (e) {
65
+ sources.agents = 'error';
66
+ fail('agents', e);
67
+ }
68
+ }
69
+ return {
70
+ ofSession: (sid) => bySession.get(sid) ?? 'direct chat',
71
+ // An agent's own home names it at any time; another cwd names the agent last seen there.
72
+ ofCwd: (cwd, at) =>
73
+ byDir.get(cwd) ??
74
+ byCwd.filter((r) => r.cwd === cwd && r.at <= at).sort((a, b) => b.at - a.at)[0]?.name ??
75
+ 'direct chat',
76
+ };
77
+ }
78
+
79
+ // Fork duplicates: a prompt of SHORT chars or more with the same text in another session within
80
+ // 2 minutes keeps the earliest only. Short replies ("yes") typed twice both stay; a short reply
81
+ // that a fork copied (same turn id) is one prompt. Short prompts key by turn id too, so another
82
+ // turn with the same text between two copies does not hide the copy.
83
+ export function dedupeForks(prompts) {
84
+ const kept = new Map();
85
+ return prompts
86
+ .sort((a, b) => a.ts - b.ts)
87
+ .filter((p) => {
88
+ const key = p.text.length >= SHORT ? p.text : p.turnId != null && `${p.turnId}\0${p.text}`;
89
+ const seen = key && kept.get(key);
90
+ if (seen && seen.sessionId !== p.sessionId && p.ts - seen.ts <= 2 * 60_000) return false;
91
+ if (key) kept.set(key, p);
92
+ return true;
93
+ });
94
+ }
95
+
96
+ // --- B. Emails ---
97
+ const EMAIL_SQL = `
98
+ select m.id, m.body, m.metadata, m.created_at, c.metadata as conv, pm.body as parent_body, pm.created_at as parent_at, pr.external_principal_ref as parent_ref
99
+ from messages m
100
+ left join conversations c on c.id = m.conversation_id
101
+ left join messages pm on pm.id = m.in_reply_to_message_id
102
+ left join principals pr on pr.id = pm.sender_principal_id
103
+ where m.origin = 'inbound' and m.created_at >= ? and m.created_at < ? order by m.created_at`;
104
+
105
+ const parseJson = (s) => {
106
+ try {
107
+ return JSON.parse(s ?? '{}') ?? {};
108
+ } catch {
109
+ return {};
110
+ }
111
+ };
112
+
113
+ function emailOf(r, agents) {
114
+ const cwd = /^[a-z-]+:(\/.*)$/.exec(r.parent_ref ?? '')?.[1] ?? null;
115
+ const { project, topic } = cwd ? projectOf(cwd) : { project: 'unknown', topic: null };
116
+ return {
117
+ ts: Date.parse(r.created_at),
118
+ id: r.id,
119
+ body: r.body ?? '',
120
+ subject: parseJson(r.conv).subject ?? '',
121
+ channel: parseJson(r.metadata).provider_metadata?.channel ?? '',
122
+ parent: oneLine((r.parent_body ?? '').slice(0, 300)),
123
+ project,
124
+ topic,
125
+ agent: cwd ? agents.ofCwd(cwd, Date.parse(r.parent_at ?? r.created_at)) : 'direct chat',
126
+ };
127
+ }
128
+
129
+ export function emails(home, win, agents) {
130
+ const file = join(home, '.tldr-agents', 'tightbeam', 'tightbeam.db');
131
+ if (!existsSync(file)) return null;
132
+ const db = new DatabaseSync(file, { readOnly: true });
133
+ try {
134
+ return db
135
+ .prepare(EMAIL_SQL)
136
+ .all(new Date(win.since).toISOString(), new Date(win.until).toISOString())
137
+ .map((r) => emailOf(r, agents));
138
+ } finally {
139
+ db.close();
140
+ }
141
+ }
142
+
143
+ // --- Output ---
144
+ const cutText = (text) =>
145
+ text.length > CUT_AT ? `${text.slice(0, CUT_AT)}[cut: ${text.length} chars]` : text;
146
+ const quote = (text) =>
147
+ text
148
+ .split('\n')
149
+ .map((l) => `> ${l}`)
150
+ .join('\n');
151
+ const short8 = (s) => String(s ?? '').slice(0, 8);
152
+ function promptEntry(p, obs, agents) {
153
+ const { project, topic } = projectOf(p.cwd);
154
+ const branch = p.branch ?? obs?.branch;
155
+ const brief = REVIEW_BRIEF.test(p.text) ? ` · ${BRIEF_MARK}` : '';
156
+ const agent = agents.ofSession(p.sessionId);
157
+ const lines = [
158
+ `### ${ptTime(p.ts)} PT · ${p.runtime} · ${project}${topic ? `/${topic}` : ''}/${branch ?? '-'} · session ${short8(p.sessionId)} · turn ${short8(p.turnId)} · ${agent}${brief}`,
159
+ `cwd: ${p.cwd}`,
160
+ quote(cutText(p.text)),
161
+ ];
162
+ if (p.text.length < SHORT && p.prev) lines.push(`re: ${oneLine(p.prev.slice(-300))}`);
163
+ if (obs) {
164
+ const tags = obs.tags?.length ? ` [${obs.tags.join(', ')}]` : '';
165
+ const files = obs.files?.length ? ` (${obs.files.join(', ')})` : '';
166
+ lines.push(`Result — ${obs.actionStatus ?? ''}${tags}${files}`);
167
+ if (obs.summary) lines.push(obs.summary);
168
+ }
169
+ return { ts: p.ts, project, agent, topic, branch, md: lines.join('\n') };
170
+ }
171
+
172
+ function emailEntry(m) {
173
+ const lines = [
174
+ `### ${ptTime(m.ts)} PT · email → ${m.agent}${m.topic ? ` (${m.topic})` : ` (${m.project})`} · "${m.subject}" · msg_${m.id.replace(/^msg_/, '').slice(0, 8)}`,
175
+ `channel: ${m.channel}`,
176
+ ];
177
+ if (m.parent) lines.push(`re: ${m.parent}`);
178
+ lines.push(quote(cutText(m.body)));
179
+ return {
180
+ ts: m.ts,
181
+ project: m.project,
182
+ agent: m.agent,
183
+ topic: m.topic,
184
+ branch: null,
185
+ md: lines.join('\n'),
186
+ };
187
+ }
188
+
189
+ // --- Main ---
190
+ const win = parseArgs(process.argv.slice(2));
191
+ const home = process.env.HOME || homedir();
192
+ const agentsRoot =
193
+ process.env.AGENTS_ROOT || join(process.env.ACADEMY_HOME || join(home, '.academy'), 'agents');
194
+ const sources = {
195
+ agents: 'ok',
196
+ claude: 'ok',
197
+ codex: 'ok',
198
+ tightbeam: 'ok',
199
+ observations: 'ok',
200
+ previousRun: 'ok',
201
+ };
202
+ const guard = (name, fn, fallback) => {
203
+ try {
204
+ return fn();
205
+ } catch (e) {
206
+ sources[name] = 'error';
207
+ fail(name, e);
208
+ return fallback;
209
+ }
210
+ };
211
+ const agents = guard('agents', () => loadAgents(agentsRoot), NO_AGENTS);
212
+
213
+ const claude = guard('claude', () => claudePrompts(home, win), []);
214
+ const codex = guard(
215
+ 'codex',
216
+ () => {
217
+ const r = codexPrompts(home, win);
218
+ if (r === null) sources.codex = 'missing';
219
+ return r ?? [];
220
+ },
221
+ [],
222
+ );
223
+ const prompts = dedupeForks([...claude, ...codex]);
224
+ const mails = guard(
225
+ 'tightbeam',
226
+ () => {
227
+ const r = emails(home, win, agents);
228
+ if (r === null) sources.tightbeam = 'missing';
229
+ return r ?? [];
230
+ },
231
+ [],
232
+ );
233
+ let obsHomes = [];
234
+ const byTurn = guard(
235
+ 'observations',
236
+ () => {
237
+ const r = observations(process.env, win);
238
+ obsHomes = r.homes;
239
+ if (!r.byTurn) sources.observations = 'missing';
240
+ return r.byTurn ?? new Map();
241
+ },
242
+ new Map(),
243
+ );
244
+ const prev = guard('previousRun', () => previousRun(home, agentsRoot), null);
245
+
246
+ // One bad row is skipped with one stderr line; it never stops the file.
247
+ const entryOf = (source, id, make) => {
248
+ try {
249
+ return make();
250
+ } catch (e) {
251
+ fail(source, e, id);
252
+ return null;
253
+ }
254
+ };
255
+ const observedOf = (p, obs = byTurn.get(`${p.sessionId}|${p.turnId}`)) =>
256
+ obs && entryOf('observations', obs.id ?? p.turnId, () => checkedObservation(obs));
257
+ const observed = new Map(prompts.map((p) => [p, observedOf(p)]));
258
+ const shown = (items, source, idOf, make) =>
259
+ items.flatMap((item) => {
260
+ const entry = entryOf(source(item), idOf(item), () => make(item));
261
+ return entry ? [{ item, entry }] : [];
262
+ });
263
+ const shownPrompts = shown(
264
+ prompts,
265
+ (p) => p.runtime,
266
+ (p) => p.turnId ?? p.sessionId,
267
+ (p) => promptEntry(p, observed.get(p), agents),
268
+ );
269
+ const shownMails = shown(
270
+ mails,
271
+ () => 'tightbeam',
272
+ (m) => m.id,
273
+ emailEntry,
274
+ );
275
+ const { kept, notes } = applyScope(
276
+ [...shownPrompts, ...shownMails],
277
+ win,
278
+ process.env.ACADEMY_AGENT_NAME || null,
279
+ );
280
+ function build(rest, lines) {
281
+ const mine = rest.filter(({ item }) => observed.has(item));
282
+ const counts = {
283
+ prompts: mine.length,
284
+ emails: rest.length - mine.length,
285
+ observations: mine.filter(({ item }) => observed.get(item)).length,
286
+ cut: mine.filter(({ item }) => item.text.length > CUT_AT).length,
287
+ };
288
+ const args = {
289
+ win,
290
+ counts,
291
+ sources,
292
+ obsHomes,
293
+ entries: rest.map((k) => k.entry),
294
+ home,
295
+ notes: lines,
296
+ };
297
+ const text = render({ ...args, tokens: 0 });
298
+ return {
299
+ counts,
300
+ final: render({ ...args, tokens: Math.round(text.length / 4) }),
301
+ };
302
+ }
303
+ const { counts, final } = fitToCap({ kept, win, notes, build });
304
+ const tokensEstimate = Math.round(final.length / 4);
305
+
306
+ try {
307
+ mkdirSync(dirname(win.out), { recursive: true });
308
+ writeFileSync(win.out, final);
309
+ } catch (e) {
310
+ fail('write', e);
311
+ process.stdout.write(
312
+ `${JSON.stringify({ ok: false, out: win.out, error: String(e.message) })}\n`,
313
+ );
314
+ process.exit(1);
315
+ }
316
+ process.stdout.write(
317
+ `${JSON.stringify({ ok: true, out: win.out, ...counts, tokensEstimate, sources, previousRun: prev })}\n`,
318
+ );
@@ -0,0 +1,73 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import {
4
+ AGENTS_ROOT,
5
+ LockTimeoutError,
6
+ activeCommandName,
7
+ agentDir,
8
+ contractError,
9
+ validateAgentsRoot,
10
+ } from './core.mjs';
11
+ import { agentIdOrNull } from './agent-id.mjs';
12
+ import { withRootEventLock } from './eventlog.mjs';
13
+
14
+ const DEFAULT_NAME_WORDS = 'one two three four five six seven eight nine ten'.split(' ');
15
+
16
+ function* defaultNames(kind) {
17
+ for (const word of DEFAULT_NAME_WORDS) yield `${kind}-${word}`;
18
+ for (let n = DEFAULT_NAME_WORDS.length + 1; ; n += 1) yield `${kind}-${n}`;
19
+ }
20
+
21
+ // Non-exiting, unlike assertAgentNameFree: it runs inside the root event lock,
22
+ // and a pick that skips a taken name must not end the process. A name is taken
23
+ // by any live or archived id or current name.
24
+ export function isAgentNameFree(name) {
25
+ return agentIdOrNull(name) === null;
26
+ }
27
+
28
+ /** Claims an explicit name: true, false when taken, or the mkdir error. */
29
+ export function claimName(name, json) {
30
+ try {
31
+ return underNameClaimLock(`the name "${name}"`, json, () => claimFreeName(name));
32
+ } catch (error) {
33
+ return error;
34
+ }
35
+ }
36
+
37
+ // Makes the folder for a free name; false when the name is taken. Only under the lock.
38
+ function claimFreeName(name) {
39
+ if (!isAgentNameFree(name)) return false;
40
+ try {
41
+ mkdirSync(join(AGENTS_ROOT, name));
42
+ return true;
43
+ } catch (error) {
44
+ // Another writer outside this lock may have made the folder.
45
+ if (error.code !== 'EEXIST') throw error;
46
+ return false;
47
+ }
48
+ }
49
+
50
+ // The final name check and the mkdir claim share the root event lock, so two
51
+ // creates cannot take one name. Conflicts are reported after the lock
52
+ // releases. appendLifecycleEvent takes the same lock, so it runs after the claim.
53
+ export function claimDefaultName(kind, json) {
54
+ validateAgentsRoot(json);
55
+ return underNameClaimLock(`a default name for ${kind}`, json, () => {
56
+ for (const name of defaultNames(kind)) {
57
+ if (claimFreeName(name)) return { name, dir: agentDir(name) };
58
+ }
59
+ });
60
+ }
61
+
62
+ /** Runs `claim` under the root event lock; a lock timeout exits after release. */
63
+ function underNameClaimLock(what, json, claim) {
64
+ try {
65
+ return withRootEventLock(claim);
66
+ } catch (error) {
67
+ if (!(error instanceof LockTimeoutError)) throw error;
68
+ const message = `failed to claim ${what}: ${error.message}`;
69
+ if (json) contractError(activeCommandName(), error.code, message, error.fields);
70
+ console.error(`Error: ${message}`);
71
+ process.exit(1);
72
+ }
73
+ }