@ozzylabs/feedradar 0.1.4 → 0.1.5
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/README.ja.md +12 -6
- package/README.md +10 -5
- package/dist/agents/claude-code.d.ts +12 -1
- package/dist/agents/claude-code.d.ts.map +1 -1
- package/dist/agents/claude-code.js +9 -5
- package/dist/agents/claude-code.js.map +1 -1
- package/dist/agents/codex-cli.d.ts +7 -1
- package/dist/agents/codex-cli.d.ts.map +1 -1
- package/dist/agents/codex-cli.js +9 -5
- package/dist/agents/codex-cli.js.map +1 -1
- package/dist/agents/copilot.d.ts +7 -1
- package/dist/agents/copilot.d.ts.map +1 -1
- package/dist/agents/copilot.js +9 -5
- package/dist/agents/copilot.js.map +1 -1
- package/dist/agents/gemini-cli.d.ts +7 -1
- package/dist/agents/gemini-cli.d.ts.map +1 -1
- package/dist/agents/gemini-cli.js +9 -5
- package/dist/agents/gemini-cli.js.map +1 -1
- package/dist/agents/index.d.ts +1 -1
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/types.d.ts +33 -0
- package/dist/agents/types.d.ts.map +1 -1
- package/dist/cli/_progress.d.ts +138 -0
- package/dist/cli/_progress.d.ts.map +1 -0
- package/dist/cli/_progress.js +176 -0
- package/dist/cli/_progress.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/research.d.ts +18 -20
- package/dist/cli/research.d.ts.map +1 -1
- package/dist/cli/research.js +318 -203
- package/dist/cli/research.js.map +1 -1
- package/dist/cli/review.d.ts +7 -0
- package/dist/cli/review.d.ts.map +1 -1
- package/dist/cli/review.js +46 -1
- package/dist/cli/review.js.map +1 -1
- package/dist/cli/source.d.ts +23 -2
- package/dist/cli/source.d.ts.map +1 -1
- package/dist/cli/source.js +425 -7
- package/dist/cli/source.js.map +1 -1
- package/dist/cli/update.d.ts +7 -0
- package/dist/cli/update.d.ts.map +1 -1
- package/dist/cli/update.js +41 -1
- package/dist/cli/update.js.map +1 -1
- package/dist/cli/watch.d.ts.map +1 -1
- package/dist/cli/watch.js +65 -3
- package/dist/cli/watch.js.map +1 -1
- package/dist/cli/workflow/generate-combined.d.ts +100 -0
- package/dist/cli/workflow/generate-combined.d.ts.map +1 -0
- package/dist/cli/workflow/generate-combined.js +387 -0
- package/dist/cli/workflow/generate-combined.js.map +1 -0
- package/dist/cli/workflow/generate-watch.d.ts +142 -0
- package/dist/cli/workflow/generate-watch.d.ts.map +1 -0
- package/dist/cli/workflow/generate-watch.js +338 -0
- package/dist/cli/workflow/generate-watch.js.map +1 -0
- package/dist/cli/workflow.d.ts +29 -0
- package/dist/cli/workflow.d.ts.map +1 -0
- package/dist/cli/workflow.js +66 -0
- package/dist/cli/workflow.js.map +1 -0
- package/dist/core/feeds/_fetch.d.ts +10 -0
- package/dist/core/feeds/_fetch.d.ts.map +1 -1
- package/dist/core/feeds/_fetch.js +182 -0
- package/dist/core/feeds/_fetch.js.map +1 -1
- package/dist/core/feeds/_jsonpath.d.ts +57 -0
- package/dist/core/feeds/_jsonpath.d.ts.map +1 -0
- package/dist/core/feeds/_jsonpath.js +207 -0
- package/dist/core/feeds/_jsonpath.js.map +1 -0
- package/dist/core/feeds/html-js.d.ts +8 -0
- package/dist/core/feeds/html-js.d.ts.map +1 -1
- package/dist/core/feeds/html-js.js +47 -1
- package/dist/core/feeds/html-js.js.map +1 -1
- package/dist/core/feeds/index.d.ts +1 -1
- package/dist/core/feeds/index.d.ts.map +1 -1
- package/dist/core/feeds/index.js +4 -0
- package/dist/core/feeds/index.js.map +1 -1
- package/dist/core/feeds/json-api.d.ts +3 -0
- package/dist/core/feeds/json-api.d.ts.map +1 -0
- package/dist/core/feeds/json-api.js +723 -0
- package/dist/core/feeds/json-api.js.map +1 -0
- package/dist/core/feeds/json-feed.d.ts +11 -0
- package/dist/core/feeds/json-feed.d.ts.map +1 -0
- package/dist/core/feeds/json-feed.js +242 -0
- package/dist/core/feeds/json-feed.js.map +1 -0
- package/dist/core/feeds/types.d.ts +123 -0
- package/dist/core/feeds/types.d.ts.map +1 -1
- package/dist/core/progress.d.ts +101 -0
- package/dist/core/progress.d.ts.map +1 -0
- package/dist/core/progress.js +212 -0
- package/dist/core/progress.js.map +1 -0
- package/dist/core/recipes.d.ts +138 -0
- package/dist/core/recipes.d.ts.map +1 -0
- package/dist/core/recipes.js +238 -0
- package/dist/core/recipes.js.map +1 -0
- package/dist/core/watcher.d.ts +61 -1
- package/dist/core/watcher.d.ts.map +1 -1
- package/dist/core/watcher.js +99 -2
- package/dist/core/watcher.js.map +1 -1
- package/dist/recipes/aws-whats-new.yaml +61 -0
- package/dist/recipes/dev-to.yaml +40 -0
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +1 -0
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/recipe.d.ts +115 -0
- package/dist/schemas/recipe.d.ts.map +1 -0
- package/dist/schemas/recipe.js +54 -0
- package/dist/schemas/recipe.js.map +1 -0
- package/dist/schemas/source.d.ts +130 -0
- package/dist/schemas/source.d.ts.map +1 -1
- package/dist/schemas/source.js +130 -0
- package/dist/schemas/source.js.map +1 -1
- package/dist/templates/agents/AGENTS.md +31 -3
- package/dist/templates/feedradar.md +23 -8
- package/dist/templates/workflows/combined.template.yaml.tmpl +110 -0
- package/dist/templates/workflows/watch.template.yaml.tmpl +103 -0
- package/package.json +1 -2
package/dist/cli/research.js
CHANGED
|
@@ -21,7 +21,18 @@ import { getAgentAdapter } from "../agents/index.js";
|
|
|
21
21
|
import { getDefaultAgent, loadRadarConfig, RadarConfigError } from "../core/config.js";
|
|
22
22
|
import { loadItems, saveItems } from "../core/items.js";
|
|
23
23
|
import { loadTemplate } from "../core/templates.js";
|
|
24
|
-
import { AgentIdSchema, ResearchFrontmatterSchema } from "../schemas/index.js";
|
|
24
|
+
import { AgentIdSchema, ItemStatusSchema, ResearchFrontmatterSchema } from "../schemas/index.js";
|
|
25
|
+
import { buildAgentProgressCallback, buildReporter, ProgressFlagError, parseProgressFlags, pollOutputFileSize, } from "./_progress.js";
|
|
26
|
+
/**
|
|
27
|
+
* Default hard-cap for `radar research --batch`.
|
|
28
|
+
*
|
|
29
|
+
* ADR-0014 D3a pins the default to 10: 2-10x the empirical detection rate
|
|
30
|
+
* (1-5 items per cron tick) while keeping LLM cost-per-tick bounded
|
|
31
|
+
* (~$0.01/item * 10 ~= $0.1). Generated workflow YAML embeds this same
|
|
32
|
+
* literal via `combined.template.yaml`, but the CLI re-enforces it so the
|
|
33
|
+
* cap also applies when the YAML is hand-edited.
|
|
34
|
+
*/
|
|
35
|
+
export const RESEARCH_BATCH_DEFAULT_MAX_ITEMS = 10;
|
|
25
36
|
function parseArgs(args) {
|
|
26
37
|
const out = { itemIds: [] };
|
|
27
38
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -34,6 +45,10 @@ function parseArgs(args) {
|
|
|
34
45
|
out.digest = true;
|
|
35
46
|
continue;
|
|
36
47
|
}
|
|
48
|
+
if (a === "--batch") {
|
|
49
|
+
out.batch = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
37
52
|
if (a === "--agent") {
|
|
38
53
|
out.agent = args[++i];
|
|
39
54
|
continue;
|
|
@@ -42,6 +57,18 @@ function parseArgs(args) {
|
|
|
42
57
|
out.template = args[++i];
|
|
43
58
|
continue;
|
|
44
59
|
}
|
|
60
|
+
if (a === "--status") {
|
|
61
|
+
out.status = args[++i];
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (a === "--max-items") {
|
|
65
|
+
out.maxItems = args[++i];
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (a === "--filter-tags") {
|
|
69
|
+
out.filterTags = args[++i];
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
45
72
|
if (a?.startsWith("--")) {
|
|
46
73
|
throw new Error(`unknown option: ${a}`);
|
|
47
74
|
}
|
|
@@ -55,19 +82,34 @@ function printHelp(log) {
|
|
|
55
82
|
log("Usage:");
|
|
56
83
|
log(" radar research <item-id> [--agent <agent-id>] [--template <template-id>]");
|
|
57
84
|
log(" radar research --digest <item-id> <item-id> ... [--agent <agent-id>] [--template <id>]");
|
|
85
|
+
log(` radar research --batch [--status <status>] [--max-items N] [--filter-tags <list>] [--agent <id>]`);
|
|
58
86
|
log("");
|
|
59
87
|
log("Arguments:");
|
|
60
88
|
log(" <item-id> Item id (matches items/<sourceId>/<item-id>.yaml)");
|
|
61
89
|
log(" Pass 2 or more ids together with --digest to bundle them.");
|
|
90
|
+
log(" Omit positional ids with --batch — items are discovered.");
|
|
62
91
|
log("");
|
|
63
92
|
log("Options:");
|
|
64
93
|
log(" --agent <agent-id> claude-code | codex-cli | gemini-cli | copilot (default: claude-code)");
|
|
65
94
|
log(" --template <id> Template id under templates/ (default: default; digest: digest)");
|
|
66
95
|
log(" --digest Bundle multiple items into a single digest report (ADR-0011)");
|
|
96
|
+
log(" --batch Research every item matching --status (and --filter-tags)");
|
|
97
|
+
log(" respecting the --max-items hard-cap (ADR-0014 D3a).");
|
|
98
|
+
log(" --status <status> Batch-mode filter: detected | researched | reviewed | dismissed");
|
|
99
|
+
log(" (default: detected).");
|
|
100
|
+
log(` --max-items N Batch-mode hard-cap on processed items (default: ${RESEARCH_BATCH_DEFAULT_MAX_ITEMS}).`);
|
|
101
|
+
log(" Excess items are dropped and announced via warn() so a runaway");
|
|
102
|
+
log(" detection cannot blow the cap from inside a workflow.");
|
|
103
|
+
log(" --filter-tags <list> Batch-mode comma-separated allow-list matched against");
|
|
104
|
+
log(" each item's matchedKeywords (case-insensitive). Default: all.");
|
|
105
|
+
log(" --verbose Stream the agent CLI's stdout/stderr in addition to phase markers.");
|
|
106
|
+
log(" --quiet Suppress phase markers and spinner; print only the completion line.");
|
|
107
|
+
log(" Equivalent to setting RADAR_NO_PROGRESS=1 (ADR-0015 D2).");
|
|
67
108
|
log("");
|
|
68
109
|
log("Output:");
|
|
69
110
|
log(" single-item: research/<YYYYMMDD>_<slug>_v1.md (ADR-0003)");
|
|
70
111
|
log(" digest: research/<YYYYMMDD>_digest_<slug>_v1.md (ADR-0011)");
|
|
112
|
+
log(" batch: one single-item report per matched item (no digest aggregation).");
|
|
71
113
|
}
|
|
72
114
|
async function pathExists(p) {
|
|
73
115
|
try {
|
|
@@ -78,14 +120,6 @@ async function pathExists(p) {
|
|
|
78
120
|
return false;
|
|
79
121
|
}
|
|
80
122
|
}
|
|
81
|
-
/**
|
|
82
|
-
* Slug an Item into the `<sourceId>-<short-slug>` form used by the research
|
|
83
|
-
* filename (`research/<YYYYMMDD>_<slug>_v1.md`).
|
|
84
|
-
*
|
|
85
|
-
* Falls back to the item id when the title is empty/unicode-only. We trim to
|
|
86
|
-
* 60 chars so the resulting filename stays inside typical filesystem limits
|
|
87
|
-
* after adding date prefix and version suffix.
|
|
88
|
-
*/
|
|
89
123
|
function buildSlug(item) {
|
|
90
124
|
const baseSource = item.sourceId.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
91
125
|
const titleSlug = item.title
|
|
@@ -100,39 +134,19 @@ function buildSlug(item) {
|
|
|
100
134
|
.slice(0, 60);
|
|
101
135
|
return `${baseSource}-${tail}`;
|
|
102
136
|
}
|
|
103
|
-
/**
|
|
104
|
-
* Pick `YYYYMMDD` from the item's `publishedAt`, falling back to today (UTC)
|
|
105
|
-
* when the source feed did not provide a publish date.
|
|
106
|
-
*/
|
|
107
137
|
function buildDatePrefix(item, now) {
|
|
108
138
|
const iso = item.publishedAt ?? now.toISOString();
|
|
109
139
|
return iso.slice(0, 10).replace(/-/g, "");
|
|
110
140
|
}
|
|
111
|
-
/**
|
|
112
|
-
* Pick `YYYYMMDD` for digest output. Per ADR-0011 §1, digest filenames use the
|
|
113
|
-
* **generation date** (UTC, CLI invocation time) rather than any item's
|
|
114
|
-
* `publishedAt`. Constituent items rarely share a publish date and the digest
|
|
115
|
-
* is a synthesis artifact, so the generation date is the only stable key.
|
|
116
|
-
*/
|
|
117
141
|
function buildDigestDatePrefix(now) {
|
|
118
142
|
return now.toISOString().slice(0, 10).replace(/-/g, "");
|
|
119
143
|
}
|
|
120
|
-
/**
|
|
121
|
-
* Kebab-case helper used by both digest slug derivation and the clamp routine.
|
|
122
|
-
* Mirrors the algorithm pinned in ADR-0011 §2: lowercase, non-alphanumerics
|
|
123
|
-
* collapse to a single hyphen, leading/trailing hyphens are stripped.
|
|
124
|
-
*/
|
|
125
144
|
function kebabCase(s) {
|
|
126
145
|
return s
|
|
127
146
|
.toLowerCase()
|
|
128
147
|
.replace(/[^a-z0-9]+/g, "-")
|
|
129
148
|
.replace(/^-+|-+$/g, "");
|
|
130
149
|
}
|
|
131
|
-
/**
|
|
132
|
-
* Trim a slug to `max` characters, but never end mid-word: cut at the last
|
|
133
|
-
* hyphen boundary inside the limit so the slug stays readable when truncated
|
|
134
|
-
* (ADR-0011 §2). When no hyphen is present we fall back to a hard cut.
|
|
135
|
-
*/
|
|
136
150
|
function clampSlug(s, max = 60) {
|
|
137
151
|
if (s.length <= max)
|
|
138
152
|
return s;
|
|
@@ -140,26 +154,6 @@ function clampSlug(s, max = 60) {
|
|
|
140
154
|
const lastHyphen = cut.lastIndexOf("-");
|
|
141
155
|
return lastHyphen > 0 ? cut.slice(0, lastHyphen) : cut;
|
|
142
156
|
}
|
|
143
|
-
/**
|
|
144
|
-
* Derive the digest slug from the constituent items' `matchedKeywords`
|
|
145
|
-
* (ADR-0011 §2).
|
|
146
|
-
*
|
|
147
|
-
* Algorithm:
|
|
148
|
-
* 1. Aggregate every item's `matchedKeywords` into a frequency map keyed by
|
|
149
|
-
* the normalized (lowercased, trimmed) keyword. Empty strings are
|
|
150
|
-
* ignored so a noisy `[""]` entry cannot dominate the ranking.
|
|
151
|
-
* 2. Sort by frequency descending, ties broken by lexicographic ascending so
|
|
152
|
-
* the output is deterministic regardless of input item order.
|
|
153
|
-
* 3. Take the top 1-2 entries, kebab-case each, and join with `-`. If no
|
|
154
|
-
* keywords are present we fall back to the literal `"digest"` so the
|
|
155
|
-
* ADR-0011 filename pattern (`<date>_digest_<slug>_v<n>.md`) still
|
|
156
|
-
* produces a recognizable `<date>_digest_digest_v1.md` artifact.
|
|
157
|
-
* 4. Clamp to 60 chars on hyphen boundaries (`clampSlug`).
|
|
158
|
-
*
|
|
159
|
-
* Stable ordering matters because the digest id is content-addressed: the
|
|
160
|
-
* same item set must always yield the same filename so re-runs can detect
|
|
161
|
-
* collisions deterministically.
|
|
162
|
-
*/
|
|
163
157
|
function deriveDigestSlug(items) {
|
|
164
158
|
const freq = new Map();
|
|
165
159
|
for (const item of items) {
|
|
@@ -178,14 +172,6 @@ function deriveDigestSlug(items) {
|
|
|
178
172
|
return "digest";
|
|
179
173
|
return clampSlug(top.map(kebabCase).join("-"));
|
|
180
174
|
}
|
|
181
|
-
/**
|
|
182
|
-
* Locate `items/<sourceId>/<item-id>.yaml` across all source directories,
|
|
183
|
-
* since the CLI only takes `<item-id>` (sourceId is not on the command line).
|
|
184
|
-
*
|
|
185
|
-
* `loadItems` already walks every source subdir, so we delegate to it and
|
|
186
|
-
* then match by id. Returning the full Item (rather than just the path) lets
|
|
187
|
-
* the caller compute slug / date without a second read.
|
|
188
|
-
*/
|
|
189
175
|
async function findItem(cwd, itemId) {
|
|
190
176
|
const itemsDir = join(cwd, "items");
|
|
191
177
|
if (!(await pathExists(itemsDir)))
|
|
@@ -196,15 +182,6 @@ async function findItem(cwd, itemId) {
|
|
|
196
182
|
return null;
|
|
197
183
|
return { item: match };
|
|
198
184
|
}
|
|
199
|
-
/**
|
|
200
|
-
* Locate multiple items at once. We do a single `loadItems` walk and then
|
|
201
|
-
* map the requested ids against the result, preserving the caller's order
|
|
202
|
-
* for the eventual `itemIds` frontmatter array (ADR-0011 keeps no constraint
|
|
203
|
-
* on order, but stable user-supplied order is the least surprising default).
|
|
204
|
-
*
|
|
205
|
-
* Returns `null` for the first missing id so the caller can emit a targeted
|
|
206
|
-
* error rather than walking `items/` repeatedly.
|
|
207
|
-
*/
|
|
208
185
|
async function findItems(cwd, itemIds) {
|
|
209
186
|
const itemsDir = join(cwd, "items");
|
|
210
187
|
if (!(await pathExists(itemsDir)))
|
|
@@ -220,145 +197,41 @@ async function findItems(cwd, itemIds) {
|
|
|
220
197
|
}
|
|
221
198
|
return { items: matched };
|
|
222
199
|
}
|
|
223
|
-
|
|
224
|
-
* Implementation of `radar research <item-id>` (single-item) and
|
|
225
|
-
* `radar research --digest <id1> <id2> ...` (multi-item digest, ADR-0011).
|
|
226
|
-
*
|
|
227
|
-
* High-level flow (Phase 1):
|
|
228
|
-
* 1. Parse + validate args (agent defaults to `claude-code`, template to
|
|
229
|
-
* `default` for single-item or `digest` for `--digest`).
|
|
230
|
-
* 2. Locate `items/<sourceId>/<item-id>.yaml` for each id and parse it.
|
|
231
|
-
* 3. Load `templates/<template-id>.md` (empty body when default is absent).
|
|
232
|
-
* 4. Compute the output path:
|
|
233
|
-
* - single-item: `research/<YYYYMMDD>_<slug>_v1.md` (ADR-0003)
|
|
234
|
-
* - digest: `research/<YYYYMMDD>_digest_<slug>_v1.md` (ADR-0011)
|
|
235
|
-
* Refuse to overwrite an existing file — re-runs go through `update`.
|
|
236
|
-
* 5. Invoke the registered agent adapter; the agent writes the report.
|
|
237
|
-
* 6. Validate the report's frontmatter against `ResearchFrontmatterSchema`.
|
|
238
|
-
* 7. Transition every included item: `status` → `researched` and persist.
|
|
239
|
-
*
|
|
240
|
-
* Any step from 4 onward that fails surfaces a non-zero exit code with a
|
|
241
|
-
* targeted message so the user can debug without re-reading the source.
|
|
242
|
-
*/
|
|
243
|
-
export async function runResearch(args, options = {}) {
|
|
244
|
-
const cwd = options.cwd ?? process.cwd();
|
|
245
|
-
const log = options.io?.log ?? ((m) => console.log(m));
|
|
246
|
-
const warn = options.io?.warn ?? ((m) => console.warn(m));
|
|
247
|
-
const error = options.io?.error ?? ((m) => console.error(m));
|
|
248
|
-
let parsed;
|
|
249
|
-
try {
|
|
250
|
-
parsed = parseArgs(args);
|
|
251
|
-
}
|
|
252
|
-
catch (e) {
|
|
253
|
-
error(`research: ${e instanceof Error ? e.message : String(e)}`);
|
|
254
|
-
return 2;
|
|
255
|
-
}
|
|
256
|
-
if (parsed.help) {
|
|
257
|
-
printHelp(log);
|
|
258
|
-
return 0;
|
|
259
|
-
}
|
|
260
|
-
if (parsed.itemIds.length === 0) {
|
|
261
|
-
error("research: missing <item-id>");
|
|
262
|
-
printHelp(error);
|
|
263
|
-
return 2;
|
|
264
|
-
}
|
|
265
|
-
// Single-item mode: enforce exactly one positional id so the existing
|
|
266
|
-
// behavior of `radar research <id>` is unambiguous. The user must opt into
|
|
267
|
-
// multi-item mode via `--digest`.
|
|
268
|
-
if (!parsed.digest && parsed.itemIds.length > 1) {
|
|
269
|
-
error(`research: multiple <item-id> arguments require --digest (got ${parsed.itemIds.length}: ${parsed.itemIds.join(", ")})`);
|
|
270
|
-
return 2;
|
|
271
|
-
}
|
|
272
|
-
// Digest mode: must bundle 2+ items per ADR-0011 §1 (a single-item digest
|
|
273
|
-
// is indistinguishable from a regular research run and would muddy the ID
|
|
274
|
-
// space). Reject early with exit 2 (argument error).
|
|
275
|
-
if (parsed.digest && parsed.itemIds.length < 2) {
|
|
276
|
-
error(`research: --digest requires 2 or more <item-id> arguments (got ${parsed.itemIds.length})`);
|
|
277
|
-
return 2;
|
|
278
|
-
}
|
|
279
|
-
// Resolve the agent honoring the priority chain:
|
|
280
|
-
// explicit --agent > radar.config.yaml defaultResearchAgent > "claude-code"
|
|
281
|
-
// The explicit value is validated against AgentIdSchema first so a bogus
|
|
282
|
-
// --agent never reaches the config / fallback path.
|
|
200
|
+
async function resolveAgent(cwd, rawAgent, error) {
|
|
283
201
|
let explicitAgent;
|
|
284
|
-
if (
|
|
285
|
-
const agentResult = AgentIdSchema.safeParse(
|
|
202
|
+
if (rawAgent !== undefined) {
|
|
203
|
+
const agentResult = AgentIdSchema.safeParse(rawAgent);
|
|
286
204
|
if (!agentResult.success) {
|
|
287
|
-
error(`research: invalid --agent '${
|
|
288
|
-
return 2;
|
|
205
|
+
error(`research: invalid --agent '${rawAgent}' (expected: claude-code | codex-cli | gemini-cli | copilot)`);
|
|
206
|
+
return { exitCode: 2 };
|
|
289
207
|
}
|
|
290
208
|
explicitAgent = agentResult.data;
|
|
291
209
|
}
|
|
292
|
-
let agent;
|
|
293
210
|
try {
|
|
294
211
|
const config = await loadRadarConfig(cwd);
|
|
295
|
-
agent = await getDefaultAgent("research", {
|
|
212
|
+
const agent = await getDefaultAgent("research", {
|
|
213
|
+
explicit: explicitAgent,
|
|
214
|
+
configOverride: config,
|
|
215
|
+
});
|
|
216
|
+
return { agent };
|
|
296
217
|
}
|
|
297
218
|
catch (e) {
|
|
298
219
|
if (e instanceof RadarConfigError) {
|
|
299
220
|
error(`research: ${e.message}`);
|
|
300
|
-
return 2;
|
|
221
|
+
return { exitCode: 2 };
|
|
301
222
|
}
|
|
302
223
|
throw e;
|
|
303
224
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
// Default template depends on mode: ADR-0011 §6 pins `digest` as the digest
|
|
308
|
-
// templateId so the bundled `templates/digest.md` is picked up automatically
|
|
309
|
-
// when the user doesn't pass `--template`.
|
|
310
|
-
const templateId = parsed.template ?? (parsed.digest ? "digest" : "default");
|
|
311
|
-
// Locate the item(s). Digest mode collects every id up front so a missing id
|
|
312
|
-
// fails before we invoke the agent (cheap fail-fast vs. burning tokens).
|
|
313
|
-
let items;
|
|
314
|
-
if (parsed.digest) {
|
|
315
|
-
const result = await findItems(cwd, parsed.itemIds);
|
|
316
|
-
if ("missing" in result) {
|
|
317
|
-
error(`research: item '${result.missing}' not found under items/`);
|
|
318
|
-
return 1;
|
|
319
|
-
}
|
|
320
|
-
items = result.items;
|
|
321
|
-
// ADR-0011 §5: a `dismissed` item must not appear in a digest. Validate
|
|
322
|
-
// up-front so the user can either remove the id or re-detect the source
|
|
323
|
-
// before re-running.
|
|
324
|
-
const dismissed = items.filter((i) => i.status === "dismissed");
|
|
325
|
-
if (dismissed.length > 0) {
|
|
326
|
-
error(`research: cannot include dismissed items in a digest: ${dismissed.map((i) => i.id).join(", ")}`);
|
|
327
|
-
return 1;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
else {
|
|
331
|
-
const found = await findItem(cwd, parsed.itemIds[0]);
|
|
332
|
-
if (!found) {
|
|
333
|
-
error(`research: item '${parsed.itemIds[0]}' not found under items/`);
|
|
334
|
-
return 1;
|
|
335
|
-
}
|
|
336
|
-
items = [found.item];
|
|
337
|
-
}
|
|
338
|
-
// Surface any prompt-injection pre-filter hits recorded by the watcher
|
|
339
|
-
// (ADR-0009 M1a / M5a — Adopt). Audit-only: the agent still runs against
|
|
340
|
-
// the original content, but the user gets an explicit warning so they can
|
|
341
|
-
// `radar dismiss` and re-evaluate before committing tokens.
|
|
225
|
+
}
|
|
226
|
+
async function processResearchInvocation(params) {
|
|
227
|
+
const { cwd, items, digest, agent, templateId, template, now, log, warn, error, progress } = params;
|
|
342
228
|
for (const item of items) {
|
|
343
229
|
if (item.injectionFlags.length > 0) {
|
|
344
230
|
warn(`research: item '${item.id}' has ${item.injectionFlags.length} injection flag(s): ${item.injectionFlags.join(", ")} (audit-only; use \`radar dismiss\` to skip)`);
|
|
345
231
|
}
|
|
346
232
|
}
|
|
347
|
-
// Load template.
|
|
348
|
-
const templatesDir = join(cwd, "templates");
|
|
349
|
-
let template;
|
|
350
|
-
try {
|
|
351
|
-
template = await loadTemplate(templateId, templatesDir);
|
|
352
|
-
}
|
|
353
|
-
catch (e) {
|
|
354
|
-
error(`research: ${e instanceof Error ? e.message : String(e)}`);
|
|
355
|
-
return 1;
|
|
356
|
-
}
|
|
357
|
-
// Compute output path. Refuse to overwrite an existing file — re-runs go
|
|
358
|
-
// through `radar update` per ADR-0003 / ADR-0011 (immutable history).
|
|
359
|
-
const now = new Date();
|
|
360
233
|
let filename;
|
|
361
|
-
if (
|
|
234
|
+
if (digest) {
|
|
362
235
|
const datePrefix = buildDigestDatePrefix(now);
|
|
363
236
|
const slug = deriveDigestSlug(items);
|
|
364
237
|
filename = `${datePrefix}_digest_${slug}_v1.md`;
|
|
@@ -374,14 +247,26 @@ export async function runResearch(args, options = {}) {
|
|
|
374
247
|
error(`research: ${outputPath} already exists (use \`radar update\` to re-research)`);
|
|
375
248
|
return 1;
|
|
376
249
|
}
|
|
377
|
-
const itemDescription =
|
|
250
|
+
const itemDescription = digest
|
|
378
251
|
? `${items.length} items (${items.map((i) => i.id).join(", ")})`
|
|
379
252
|
: `item '${items[0].id}'`;
|
|
253
|
+
// Phase marker: items resolved (ADR-0015 D4 "Loaded <noun>"). One marker
|
|
254
|
+
// per invocation regardless of digest cardinality so the progress stream
|
|
255
|
+
// stays uniform between single / digest / batch modes.
|
|
256
|
+
progress.phase(digest ? `Loaded ${items.length} items` : `Loaded item: ${items[0].id}`, items.map((i) => i.id).join(", "));
|
|
257
|
+
// Phase marker: template resolved. Echoes the actual template id so a
|
|
258
|
+
// user running `--template deep-dive` sees the value flow through.
|
|
259
|
+
progress.phase(`Loaded template: ${templateId}.md`);
|
|
380
260
|
log(`research: invoking ${agent} adapter for ${itemDescription} -> ${filename}`);
|
|
381
|
-
// Invoke adapter. The multi-item signature lands via #140; the adapter
|
|
382
|
-
// already handles `items.length === 1` byte-equivalently for single-item
|
|
383
|
-
// callers so no branching is needed here.
|
|
384
261
|
const adapter = getAgentAdapter(agent);
|
|
262
|
+
// Phase marker + spinner: agent spawn. We pair `phase("Spawning …")` with
|
|
263
|
+
// `start("Agent running…")` so the marker is printed once for scrollback
|
|
264
|
+
// and the spinner row carries the live `[mm:ss]` heartbeat + metrics.
|
|
265
|
+
progress.phase(`Spawning ${agent}`, `cwd: ${cwd}`);
|
|
266
|
+
progress.start("Agent running");
|
|
267
|
+
const adapterStartedAt = Date.now();
|
|
268
|
+
const polling = pollOutputFileSize({ path: outputPath, reporter: progress });
|
|
269
|
+
let adapterExitCode = 0;
|
|
385
270
|
try {
|
|
386
271
|
await adapter.research({
|
|
387
272
|
agent,
|
|
@@ -390,13 +275,22 @@ export async function runResearch(args, options = {}) {
|
|
|
390
275
|
items,
|
|
391
276
|
outputPath,
|
|
392
277
|
cwd,
|
|
278
|
+
onProgress: buildAgentProgressCallback(progress),
|
|
393
279
|
});
|
|
394
280
|
}
|
|
395
281
|
catch (e) {
|
|
282
|
+
adapterExitCode = 1;
|
|
283
|
+
polling.stop();
|
|
284
|
+
progress.fail("Agent failed", e instanceof Error ? e.message : String(e));
|
|
396
285
|
error(`research: adapter failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
397
286
|
return 1;
|
|
398
287
|
}
|
|
399
|
-
|
|
288
|
+
finally {
|
|
289
|
+
polling.stop();
|
|
290
|
+
}
|
|
291
|
+
if (adapterExitCode === 0) {
|
|
292
|
+
progress.succeed(`Agent completed (exit ${adapterExitCode})`, Date.now() - adapterStartedAt);
|
|
293
|
+
}
|
|
400
294
|
if (!(await pathExists(outputPath))) {
|
|
401
295
|
error(`research: adapter completed but did not write ${outputPath} (agent ignored the output path?)`);
|
|
402
296
|
return 1;
|
|
@@ -425,11 +319,10 @@ export async function runResearch(args, options = {}) {
|
|
|
425
319
|
}
|
|
426
320
|
return 1;
|
|
427
321
|
}
|
|
428
|
-
// Phase
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
// `update` command (Sub-issue B / #41) is the only writer that may set it.
|
|
322
|
+
// Phase marker: schema check passed. Emitted before the status transition
|
|
323
|
+
// so the user sees the validation outcome separately from the items.yaml
|
|
324
|
+
// write that follows.
|
|
325
|
+
progress.phase("Frontmatter validated");
|
|
433
326
|
const reviewedDrift = fmResult.data.reviewedAt !== null || fmResult.data.reviewedBy !== null;
|
|
434
327
|
const supersedesDrift = fmResult.data.supersedes !== null;
|
|
435
328
|
if (reviewedDrift || supersedesDrift) {
|
|
@@ -439,8 +332,8 @@ export async function runResearch(args, options = {}) {
|
|
|
439
332
|
if (supersedesDrift) {
|
|
440
333
|
warn("research: agent populated supersedes; resetting to null (Phase 5 contract — v1 has no predecessor; `update` writes supersedes)");
|
|
441
334
|
}
|
|
442
|
-
const
|
|
443
|
-
const rewritten = matter.stringify(
|
|
335
|
+
const parsedReport = matter(body, matterOptions);
|
|
336
|
+
const rewritten = matter.stringify(parsedReport.content, {
|
|
444
337
|
...fmResult.data,
|
|
445
338
|
reviewedAt: null,
|
|
446
339
|
reviewedBy: null,
|
|
@@ -448,14 +341,8 @@ export async function runResearch(args, options = {}) {
|
|
|
448
341
|
}, matterOptions);
|
|
449
342
|
await writeFile(outputPath, rewritten, "utf8");
|
|
450
343
|
}
|
|
451
|
-
// Transition item status. ADR-0011 §5: every included item transitions
|
|
452
|
-
// `detected` → `researched`; terminal states (`researched` / `reviewed`)
|
|
453
|
-
// are protected and pass through unchanged so a digest that re-includes
|
|
454
|
-
// an already-researched item does not regress its status.
|
|
455
344
|
const updated = items.map((item) => item.status === "detected" ? { ...item, status: "researched" } : item);
|
|
456
345
|
try {
|
|
457
|
-
// saveItems writes by sourceId+id, so it will overwrite the existing file
|
|
458
|
-
// in place. We rely on this rather than constructing the path manually.
|
|
459
346
|
await saveItems(join(cwd, "items"), updated);
|
|
460
347
|
}
|
|
461
348
|
catch (e) {
|
|
@@ -466,11 +353,239 @@ export async function runResearch(args, options = {}) {
|
|
|
466
353
|
log(`research: wrote ${outputPath}`);
|
|
467
354
|
for (const item of updated) {
|
|
468
355
|
if (item.status === "researched") {
|
|
356
|
+
// Phase marker: status transition. We emit one phase per item rather
|
|
357
|
+
// than collapsing them so the digest case stays explicit about what
|
|
358
|
+
// moved. The arrow uses U+2192 (→) per ADR-0015 D4 examples.
|
|
359
|
+
progress.phase(`Status: detected → researched`, `items/${item.sourceId}/${item.id}.yaml`);
|
|
469
360
|
log(`research: items/${item.sourceId}/${item.id}.yaml status -> researched`);
|
|
470
361
|
}
|
|
471
362
|
}
|
|
472
363
|
return 0;
|
|
473
364
|
}
|
|
365
|
+
function parseMaxItems(raw, error) {
|
|
366
|
+
if (raw === undefined)
|
|
367
|
+
return RESEARCH_BATCH_DEFAULT_MAX_ITEMS;
|
|
368
|
+
if (!/^[0-9]+$/.test(raw)) {
|
|
369
|
+
error(`research: invalid --max-items '${raw}' (expected positive integer)`);
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
const n = Number.parseInt(raw, 10);
|
|
373
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
374
|
+
error(`research: invalid --max-items '${raw}' (must be > 0)`);
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
return n;
|
|
378
|
+
}
|
|
379
|
+
function parseFilterTags(raw) {
|
|
380
|
+
if (raw === undefined || raw.trim() === "")
|
|
381
|
+
return [];
|
|
382
|
+
return [
|
|
383
|
+
...new Set(raw
|
|
384
|
+
.split(",")
|
|
385
|
+
.map((s) => s.trim().toLowerCase())
|
|
386
|
+
.filter((s) => s.length > 0)),
|
|
387
|
+
];
|
|
388
|
+
}
|
|
389
|
+
async function runResearchBatch(parsed, cwd, log, warn, error, progress) {
|
|
390
|
+
if (parsed.itemIds.length > 0) {
|
|
391
|
+
error(`research: --batch is incompatible with positional <item-id> arguments (got ${parsed.itemIds.length})`);
|
|
392
|
+
return 2;
|
|
393
|
+
}
|
|
394
|
+
if (parsed.digest) {
|
|
395
|
+
error("research: --batch is incompatible with --digest");
|
|
396
|
+
return 2;
|
|
397
|
+
}
|
|
398
|
+
const rawStatus = parsed.status ?? "detected";
|
|
399
|
+
const statusResult = ItemStatusSchema.safeParse(rawStatus);
|
|
400
|
+
if (!statusResult.success) {
|
|
401
|
+
error(`research: invalid --status '${rawStatus}' (expected: detected | dismissed | researched | reviewed)`);
|
|
402
|
+
return 2;
|
|
403
|
+
}
|
|
404
|
+
const status = statusResult.data;
|
|
405
|
+
const maxItems = parseMaxItems(parsed.maxItems, error);
|
|
406
|
+
if (maxItems === null)
|
|
407
|
+
return 2;
|
|
408
|
+
const filterTags = parseFilterTags(parsed.filterTags);
|
|
409
|
+
const agentResult = await resolveAgent(cwd, parsed.agent, error);
|
|
410
|
+
if ("exitCode" in agentResult)
|
|
411
|
+
return agentResult.exitCode;
|
|
412
|
+
const agent = agentResult.agent;
|
|
413
|
+
const templateId = parsed.template ?? "default";
|
|
414
|
+
const templatesDir = join(cwd, "templates");
|
|
415
|
+
let template;
|
|
416
|
+
try {
|
|
417
|
+
template = await loadTemplate(templateId, templatesDir);
|
|
418
|
+
}
|
|
419
|
+
catch (e) {
|
|
420
|
+
error(`research: ${e instanceof Error ? e.message : String(e)}`);
|
|
421
|
+
return 1;
|
|
422
|
+
}
|
|
423
|
+
const itemsDir = join(cwd, "items");
|
|
424
|
+
const all = await loadItems(itemsDir);
|
|
425
|
+
const lowerFilterTags = filterTags;
|
|
426
|
+
const matches = all
|
|
427
|
+
.filter((it) => it.status === status)
|
|
428
|
+
.filter((it) => {
|
|
429
|
+
if (lowerFilterTags.length === 0)
|
|
430
|
+
return true;
|
|
431
|
+
const haystack = new Set(it.matchedKeywords.map((k) => k.toLowerCase()));
|
|
432
|
+
return lowerFilterTags.some((t) => haystack.has(t));
|
|
433
|
+
})
|
|
434
|
+
.sort((a, b) => {
|
|
435
|
+
const ap = a.publishedAt ?? a.fetchedAt;
|
|
436
|
+
const bp = b.publishedAt ?? b.fetchedAt;
|
|
437
|
+
if (ap !== bp)
|
|
438
|
+
return ap < bp ? -1 : 1;
|
|
439
|
+
return a.id.localeCompare(b.id);
|
|
440
|
+
});
|
|
441
|
+
if (matches.length === 0) {
|
|
442
|
+
log(`research: no items matched --batch filters (status=${status}${filterTags.length > 0 ? `, tags=${filterTags.join(",")}` : ""})`);
|
|
443
|
+
return 0;
|
|
444
|
+
}
|
|
445
|
+
let selected = matches;
|
|
446
|
+
if (matches.length > maxItems) {
|
|
447
|
+
const dropped = matches.length - maxItems;
|
|
448
|
+
warn(`research: --max-items ${maxItems} cap reached; dropping ${dropped} excess item(s) (matched ${matches.length})`);
|
|
449
|
+
selected = matches.slice(0, maxItems);
|
|
450
|
+
}
|
|
451
|
+
log(`research: --batch will process ${selected.length} item(s) (status=${status}${filterTags.length > 0 ? `, tags=${filterTags.join(",")}` : ""}, agent=${agent}, cap=${maxItems})`);
|
|
452
|
+
const now = new Date();
|
|
453
|
+
for (const item of selected) {
|
|
454
|
+
const exitCode = await processResearchInvocation({
|
|
455
|
+
cwd,
|
|
456
|
+
items: [item],
|
|
457
|
+
digest: false,
|
|
458
|
+
agent,
|
|
459
|
+
templateId,
|
|
460
|
+
template,
|
|
461
|
+
now,
|
|
462
|
+
log,
|
|
463
|
+
warn,
|
|
464
|
+
error,
|
|
465
|
+
progress,
|
|
466
|
+
});
|
|
467
|
+
if (exitCode !== 0) {
|
|
468
|
+
error(`research: --batch halted on item '${item.id}' (exit ${exitCode})`);
|
|
469
|
+
return exitCode;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
log(`research: --batch completed ${selected.length} item(s)`);
|
|
473
|
+
return 0;
|
|
474
|
+
}
|
|
475
|
+
export async function runResearch(args, options = {}) {
|
|
476
|
+
const cwd = options.cwd ?? process.cwd();
|
|
477
|
+
const log = options.io?.log ?? ((m) => console.log(m));
|
|
478
|
+
const warn = options.io?.warn ?? ((m) => console.warn(m));
|
|
479
|
+
const error = options.io?.error ?? ((m) => console.error(m));
|
|
480
|
+
// Strip the global --verbose / --quiet flags BEFORE the command-specific
|
|
481
|
+
// parser sees argv. This keeps `parseArgs` ignorant of progress concerns
|
|
482
|
+
// (and means a future `radar review --verbose` works the same way without
|
|
483
|
+
// duplicating flag plumbing per command).
|
|
484
|
+
let progressState;
|
|
485
|
+
try {
|
|
486
|
+
progressState = parseProgressFlags(args);
|
|
487
|
+
}
|
|
488
|
+
catch (e) {
|
|
489
|
+
if (e instanceof ProgressFlagError) {
|
|
490
|
+
error(`research: ${e.message}`);
|
|
491
|
+
return 2;
|
|
492
|
+
}
|
|
493
|
+
throw e;
|
|
494
|
+
}
|
|
495
|
+
// Tests inject a reporter directly; production constructs one from the
|
|
496
|
+
// flag state. Either way the per-invocation state stays local — there is
|
|
497
|
+
// no shared global reporter, so concurrent CLI invocations are isolated.
|
|
498
|
+
const progress = options.progress ?? buildReporter({ level: progressState.level });
|
|
499
|
+
let parsed;
|
|
500
|
+
try {
|
|
501
|
+
parsed = parseArgs(progressState.rest);
|
|
502
|
+
}
|
|
503
|
+
catch (e) {
|
|
504
|
+
error(`research: ${e instanceof Error ? e.message : String(e)}`);
|
|
505
|
+
return 2;
|
|
506
|
+
}
|
|
507
|
+
if (parsed.help) {
|
|
508
|
+
printHelp(log);
|
|
509
|
+
return 0;
|
|
510
|
+
}
|
|
511
|
+
if (parsed.batch) {
|
|
512
|
+
return runResearchBatch(parsed, cwd, log, warn, error, progress);
|
|
513
|
+
}
|
|
514
|
+
if (parsed.status !== undefined) {
|
|
515
|
+
error("research: --status requires --batch");
|
|
516
|
+
return 2;
|
|
517
|
+
}
|
|
518
|
+
if (parsed.maxItems !== undefined) {
|
|
519
|
+
error("research: --max-items requires --batch");
|
|
520
|
+
return 2;
|
|
521
|
+
}
|
|
522
|
+
if (parsed.filterTags !== undefined) {
|
|
523
|
+
error("research: --filter-tags requires --batch");
|
|
524
|
+
return 2;
|
|
525
|
+
}
|
|
526
|
+
if (parsed.itemIds.length === 0) {
|
|
527
|
+
error("research: missing <item-id>");
|
|
528
|
+
printHelp(error);
|
|
529
|
+
return 2;
|
|
530
|
+
}
|
|
531
|
+
if (!parsed.digest && parsed.itemIds.length > 1) {
|
|
532
|
+
error(`research: multiple <item-id> arguments require --digest (got ${parsed.itemIds.length}: ${parsed.itemIds.join(", ")})`);
|
|
533
|
+
return 2;
|
|
534
|
+
}
|
|
535
|
+
if (parsed.digest && parsed.itemIds.length < 2) {
|
|
536
|
+
error(`research: --digest requires 2 or more <item-id> arguments (got ${parsed.itemIds.length})`);
|
|
537
|
+
return 2;
|
|
538
|
+
}
|
|
539
|
+
const agentResult = await resolveAgent(cwd, parsed.agent, error);
|
|
540
|
+
if ("exitCode" in agentResult)
|
|
541
|
+
return agentResult.exitCode;
|
|
542
|
+
const agent = agentResult.agent;
|
|
543
|
+
const templateId = parsed.template ?? (parsed.digest ? "digest" : "default");
|
|
544
|
+
let items;
|
|
545
|
+
if (parsed.digest) {
|
|
546
|
+
const result = await findItems(cwd, parsed.itemIds);
|
|
547
|
+
if ("missing" in result) {
|
|
548
|
+
error(`research: item '${result.missing}' not found under items/`);
|
|
549
|
+
return 1;
|
|
550
|
+
}
|
|
551
|
+
items = result.items;
|
|
552
|
+
const dismissed = items.filter((i) => i.status === "dismissed");
|
|
553
|
+
if (dismissed.length > 0) {
|
|
554
|
+
error(`research: cannot include dismissed items in a digest: ${dismissed.map((i) => i.id).join(", ")}`);
|
|
555
|
+
return 1;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
const found = await findItem(cwd, parsed.itemIds[0]);
|
|
560
|
+
if (!found) {
|
|
561
|
+
error(`research: item '${parsed.itemIds[0]}' not found under items/`);
|
|
562
|
+
return 1;
|
|
563
|
+
}
|
|
564
|
+
items = [found.item];
|
|
565
|
+
}
|
|
566
|
+
const templatesDir = join(cwd, "templates");
|
|
567
|
+
let template;
|
|
568
|
+
try {
|
|
569
|
+
template = await loadTemplate(templateId, templatesDir);
|
|
570
|
+
}
|
|
571
|
+
catch (e) {
|
|
572
|
+
error(`research: ${e instanceof Error ? e.message : String(e)}`);
|
|
573
|
+
return 1;
|
|
574
|
+
}
|
|
575
|
+
return processResearchInvocation({
|
|
576
|
+
cwd,
|
|
577
|
+
items,
|
|
578
|
+
digest: parsed.digest ?? false,
|
|
579
|
+
agent,
|
|
580
|
+
templateId,
|
|
581
|
+
template,
|
|
582
|
+
now: new Date(),
|
|
583
|
+
log,
|
|
584
|
+
warn,
|
|
585
|
+
error,
|
|
586
|
+
progress,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
474
589
|
export const researchCommand = {
|
|
475
590
|
name: "research",
|
|
476
591
|
summary: "Generate Markdown research reports from items via an AI agent",
|