akm-cli 0.7.4 → 0.7.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.
Files changed (37) hide show
  1. package/{CHANGELOG.md → .github/CHANGELOG.md} +33 -0
  2. package/.github/LICENSE +374 -0
  3. package/dist/cli.js +241 -170
  4. package/dist/commands/curate.js +1 -0
  5. package/dist/commands/distill.js +14 -4
  6. package/dist/commands/events.js +10 -1
  7. package/dist/commands/migration-help.js +2 -2
  8. package/dist/commands/propose.js +36 -16
  9. package/dist/commands/reflect.js +40 -14
  10. package/dist/commands/remember.js +1 -1
  11. package/dist/commands/show.js +19 -44
  12. package/dist/commands/vault.js +5 -10
  13. package/dist/core/asset-registry.js +1 -1
  14. package/dist/core/asset-spec.js +1 -1
  15. package/dist/core/config.js +13 -0
  16. package/dist/core/events.js +19 -2
  17. package/dist/indexer/db-search.js +35 -235
  18. package/dist/indexer/db.js +15 -5
  19. package/dist/indexer/ensure-index.js +72 -0
  20. package/dist/indexer/graph-extraction.js +10 -0
  21. package/dist/indexer/indexer.js +38 -22
  22. package/dist/integrations/agent/prompts.js +95 -15
  23. package/dist/integrations/agent/spawn.js +65 -12
  24. package/dist/llm/client.js +40 -2
  25. package/dist/llm/graph-extract.js +2 -4
  26. package/dist/llm/memory-infer.js +7 -4
  27. package/dist/output/cli-hints.js +17 -8
  28. package/dist/output/renderers.js +6 -1
  29. package/dist/output/shapes.js +8 -3
  30. package/dist/output/text.js +18 -19
  31. package/dist/sources/providers/git.js +43 -1
  32. package/dist/workflows/db.js +9 -0
  33. package/dist/workflows/runs.js +25 -8
  34. package/dist/workflows/scope-key.js +76 -0
  35. package/docs/migration/release-notes/0.7.4.md +1 -1
  36. package/docs/migration/release-notes/0.7.5.md +20 -0
  37. package/package.json +2 -2
@@ -152,7 +152,12 @@ export async function akmDistill(options) {
152
152
  catch {
153
153
  assetContent = null;
154
154
  }
155
- const { events } = readEventsImpl({ ref: inputRef, type: "feedback" });
155
+ const { events } = readEventsImpl({
156
+ ref: inputRef,
157
+ type: "feedback",
158
+ excludeTags: options.excludeTags,
159
+ includeTags: options.includeTags,
160
+ });
156
161
  // #267 — feedback exclusion. Filter events whose `ref` matches the
157
162
  // exclusion list BEFORE the prompt is built. The original event stream
158
163
  // is never mutated; only the `feedback` slice that reaches the LLM is
@@ -274,10 +279,15 @@ async function defaultLookup(ref) {
274
279
  }
275
280
  /** Best-effort fence stripping. Keeps the body intact when no fence is present. */
276
281
  function stripMarkdownFences(raw) {
277
- const trimmed = raw.trim();
282
+ // Strip <think>…</think> reasoning blocks first — local LLMs (e.g. Qwen3)
283
+ // emit these before the content, which breaks YAML frontmatter detection.
284
+ const stripped = raw
285
+ .trim()
286
+ .replace(/<think>[\s\S]*?<\/think>/gi, "")
287
+ .trim();
278
288
  // Only strip outer triple-fence pairs — leave inner code blocks alone.
279
- const fence = trimmed.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```\s*$/i);
289
+ const fence = stripped.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```\s*$/i);
280
290
  if (fence)
281
291
  return fence[1].trim();
282
- return trimmed;
292
+ return stripped;
283
293
  }
@@ -67,7 +67,14 @@ function normalizeSince(since) {
67
67
  export function akmEventsList(options = {}) {
68
68
  const ref = validateRef(options.ref);
69
69
  const parsed = parseSinceFlag(options.since);
70
- const result = readEvents({ since: parsed.since, sinceOffset: parsed.sinceOffset, type: options.type, ref }, options.ctx);
70
+ const result = readEvents({
71
+ since: parsed.since,
72
+ sinceOffset: parsed.sinceOffset,
73
+ type: options.type,
74
+ ref,
75
+ excludeTags: options.excludeTags,
76
+ includeTags: options.includeTags,
77
+ }, options.ctx);
71
78
  return {
72
79
  schemaVersion: 1,
73
80
  totalCount: result.events.length,
@@ -92,6 +99,8 @@ export async function akmEventsTail(options = {}) {
92
99
  maxEvents: options.maxEvents,
93
100
  signal: options.signal,
94
101
  onEvent: options.onEvent,
102
+ excludeTags: options.excludeTags,
103
+ includeTags: options.includeTags,
95
104
  };
96
105
  const result = await tailEvents(tailOptions, options.ctx);
97
106
  return {
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- const CHANGELOG_URL = "https://github.com/itlackey/akm/blob/main/CHANGELOG.md";
3
+ const CHANGELOG_URL = "https://github.com/itlackey/akm/blob/main/.github/CHANGELOG.md";
4
4
  const MIGRATION_DOC_URL = "https://github.com/itlackey/akm/blob/main/docs/migration/v0.5-to-v0.6.md";
5
5
  /**
6
6
  * Directory containing per-version release notes. Resolved relative to
@@ -14,7 +14,7 @@ function releaseNotesDir() {
14
14
  }
15
15
  function loadChangelog() {
16
16
  try {
17
- const changelogPath = path.resolve(import.meta.dir, "../../CHANGELOG.md");
17
+ const changelogPath = path.resolve(import.meta.dir, "../../.github/CHANGELOG.md");
18
18
  if (fs.existsSync(changelogPath)) {
19
19
  return fs.readFileSync(changelogPath, "utf8");
20
20
  }
@@ -78,14 +78,21 @@ export async function akmPropose(options) {
78
78
  throw err;
79
79
  }
80
80
  // 3. Build prompt.
81
+ // Synthesize a temp draft path so opencode can write the asset content
82
+ // directly using its file tools rather than returning JSON via stdout.
83
+ const draftFilePath = import("node:os").then((os) => import("node:path").then((path) => path.join(os.tmpdir(), `akm-propose-${options.type}-${options.name.replace(/[^a-z0-9_-]/gi, "_")}-${Date.now()}.md`)));
84
+ const resolvedDraftPath = await draftFilePath;
81
85
  const prompt = buildProposePrompt({
82
86
  type: options.type,
83
87
  name: options.name,
84
88
  task: options.task,
89
+ draftFilePath: resolvedDraftPath,
85
90
  });
86
91
  // 4. Spawn the agent.
92
+ // Real agent runs use interactive mode so file tools can write the draft.
93
+ // Injected/custom spawns still need captured stdout for JSON payload tests.
87
94
  const runOptions = {
88
- stdio: "captured",
95
+ stdio: options.runAgentOptions?.spawn ? "captured" : "interactive",
89
96
  parseOutput: "text",
90
97
  ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
91
98
  ...(options.runAgentOptions ?? {}),
@@ -94,24 +101,37 @@ export async function akmPropose(options) {
94
101
  if (!result.ok) {
95
102
  return failureEnvelope(result, options.type, options.name);
96
103
  }
97
- // 5. Parse the structured response.
104
+ // 5. Resolve the proposal content.
105
+ // Path A: opencode wrote the draft file — read it directly (no stdout parse).
106
+ // Path B: fallback to stdout JSON parse for non-file-writing agents.
107
+ const fs = await import("node:fs");
98
108
  let payload;
99
- try {
100
- payload = parseAgentProposalPayload(result.stdout);
101
- }
102
- catch (err) {
103
- return {
104
- schemaVersion: 1,
105
- ok: false,
106
- reason: "parse_error",
107
- error: err instanceof Error ? err.message : String(err),
108
- type: options.type,
109
- name: options.name,
110
- exitCode: result.exitCode,
111
- stdout: result.stdout,
112
- ...(result.stderr ? { stderr: result.stderr } : {}),
109
+ if (fs.existsSync(resolvedDraftPath)) {
110
+ const draftContent = fs.readFileSync(resolvedDraftPath, "utf8");
111
+ fs.unlinkSync(resolvedDraftPath);
112
+ payload = {
113
+ ref: `${options.type}:${options.name}`,
114
+ content: draftContent,
113
115
  };
114
116
  }
117
+ else {
118
+ try {
119
+ payload = parseAgentProposalPayload(result.stdout ?? "");
120
+ }
121
+ catch (err) {
122
+ return {
123
+ schemaVersion: 1,
124
+ ok: false,
125
+ reason: "parse_error",
126
+ error: err instanceof Error ? err.message : String(err),
127
+ type: options.type,
128
+ name: options.name,
129
+ exitCode: result.exitCode,
130
+ stdout: result.stdout,
131
+ ...(result.stderr ? { stderr: result.stderr } : {}),
132
+ };
133
+ }
134
+ }
115
135
  // 6. Insert the proposal. Note: we allow the agent's `ref` to normalise the
116
136
  // asset name (e.g. path-cleanup), but only after validating that the ref is
117
137
  // well-formed and the type still matches the requested type.
@@ -68,6 +68,24 @@ function buildSchemaHints(type, content) {
68
68
  const report = lintLessonContent(content, "reflect");
69
69
  return report.findings.map((f) => `[${f.kind}] ${f.message}`);
70
70
  }
71
+ function fallbackPayloadFromRawContent(stdout, ref) {
72
+ if (!ref)
73
+ return undefined;
74
+ const trimmed = stripMarkdownFence(stdout).trim();
75
+ if (!trimmed)
76
+ return undefined;
77
+ if (!looksLikeAssetContent(trimmed))
78
+ return undefined;
79
+ return { ref, content: trimmed };
80
+ }
81
+ function stripMarkdownFence(stdout) {
82
+ const trimmed = stdout.trim();
83
+ const match = trimmed.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```$/i);
84
+ return match?.[1] ?? trimmed;
85
+ }
86
+ function looksLikeAssetContent(value) {
87
+ return value.startsWith("#") || value.startsWith("---");
88
+ }
71
89
  function loadAgentConfigFromDisk() {
72
90
  const config = loadConfig();
73
91
  return parseAgentConfig(config.agent);
@@ -130,6 +148,9 @@ export async function akmReflect(options = {}) {
130
148
  throw err;
131
149
  }
132
150
  // 4. Build the prompt.
151
+ // Keep reflect on the same captured JSON path the bench harness already
152
+ // uses successfully. The draft-file interactive path proved brittle with
153
+ // local opencode models and caused proposal generation failures.
133
154
  const feedback = readRecentFeedback(options.ref);
134
155
  const schemaHints = buildSchemaHints(parsedRef?.type ?? "", assetContent);
135
156
  const prompt = buildReflectPrompt({
@@ -141,8 +162,7 @@ export async function akmReflect(options = {}) {
141
162
  ...(schemaHints.length > 0 ? { schemaHints } : {}),
142
163
  ...(options.task ? { task: options.task } : {}),
143
164
  });
144
- // 5. Spawn the agent. Force captured stdio + JSON parse so we can extract
145
- // the structured payload without confusing terminal control codes.
165
+ // 5. Spawn the agent.
146
166
  const runOptions = {
147
167
  stdio: "captured",
148
168
  parseOutput: "text",
@@ -153,22 +173,28 @@ export async function akmReflect(options = {}) {
153
173
  if (!result.ok) {
154
174
  return failureEnvelope(result, options.ref);
155
175
  }
156
- // 6. Parse stdout into a proposal payload.
176
+ // 6. Resolve the proposal content from stdout JSON.
157
177
  let payload;
158
178
  try {
159
- payload = parseAgentProposalPayload(result.stdout);
179
+ payload = parseAgentProposalPayload(result.stdout ?? "");
160
180
  }
161
181
  catch (err) {
162
- return {
163
- schemaVersion: 1,
164
- ok: false,
165
- reason: "parse_error",
166
- error: err instanceof Error ? err.message : String(err),
167
- ...(options.ref ? { ref: options.ref } : {}),
168
- exitCode: result.exitCode,
169
- stdout: result.stdout,
170
- ...(result.stderr ? { stderr: result.stderr } : {}),
171
- };
182
+ const fallback = fallbackPayloadFromRawContent(result.stdout ?? "", options.ref);
183
+ if (fallback) {
184
+ payload = fallback;
185
+ }
186
+ else {
187
+ return {
188
+ schemaVersion: 1,
189
+ ok: false,
190
+ reason: "parse_error",
191
+ error: err instanceof Error ? err.message : String(err),
192
+ ...(options.ref ? { ref: options.ref } : {}),
193
+ exitCode: result.exitCode,
194
+ stdout: result.stdout,
195
+ ...(result.stderr ? { stderr: result.stderr } : {}),
196
+ };
197
+ }
172
198
  }
173
199
  // 7. Create the proposal. The proposal queue is the ONLY thing reflect
174
200
  // writes — promotion to a real asset is gated by `akm proposal accept`.
@@ -138,7 +138,7 @@ export async function runLlmEnrich(body) {
138
138
  return { tags: [] };
139
139
  }
140
140
  const llmConfig = config.llm;
141
- const { chatCompletion, parseJsonResponse } = await import("../llm/client");
141
+ const { chatCompletion, parseEmbeddedJsonResponse: parseJsonResponse } = await import("../llm/client");
142
142
  const prompt = `You are a memory tagger for a developer knowledge base.
143
143
  Given the memory text below, return ONLY a JSON object with these fields:
144
144
  - "tags": array of 1-5 short lowercase keyword tags
@@ -9,15 +9,9 @@
9
9
  * edit-hints, summary-detail truncation) lives below in this file. The flow:
10
10
  *
11
11
  * 1. Special-case wiki-root refs (`wiki:<name>` with no page path).
12
- * 2. Ask `indexer.lookup(ref)` for the row in the FTS index.
13
- * 3. Fall back to the on-disk type-dir resolver only when the index has
14
- * no matching row — covers the "indexed yet?" gap when the user has
15
- * just added a file and not run `akm index`.
12
+ * 2. Auto-index when stale so the index is current.
13
+ * 3. Ask `indexer.lookup(ref)` for the row in the FTS index.
16
14
  * 4. Render the file via the matcher/renderer pipeline.
17
- *
18
- * Step (2) is the v1 spec change: reading is the indexer's job. Step (3) is a
19
- * pragmatic safety net (NOT remote provider fallback, which the spec
20
- * forbids — "Show: Local FTS5 index only. No remote provider fallback.").
21
15
  */
22
16
  import fs from "node:fs";
23
17
  import path from "node:path";
@@ -27,6 +21,7 @@ import { NotFoundError, UsageError } from "../core/errors";
27
21
  import { appendEvent, readEvents } from "../core/events";
28
22
  import { parseFrontmatter, toStringOrUndefined } from "../core/frontmatter";
29
23
  import { closeDatabase, findEntryIdByRef, openExistingDatabase } from "../indexer/db";
24
+ import { ensureIndex } from "../indexer/ensure-index";
30
25
  import { buildFileContext, buildRenderContext, getRenderer, runMatchers } from "../indexer/file-context";
31
26
  import { lookup } from "../indexer/indexer";
32
27
  import { buildEditHint, findSourceForPath, isEditable, resolveSourceEntries } from "../indexer/search-source";
@@ -34,8 +29,8 @@ import { insertUsageEvent } from "../indexer/usage-events";
34
29
  import { resolveSourcesForOrigin } from "../registry/origin-resolve";
35
30
  // Eagerly import source providers to trigger self-registration.
36
31
  import "../sources/providers/index";
37
- import { resolveAssetPath } from "../sources/resolve";
38
32
  import { getActiveWorkflowRun } from "../workflows/runs";
33
+ import { getCurrentWorkflowScopeKey } from "../workflows/scope-key";
39
34
  /**
40
35
  * Show a wiki root (no page path) — returns the same payload as
41
36
  * `akm wiki show <name>`.
@@ -128,7 +123,12 @@ export async function akmShowUnified(input) {
128
123
  new NotFoundError(`Wiki not found: ${parsed.name}. Run \`akm wiki create ${parsed.name}\` to create it.`));
129
124
  }
130
125
  }
131
- // Try local filesystem (FTS5 index lookup, then on-disk fallback)
126
+ // Auto-index when stale so the index is current before lookup.
127
+ const allSources = resolveSourceEntries();
128
+ if (allSources.length > 0) {
129
+ await ensureIndex(allSources[0].path);
130
+ }
131
+ // Try local filesystem (FTS5 index lookup)
132
132
  const result = await showLocal(input);
133
133
  // Scope filter narrows resolution: if --scope was supplied, the asset's
134
134
  // frontmatter scope must satisfy every supplied key. We re-read the file
@@ -220,38 +220,16 @@ function logShowEvent(ref, existingDb) {
220
220
  }
221
221
  }
222
222
  /**
223
- * Resolve an asset path to a file via:
224
- * 1. `indexer.lookup(ref)` — the spec's primary path (§6.2).
225
- * 2. On-disk type-dir traversal — fallback for files not yet indexed.
223
+ * Resolve an asset path via the FTS5 index only. Spec §6.2's primary path.
226
224
  *
227
- * Returns `undefined` if neither path finds a match.
225
+ * Returns `undefined` if the index has no matching row.
228
226
  */
229
- async function resolvePathViaIndexThenDisk(parsed, searchSourceDirs) {
230
- // Step 1: indexer
231
- try {
232
- const entry = await lookup(parsed);
233
- if (entry) {
234
- return { assetPath: entry.filePath };
235
- }
236
- }
237
- catch (err) {
238
- // Index unavailable (e.g. DB doesn't exist yet) — fall back to disk walk.
239
- if (!(err instanceof NotFoundError)) {
240
- // continue to disk fallback
241
- }
242
- }
243
- // Step 2: on-disk type-dir traversal
244
- let lastError;
245
- for (const dir of searchSourceDirs) {
246
- try {
247
- const assetPath = await resolveAssetPath(dir, parsed.type, parsed.name);
248
- return { assetPath, lastError };
249
- }
250
- catch (err) {
251
- lastError = err instanceof Error ? err : new Error(String(err));
252
- }
227
+ async function resolvePathViaIndex(parsed) {
228
+ const entry = await lookup(parsed);
229
+ if (entry) {
230
+ return { assetPath: entry.filePath };
253
231
  }
254
- return lastError ? { assetPath: "", lastError } : undefined;
232
+ return undefined;
255
233
  }
256
234
  /** @internal Use akmShowUnified() for all external callers. */
257
235
  export async function showLocal(input) {
@@ -273,13 +251,10 @@ export async function showLocal(input) {
273
251
  }
274
252
  }
275
253
  if (!assetPath) {
276
- const resolved = await resolvePathViaIndexThenDisk(parsed, allSourceDirs);
254
+ const resolved = await resolvePathViaIndex(parsed);
277
255
  if (resolved?.assetPath) {
278
256
  assetPath = resolved.assetPath;
279
257
  }
280
- else if (resolved?.lastError) {
281
- lastError = resolved.lastError;
282
- }
283
258
  }
284
259
  if (!assetPath && parsed.origin && searchSources.length === 0) {
285
260
  const installCmd = `akm add ${parsed.origin}`;
@@ -319,7 +294,7 @@ export async function showLocal(input) {
319
294
  editable,
320
295
  ...(!editable ? { editHint: buildEditHint(assetPath, parsed.type, parsed.name, source?.registryId) } : {}),
321
296
  };
322
- const activeRun = getActiveWorkflowRun();
297
+ const activeRun = getActiveWorkflowRun(getCurrentWorkflowScopeKey());
323
298
  if (activeRun) {
324
299
  fullResponse.activeRun = activeRun;
325
300
  }
@@ -5,13 +5,9 @@
5
5
  * the indexer, the `akm show` renderer, or any structured output channel.
6
6
  * The supported load paths are:
7
7
  *
8
- * - `eval "$(akm vault load vault:<name>)"` — `vault load` parses the vault
9
- * with dotenv (no shell expansion, no code execution), writes a safely
10
- * single-quote-escaped `export KEY='value'` script to a mode-0600 temp
11
- * file, and emits `. <tmp>; rm -f <tmp>` on stdout. Values reach bash
12
- * only via the temp file, never via akm's stdout.
13
- * - `injectIntoEnv(vaultPath, target)` — programmatic API for modules that
14
- * need values in a process environment.
8
+ * - `source "$(akm vault path vault:<name>)"` — direct shell loading path.
9
+ * - `injectIntoEnv(vaultPath, target)` / `loadEnv(vaultPath)` programmatic
10
+ * APIs for modules that need values in process memory.
15
11
  *
16
12
  * Value parsing is delegated to the `dotenv` package — we deliberately do not
17
13
  * implement our own quoting/escaping rules for security-sensitive content.
@@ -144,8 +140,7 @@ export function injectIntoEnv(vaultPath, target = process.env) {
144
140
  * non-assignment content, so sourcing the output is safe regardless of what
145
141
  * the vault file contains.
146
142
  *
147
- * Intended for use by `akm vault load`, which writes this to a mode-0600
148
- * temp file and emits only the path (never values) on stdout.
143
+ * Retained for programmatic callers/tests that need a literal export script.
149
144
  */
150
145
  export function buildShellExportScript(vaultPath) {
151
146
  const env = loadEnv(vaultPath);
@@ -264,7 +259,7 @@ export function createVault(vaultPath) {
264
259
  * Characters that are safe in an UNquoted dotenv value AND are not
265
260
  * metacharacters in POSIX shells. Anything outside this set forces quoting,
266
261
  * which is defense-in-depth for any caller that might ever `source` the
267
- * vault file directly instead of going through `akm vault load`.
262
+ * vault file directly instead of going through `akm vault path`.
268
263
  */
269
264
  const UNQUOTED_SAFE_RE = /^[A-Za-z0-9_.:/@%+,-]+$/;
270
265
  /**
@@ -32,7 +32,7 @@ export const ACTION_BUILDERS = {
32
32
  knowledge: (ref) => `akm show ${ref} -> read reference material`,
33
33
  memory: (ref) => `akm show ${ref} -> recall context`,
34
34
  workflow: (ref) => buildWorkflowAction(ref),
35
- vault: (ref) => `akm vault list ${ref} -> see key names; eval "$(akm vault load ${ref})" -> load values into the current shell (values never echoed)`,
35
+ vault: (ref) => `akm show ${ref} -> inspect keys; source "$(akm vault path ${ref})" -> load values; akm vault run ${ref} -- <command> -> run with injected env`,
36
36
  wiki: (ref) => `akm show ${ref} -> read the wiki page`,
37
37
  };
38
38
  /**
@@ -82,7 +82,7 @@ const ASSET_SPECS_INTERNAL = {
82
82
  return path.join(typeRoot, name.endsWith(".env") ? name : `${name}.env`);
83
83
  },
84
84
  rendererName: "vault-env",
85
- actionBuilder: (ref) => `akm vault list ${ref} -> see key names; eval "$(akm vault load ${ref})" -> load values into the current shell (values never echoed)`,
85
+ actionBuilder: (ref) => `akm show ${ref} -> inspect keys; source "$(akm vault path ${ref})" -> load values; akm vault run ${ref} -- <command> -> run with injected env`,
86
86
  },
87
87
  wiki: {
88
88
  stashDir: "wikis",
@@ -485,6 +485,10 @@ function parseLlmConfig(value) {
485
485
  warn(`[akm] Ignoring llm config: endpoint must start with http:// or https://, got "${obj.endpoint}"`);
486
486
  return undefined;
487
487
  }
488
+ if (!obj.endpoint.endsWith("/chat/completions")) {
489
+ warn(`[akm] llm.endpoint "${obj.endpoint}" does not end in /chat/completions. ` +
490
+ `Did you mean "${obj.endpoint.replace(/\/+$/, "")}/chat/completions"?`);
491
+ }
488
492
  const model = typeof obj.model === "string" ? obj.model : "";
489
493
  const result = {
490
494
  endpoint: obj.endpoint,
@@ -496,6 +500,15 @@ function parseLlmConfig(value) {
496
500
  if (typeof obj.temperature === "number" && Number.isFinite(obj.temperature)) {
497
501
  result.temperature = obj.temperature;
498
502
  }
503
+ if ("timeoutMs" in obj) {
504
+ if (typeof obj.timeoutMs !== "number" ||
505
+ !Number.isFinite(obj.timeoutMs) ||
506
+ !Number.isInteger(obj.timeoutMs) ||
507
+ obj.timeoutMs <= 0) {
508
+ return undefined;
509
+ }
510
+ result.timeoutMs = obj.timeoutMs;
511
+ }
499
512
  if ("maxTokens" in obj) {
500
513
  if (typeof obj.maxTokens !== "number" ||
501
514
  !Number.isFinite(obj.maxTokens) ||
@@ -158,6 +158,11 @@ function matchesFilter(envelope, options) {
158
158
  return false;
159
159
  if (options.since && envelope.ts && envelope.ts < options.since)
160
160
  return false;
161
+ const tags = envelope.metadata?.tags ?? [];
162
+ if (options.excludeTags?.some((t) => tags.includes(t)))
163
+ return false;
164
+ if (options.includeTags && !options.includeTags.every((t) => tags.includes(t)))
165
+ return false;
161
166
  return true;
162
167
  }
163
168
  /**
@@ -178,7 +183,13 @@ export async function tailEvents(options = {}, ctx) {
178
183
  // we start polling. This matches the documented behaviour of `tail
179
184
  // --since`: emit existing events that match, then follow.
180
185
  if (options.sinceOffset === undefined) {
181
- const initial = readEvents({ since: options.since, type: options.type, ref: options.ref }, ctx);
186
+ const initial = readEvents({
187
+ since: options.since,
188
+ type: options.type,
189
+ ref: options.ref,
190
+ excludeTags: options.excludeTags,
191
+ includeTags: options.includeTags,
192
+ }, ctx);
182
193
  for (const event of initial.events) {
183
194
  collected.push(event);
184
195
  options.onEvent?.(event);
@@ -202,7 +213,13 @@ export async function tailEvents(options = {}, ctx) {
202
213
  }
203
214
  function tick() {
204
215
  try {
205
- const result = readEvents({ sinceOffset: cursor, type: options.type, ref: options.ref }, ctx);
216
+ const result = readEvents({
217
+ sinceOffset: cursor,
218
+ type: options.type,
219
+ ref: options.ref,
220
+ excludeTags: options.excludeTags,
221
+ includeTags: options.includeTags,
222
+ }, ctx);
206
223
  cursor = result.nextOffset;
207
224
  for (const event of result.events) {
208
225
  // Apply --since filter inside the polling loop too — the cursor is