akm-cli 0.7.0-rc1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/src/cli.js +100 -16
  2. package/dist/src/commands/config-cli.js +42 -0
  3. package/dist/src/commands/history.js +78 -7
  4. package/dist/src/commands/registry-search.js +69 -6
  5. package/dist/src/commands/search.js +30 -3
  6. package/dist/src/commands/show.js +29 -0
  7. package/dist/src/commands/source-add.js +5 -1
  8. package/dist/src/commands/source-manage.js +7 -1
  9. package/dist/src/core/config.js +28 -0
  10. package/dist/src/indexer/db-search.js +1 -0
  11. package/dist/src/indexer/indexer.js +16 -2
  12. package/dist/src/indexer/matchers.js +1 -1
  13. package/dist/src/indexer/search-source.js +4 -2
  14. package/dist/src/integrations/agent/profiles.js +1 -1
  15. package/dist/src/integrations/agent/spawn.js +67 -16
  16. package/dist/src/integrations/github.js +9 -3
  17. package/dist/src/llm/embedders/remote.js +37 -3
  18. package/dist/src/output/cli-hints.js +15 -2
  19. package/dist/src/output/renderers.js +3 -1
  20. package/dist/src/output/shapes.js +8 -1
  21. package/dist/src/output/text.js +156 -3
  22. package/dist/src/registry/build-index.js +5 -4
  23. package/dist/src/registry/providers/static-index.js +3 -1
  24. package/dist/src/setup/setup.js +9 -0
  25. package/dist/src/wiki/wiki.js +54 -6
  26. package/dist/src/workflows/runs.js +37 -3
  27. package/dist/tests/architecture/agent-no-llm-sdk-guard.test.js +1 -1
  28. package/dist/tests/bench/attribution.test.js +24 -23
  29. package/dist/tests/bench/cleanup.js +31 -0
  30. package/dist/tests/bench/cli.js +366 -31
  31. package/dist/tests/bench/cli.test.js +282 -14
  32. package/dist/tests/bench/corpus.js +3 -0
  33. package/dist/tests/bench/corpus.test.js +10 -10
  34. package/dist/tests/bench/doctor.js +525 -0
  35. package/dist/tests/bench/driver.js +77 -22
  36. package/dist/tests/bench/driver.test.js +142 -1
  37. package/dist/tests/bench/environment.js +233 -0
  38. package/dist/tests/bench/environment.test.js +199 -0
  39. package/dist/tests/bench/evolve.js +67 -0
  40. package/dist/tests/bench/evolve.test.js +12 -4
  41. package/dist/tests/bench/failure-modes.test.js +52 -3
  42. package/dist/tests/bench/feedback-integrity.test.js +3 -2
  43. package/dist/tests/bench/leakage.test.js +105 -2
  44. package/dist/tests/bench/learning-curve.test.js +3 -2
  45. package/dist/tests/bench/metrics.js +102 -26
  46. package/dist/tests/bench/metrics.test.js +10 -4
  47. package/dist/tests/bench/opencode-config.js +194 -0
  48. package/dist/tests/bench/opencode-config.test.js +370 -0
  49. package/dist/tests/bench/report.js +73 -9
  50. package/dist/tests/bench/report.test.js +59 -10
  51. package/dist/tests/bench/run-config.js +355 -0
  52. package/dist/tests/bench/run-config.test.js +298 -0
  53. package/dist/tests/bench/run-curate-test.js +32 -0
  54. package/dist/tests/bench/run-failing-tasks.js +56 -0
  55. package/dist/tests/bench/run-full-bench.js +51 -0
  56. package/dist/tests/bench/run-items36-targeted.js +69 -0
  57. package/dist/tests/bench/run-nano-quick.js +42 -0
  58. package/dist/tests/bench/run-waveg-targeted.js +62 -0
  59. package/dist/tests/bench/runner.js +257 -94
  60. package/dist/tests/bench/tmp.js +90 -0
  61. package/dist/tests/bench/trajectory.js +2 -2
  62. package/dist/tests/bench/verifier.js +6 -1
  63. package/dist/tests/bench/workflow-spec.js +11 -24
  64. package/dist/tests/bench/workflow-spec.test.js +1 -1
  65. package/dist/tests/bench/workflow-trace.js +34 -0
  66. package/dist/tests/cli-errors.test.js +1 -0
  67. package/dist/tests/commands/history.test.js +195 -0
  68. package/dist/tests/config.test.js +25 -0
  69. package/dist/tests/e2e.test.js +23 -2
  70. package/dist/tests/fixtures/stashes/load.js +1 -1
  71. package/dist/tests/fixtures/stashes/load.test.js +11 -2
  72. package/dist/tests/indexer.test.js +12 -1
  73. package/dist/tests/output-baseline.test.js +2 -1
  74. package/dist/tests/output-shapes-unit.test.js +3 -1
  75. package/dist/tests/registry-build-index.test.js +17 -1
  76. package/dist/tests/registry-providers/static-index.test.js +34 -0
  77. package/dist/tests/registry-search.test.js +200 -0
  78. package/dist/tests/remember-frontmatter.test.js +11 -13
  79. package/dist/tests/source-qa-fixes.test.js +18 -0
  80. package/dist/tests/source-registry.test.js +3 -3
  81. package/dist/tests/source-source.test.js +61 -1
  82. package/dist/tests/workflow-qa-fixes.test.js +18 -0
  83. package/package.json +1 -1
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { isHttpUrl, resolveStashDir } from "../core/common";
4
4
  import { loadConfig, loadUserConfig, saveConfig } from "../core/config";
5
- import { UsageError } from "../core/errors";
5
+ import { ConfigError, UsageError } from "../core/errors";
6
6
  import { warn } from "../core/warn";
7
7
  import { akmIndex } from "../indexer/indexer";
8
8
  import { upsertLockEntry } from "../integrations/lockfile";
@@ -190,6 +190,10 @@ async function addWebsiteSource(ref, stashDir, name, options, wikiName) {
190
190
  * persisting the lock entry.
191
191
  */
192
192
  async function addRegistryStash(ref, stashDir, trustThisInstall, writable, wikiName) {
193
+ const parsedRef = parseRegistryRef(ref);
194
+ if (writable === true && parsedRef.source !== "git") {
195
+ throw new ConfigError("writable: true is only supported on filesystem and git sources", "INVALID_CONFIG_FILE");
196
+ }
193
197
  // Pre-sync registry-policy enforcement uses just the parsed ref (no fetch needed),
194
198
  // so we keep parity with the historical behavior where `enforceRegistryInstallPolicy`
195
199
  // ran before `extractTarGzSecure` etc.
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { loadConfig, loadUserConfig, saveConfig } from "../core/config";
3
- import { UsageError } from "../core/errors";
3
+ import { ConfigError, UsageError } from "../core/errors";
4
4
  import { resolveSourceEntries } from "../indexer/search-source";
5
5
  // ── Operations ──────────────────────────────────────────────────────────────
6
6
  /**
@@ -12,6 +12,12 @@ import { resolveSourceEntries } from "../indexer/search-source";
12
12
  */
13
13
  export function addStash(opts) {
14
14
  const { target, name, providerType, options: providerOptions, writable } = opts;
15
+ if (providerType === "openviking") {
16
+ throw new ConfigError("openviking is not supported in akm v1.", "INVALID_CONFIG_FILE");
17
+ }
18
+ if (writable === true && providerType && providerType !== "filesystem" && providerType !== "git") {
19
+ throw new ConfigError("writable: true is only supported on filesystem and git sources", "INVALID_CONFIG_FILE");
20
+ }
15
21
  const config = loadUserConfig();
16
22
  const sources = [...(config.sources ?? config.stashes ?? [])];
17
23
  const isRemoteUrl = target.startsWith("http://") ||
@@ -169,6 +169,9 @@ export function updateConfig(partial) {
169
169
  */
170
170
  function pickKnownKeys(raw) {
171
171
  const config = {};
172
+ if (Array.isArray(raw.stashes)) {
173
+ throw new ConfigError("The legacy `stashes[]` config key is no longer supported; rename it to `sources[]`.", "INVALID_CONFIG_FILE", `Edit ${_getConfigPath()} and replace \`stashes\` with \`sources\`.`);
174
+ }
172
175
  if (typeof raw.stashDir === "string" && raw.stashDir.trim()) {
173
176
  config.stashDir = raw.stashDir.trim();
174
177
  }
@@ -436,6 +439,28 @@ function parseEmbeddingConfig(value) {
436
439
  if (localModel) {
437
440
  result.localModel = localModel;
438
441
  }
442
+ if ("contextLength" in obj) {
443
+ if (typeof obj.contextLength !== "number" ||
444
+ !Number.isFinite(obj.contextLength) ||
445
+ !Number.isInteger(obj.contextLength) ||
446
+ obj.contextLength <= 0) {
447
+ return undefined;
448
+ }
449
+ result.contextLength = obj.contextLength;
450
+ }
451
+ if (typeof obj.ollamaOptions === "object" && obj.ollamaOptions !== null && !Array.isArray(obj.ollamaOptions)) {
452
+ const opts = obj.ollamaOptions;
453
+ const parsed = {};
454
+ if (typeof opts.num_ctx === "number" &&
455
+ Number.isFinite(opts.num_ctx) &&
456
+ Number.isInteger(opts.num_ctx) &&
457
+ opts.num_ctx > 0) {
458
+ parsed.num_ctx = opts.num_ctx;
459
+ }
460
+ if (Object.keys(parsed).length > 0) {
461
+ result.ollamaOptions = parsed;
462
+ }
463
+ }
439
464
  return result;
440
465
  }
441
466
  function parseLlmConfig(value) {
@@ -619,6 +644,9 @@ function parseInstalledStashEntry(value) {
619
644
  };
620
645
  if (typeof obj.writable === "boolean")
621
646
  entry.writable = obj.writable;
647
+ if (entry.writable === true && entry.source !== "git") {
648
+ throw new ConfigError(`writable: true is only supported on filesystem and git sources (got "${entry.source}" on installed entry "${entry.id}").`, "INVALID_CONFIG_FILE", "Remove `writable: true` from the installed entry or re-add it as a git source instead.");
649
+ }
622
650
  const resolvedVersion = asNonEmptyString(obj.resolvedVersion);
623
651
  if (resolvedVersion)
624
652
  entry.resolvedVersion = resolvedVersion;
@@ -294,6 +294,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
294
294
  const TYPE_BOOST = {
295
295
  skill: 0.4,
296
296
  command: 0.35,
297
+ workflow: 0.35,
297
298
  agent: 0.3,
298
299
  script: 0.2,
299
300
  memory: 0.1,
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { isHttpUrl, resolveStashDir, toErrorMessage } from "../core/common";
4
4
  import { getDbPath } from "../core/paths";
5
- import { isVerbose, warn } from "../core/warn";
5
+ import { isVerbose, warn, warnVerbose } from "../core/warn";
6
6
  import { resolveIndexPassLLM } from "../llm/index-passes";
7
7
  import { takeWorkflowDocument } from "../workflows/document-cache";
8
8
  import { closeDatabase, deleteEntriesByDir, deleteEntriesByStashDir, getEmbeddingCount, getEntriesByDir, getEntryCount, getMeta, isVecAvailable, openDatabase, rebuildFts, setMeta, upsertEmbedding, upsertEntry, upsertUtilityScore, warnIfVecMissing, } from "./db";
@@ -503,6 +503,7 @@ async function generateEmbeddingsForDb(db, config, onProgress) {
503
503
  }
504
504
  try {
505
505
  const { embedBatch } = await import("../llm/embedder.js");
506
+ const { estimateTokenCount } = await import("../llm/embedders/remote.js");
506
507
  const allEntries = getAllEntriesForEmbedding(db);
507
508
  if (allEntries.length === 0) {
508
509
  onProgress({ phase: "embeddings", message: "Embeddings already up to date." });
@@ -514,6 +515,19 @@ async function generateEmbeddingsForDb(db, config, onProgress) {
514
515
  message: `Generating embeddings for ${allEntries.length} entr${allEntries.length === 1 ? "y" : "ies"}.`,
515
516
  });
516
517
  const texts = allEntries.map((e) => e.searchText);
518
+ // Verbose: log each document before it is sent to the embedding API so
519
+ // operators can see exactly where embedding fails without waiting for an error.
520
+ if (isVerbose()) {
521
+ const EMBED_BATCH_SIZE = 100; // mirrors REMOTE_BATCH_SIZE in remote.ts
522
+ const totalBatches = Math.ceil(texts.length / EMBED_BATCH_SIZE);
523
+ for (let i = 0; i < texts.length; i++) {
524
+ const batchNum = Math.floor(i / EMBED_BATCH_SIZE) + 1;
525
+ const chars = texts[i].length;
526
+ const tokens = estimateTokenCount(texts[i]);
527
+ const ref = allEntries[i].entryKey.split(":").slice(1).join(":"); // strip stashDir prefix
528
+ warnVerbose(`[embed] ${ref} (${chars} chars, est. ${tokens} tokens) → batch ${batchNum}/${totalBatches}`);
529
+ }
530
+ }
517
531
  const embeddings = await embedBatch(texts, config.embedding);
518
532
  // Wrap all embedding upserts in a single transaction so partial
519
533
  // state is rolled back on failure rather than leaving the table half-filled.
@@ -547,7 +561,7 @@ async function generateEmbeddingsForDb(db, config, onProgress) {
547
561
  function getAllEntriesForEmbedding(db) {
548
562
  return db
549
563
  .prepare(`
550
- SELECT e.id, e.search_text AS searchText FROM entries e
564
+ SELECT e.id, e.search_text AS searchText, e.entry_key AS entryKey, e.file_path AS filePath FROM entries e
551
565
  WHERE NOT EXISTS (SELECT 1 FROM embeddings b WHERE b.id = e.id)
552
566
  `)
553
567
  .all();
@@ -97,7 +97,7 @@ export function parentDirHintMatcher(ctx) {
97
97
  if (parentDir === "scripts" && SCRIPT_EXTENSIONS.has(ext)) {
98
98
  return { type: "script", specificity: 15, renderer: "script-source" };
99
99
  }
100
- if (parentDir === "skills" && fileName === "SKILL.md") {
100
+ if (parentDir === "skills" && (fileName === "SKILL.md" || ext === ".md")) {
101
101
  return { type: "skill", specificity: 15, renderer: "skill-md" };
102
102
  }
103
103
  if (parentDir === "agents" && ext === ".md") {
@@ -224,7 +224,9 @@ function isValidDirectory(dir) {
224
224
  */
225
225
  export async function ensureSourceCaches(config) {
226
226
  const cfg = config ?? loadConfig();
227
- for (const entry of cfg.stashes ?? []) {
227
+ // Use sources[] (current key) with fallback to stashes[] (deprecated, one-release compat).
228
+ const entries = cfg.sources ?? cfg.stashes ?? [];
229
+ for (const entry of entries) {
228
230
  if (!GIT_STASH_TYPES.has(entry.type) || !entry.url || entry.enabled === false)
229
231
  continue;
230
232
  try {
@@ -236,7 +238,7 @@ export async function ensureSourceCaches(config) {
236
238
  warn(`Warning: failed to refresh git mirror for "${entry.url}": ${err instanceof Error ? err.message : String(err)}`);
237
239
  }
238
240
  }
239
- for (const entry of cfg.stashes ?? []) {
241
+ for (const entry of entries) {
240
242
  if (entry.type !== "website" || !entry.url || entry.enabled === false)
241
243
  continue;
242
244
  try {
@@ -8,7 +8,7 @@ const BUILTINS = {
8
8
  opencode: {
9
9
  name: "opencode",
10
10
  bin: "opencode",
11
- args: [],
11
+ args: ["run"],
12
12
  stdio: "interactive",
13
13
  envPassthrough: [...COMMON_PASSTHROUGH, "OPENCODE_API_KEY", "OPENCODE_CONFIG"],
14
14
  parseOutput: "text",
@@ -12,6 +12,32 @@
12
12
  * this is a pre-emptive guarantee against the #222 invariant.
13
13
  */
14
14
  import { DEFAULT_AGENT_TIMEOUT_MS } from "./config";
15
+ /**
16
+ * Kill the process group of `proc` with `signal`, falling back to
17
+ * `proc.kill(signal)` when `proc.pid` is unavailable (e.g. test fakes).
18
+ *
19
+ * Passing a negative PID to `process.kill` targets the entire process
20
+ * group, so opencode's child processes (the .opencode binary, etc.) are
21
+ * reaped alongside the node wrapper. The fallback keeps test fakes working
22
+ * without modification.
23
+ */
24
+ export function killGroup(proc, signal) {
25
+ if (typeof proc.pid === "number") {
26
+ try {
27
+ process.kill(-proc.pid, signal);
28
+ return;
29
+ }
30
+ catch {
31
+ // Process may have already exited; fall through to direct kill.
32
+ }
33
+ }
34
+ try {
35
+ proc.kill(signal);
36
+ }
37
+ catch {
38
+ /* ignore */
39
+ }
40
+ }
15
41
  const DEFAULT_TIMEOUT_MS = DEFAULT_AGENT_TIMEOUT_MS;
16
42
  function resolveSpawnFn(options) {
17
43
  if (options.spawn)
@@ -47,15 +73,21 @@ function buildChildEnv(profile, options) {
47
73
  }
48
74
  return env;
49
75
  }
50
- async function readStream(stream) {
76
+ async function readStream(stream, opts) {
51
77
  if (!stream)
52
78
  return "";
53
- try {
54
- return await new Response(stream).text();
55
- }
56
- catch {
57
- return "";
58
- }
79
+ const readPromise = new Response(stream).text().catch(() => "");
80
+ if (!opts?.timeoutMs)
81
+ return readPromise;
82
+ // Race the stream read against a timeout so a process that is killed via
83
+ // SIGTERM/SIGKILL but whose pipe endpoints stay open (e.g. background
84
+ // threads still holding the fd) cannot block the caller indefinitely.
85
+ // On timeout we return whatever we received so far (empty string here since
86
+ // `readPromise` is all-or-nothing with `Response.text()`).
87
+ const timeoutPromise = new Promise((resolve) => {
88
+ setTimeout(() => resolve(""), opts.timeoutMs);
89
+ });
90
+ return Promise.race([readPromise, timeoutPromise]);
59
91
  }
60
92
  /**
61
93
  * Spawn the agent CLI described by `profile` with `prompt` (forwarded as
@@ -90,11 +122,16 @@ export async function runAgent(profile, prompt, options = {}) {
90
122
  try {
91
123
  const spawnFn = resolveSpawnFn(options);
92
124
  proc = spawnFn([profile.bin, ...args], {
93
- stdin: stdioMode === "captured" ? "pipe" : "inherit",
125
+ stdin: stdioMode === "captured" ? (options.stdin !== undefined ? "pipe" : "ignore") : "inherit",
94
126
  stdout: stdioMode === "captured" ? "pipe" : "inherit",
95
127
  stderr: stdioMode === "captured" ? "pipe" : "inherit",
96
128
  env,
97
129
  ...(options.cwd ? { cwd: options.cwd } : {}),
130
+ // Spawn in its own process group so killGroup(-pid, signal) reaches all
131
+ // descendants (e.g. the .opencode binary that opencode's node wrapper forks).
132
+ // Only applied in captured mode — interactive mode inherits the parent
133
+ // terminal's process group intentionally.
134
+ ...(stdioMode === "captured" ? { detached: true } : {}),
98
135
  });
99
136
  }
100
137
  catch (err) {
@@ -121,15 +158,27 @@ export async function runAgent(profile, prompt, options = {}) {
121
158
  if (proc.exitCode !== null)
122
159
  return;
123
160
  timedOut = true;
124
- try {
125
- proc.kill("SIGTERM");
126
- }
127
- catch {
128
- /* ignore */
129
- }
161
+ killGroup(proc, "SIGTERM");
162
+ // Follow up with SIGKILL after 5 s in case the process ignores SIGTERM.
163
+ setTimeoutImpl(() => {
164
+ if (proc.exitCode !== null)
165
+ return;
166
+ killGroup(proc, "SIGKILL");
167
+ }, 5000);
130
168
  }, timeoutMs);
131
- const stdoutPromise = stdioMode === "captured" ? readStream(proc.stdout ?? null) : Promise.resolve("");
132
- const stderrPromise = stdioMode === "captured" ? readStream(proc.stderr ?? null) : Promise.resolve("");
169
+ // Stream-drain timeout: the overall wall-clock budget plus a 2 s grace
170
+ // period. When a process is killed via SIGTERM/SIGKILL (from our timeout
171
+ // handler or from outside) some runtimes keep the pipe write-end open in
172
+ // background threads, which would cause `Response.text()` to block forever.
173
+ // Capping stream draining at `timeoutMs + 2 000 ms` ensures the caller
174
+ // never hangs past the wall budget regardless of subprocess pipe behaviour.
175
+ const streamDrainTimeoutMs = timeoutMs + 2_000;
176
+ const stdoutPromise = stdioMode === "captured"
177
+ ? readStream(proc.stdout ?? null, { timeoutMs: streamDrainTimeoutMs })
178
+ : Promise.resolve("");
179
+ const stderrPromise = stdioMode === "captured"
180
+ ? readStream(proc.stderr ?? null, { timeoutMs: streamDrainTimeoutMs })
181
+ : Promise.resolve("");
133
182
  // Optional stdin payload (captured mode only).
134
183
  //
135
184
  // BUG-H1: race the stdin write/close against `proc.exited` and the
@@ -163,6 +212,8 @@ export async function runAgent(profile, prompt, options = {}) {
163
212
  clearTimeoutImpl(timer);
164
213
  // BUG-H2: drain stream readers before the early return so they don't
165
214
  // surface as unhandled rejections after the function resolves.
215
+ // The streams already carry a built-in drain timeout so this allSettled
216
+ // will not block indefinitely.
166
217
  await Promise.allSettled([stdoutPromise, stderrPromise]);
167
218
  const durationMs = Date.now() - start;
168
219
  return {
@@ -2,8 +2,13 @@ import * as childProcess from "node:child_process";
2
2
  export const GITHUB_API_BASE = "https://api.github.com";
3
3
  const GITHUB_TOKEN_DOMAINS = new Set(["api.github.com", "github.com", "uploads.github.com"]);
4
4
  function readGithubTokenFromEnv() {
5
- const token = process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim();
6
- return token || undefined;
5
+ if (process.env.GITHUB_TOKEN !== undefined) {
6
+ return process.env.GITHUB_TOKEN.trim();
7
+ }
8
+ if (process.env.GH_TOKEN !== undefined) {
9
+ return process.env.GH_TOKEN.trim();
10
+ }
11
+ return undefined;
7
12
  }
8
13
  function readGithubTokenFromGhCli() {
9
14
  const result = childProcess.spawnSync("gh", ["auth", "token"], {
@@ -17,7 +22,8 @@ function readGithubTokenFromGhCli() {
17
22
  return token || undefined;
18
23
  }
19
24
  function resolveGithubToken() {
20
- return readGithubTokenFromEnv() ?? readGithubTokenFromGhCli();
25
+ const token = readGithubTokenFromEnv();
26
+ return token !== undefined ? token || undefined : readGithubTokenFromGhCli();
21
27
  }
22
28
  /**
23
29
  * Build headers for GitHub API requests.
@@ -5,7 +5,11 @@
5
5
  * vectors so the scoring pipeline's L2-to-cosine conversion is correct.
6
6
  */
7
7
  import { fetchWithTimeout, isHttpUrl } from "../../core/common";
8
- const REMOTE_BATCH_SIZE = 100;
8
+ const DEFAULT_REMOTE_BATCH_SIZE = 100;
9
+ /** Cheap token estimator: 4 chars ≈ 1 token. Used in verbose logging and error messages. */
10
+ export function estimateTokenCount(text) {
11
+ return Math.round(text.length / 4);
12
+ }
9
13
  export class RemoteEmbedder {
10
14
  config;
11
15
  constructor(config) {
@@ -20,6 +24,10 @@ export class RemoteEmbedder {
20
24
  if (this.config.dimension) {
21
25
  body.dimensions = this.config.dimension;
22
26
  }
27
+ const ollamaOpts = resolveOllamaOptions(this.config);
28
+ if (ollamaOpts) {
29
+ body.options = ollamaOpts;
30
+ }
23
31
  const response = await fetchWithTimeout(normalizeEmbeddingEndpoint(this.config.endpoint), {
24
32
  method: "POST",
25
33
  headers,
@@ -40,8 +48,10 @@ export class RemoteEmbedder {
40
48
  return [];
41
49
  const results = [];
42
50
  const headers = this.buildHeaders();
43
- for (let i = 0; i < texts.length; i += REMOTE_BATCH_SIZE) {
44
- const batch = texts.slice(i, i + REMOTE_BATCH_SIZE);
51
+ const ollamaOpts = resolveOllamaOptions(this.config);
52
+ const batchSize = this.config.batchSize ?? DEFAULT_REMOTE_BATCH_SIZE;
53
+ for (let i = 0; i < texts.length; i += batchSize) {
54
+ const batch = texts.slice(i, i + batchSize);
45
55
  const body = {
46
56
  input: batch,
47
57
  model: this.config.model,
@@ -49,6 +59,9 @@ export class RemoteEmbedder {
49
59
  if (this.config.dimension) {
50
60
  body.dimensions = this.config.dimension;
51
61
  }
62
+ if (ollamaOpts) {
63
+ body.options = ollamaOpts;
64
+ }
52
65
  const response = await fetchWithTimeout(normalizeEmbeddingEndpoint(this.config.endpoint), {
53
66
  method: "POST",
54
67
  headers,
@@ -115,6 +128,27 @@ function embeddingEndpointPathHint(endpoint) {
115
128
  }
116
129
  return "";
117
130
  }
131
+ /**
132
+ * Resolve Ollama-native `options` from the embedding config.
133
+ *
134
+ * Resolution order:
135
+ * 1. `ollamaOptions` — forwarded verbatim (explicit opt-in, takes precedence).
136
+ * 2. `contextLength` — wrapped as `{ num_ctx: contextLength }`.
137
+ * 3. Neither set → returns `undefined` (no `options` field in the request body).
138
+ *
139
+ * These options are only meaningful for Ollama's native `/api/embed` endpoint.
140
+ * OpenAI-compatible endpoints ignore unknown request fields, so passing them to
141
+ * other providers is harmless but has no effect.
142
+ */
143
+ function resolveOllamaOptions(config) {
144
+ if (config.ollamaOptions && Object.keys(config.ollamaOptions).length > 0) {
145
+ return config.ollamaOptions;
146
+ }
147
+ if (config.contextLength) {
148
+ return { num_ctx: config.contextLength };
149
+ }
150
+ return undefined;
151
+ }
118
152
  /** Check whether an EmbeddingConnectionConfig has a valid remote endpoint. */
119
153
  export function hasRemoteEndpoint(config) {
120
154
  return isHttpUrl(config.endpoint);
@@ -11,6 +11,19 @@ const EMBEDDED_HINTS = `# akm CLI
11
11
 
12
12
  You have access to a searchable library of scripts, skills, commands, agents, knowledge documents, workflows, wikis, and memories via \`akm\`. Search your sources first before writing something from scratch.
13
13
 
14
+ ## Agent Task Loop
15
+
16
+ For any task, follow this loop:
17
+ 1. \`akm curate "<task>"\` — find the best matching asset
18
+ 2. \`akm show <ref>\` — read the schema (field names and structure)
19
+ 3. Edit the workspace file using schema field names + task-specific values from your README
20
+ 4. \`akm feedback <ref> --positive\` — record success
21
+
22
+ For workflow tasks:
23
+ 1. \`akm workflow next workflow:<name>\` — get current step instructions
24
+ 2. Do the step work in your workspace
25
+ 3. \`akm workflow complete <run-id> --step <step-id>\` — mark done, get next step
26
+
14
27
  ## Quick Reference
15
28
 
16
29
  \`\`\`sh
@@ -144,7 +157,7 @@ akm wiki list # List wikis (name, pages, raws,
144
157
  akm wiki create research # Scaffold a new wiki
145
158
  akm wiki register ics-docs ~/code/ics-documentation # Register an external wiki
146
159
  akm wiki show research # Path, description, counts, last 3 log entries
147
- akm wiki pages research # Page refs + descriptions (excludes schema/index/log/raw)
160
+ akm wiki pages research # Page refs + descriptions (excludes schema/index/log; includes raw/)
148
161
  akm wiki search research "attention" # Scoped search (equivalent to --type wiki --wiki research)
149
162
  akm wiki stash research ./paper.md # Copy source into raw/<slug>.md (never overwrites)
150
163
  echo "..." | akm wiki stash research - # stdin form
@@ -254,7 +267,7 @@ akm registry add <url> --provider skills-sh # Specify provider type
254
267
  akm registry remove <url-or-name> # Remove a registry
255
268
  akm registry search "<query>" # Search all registries
256
269
  akm registry search "<query>" --assets # Include asset-level results
257
- akm registry build-index # Build ./index.json
270
+ akm registry build-index # Build the default cache-backed index.json
258
271
  akm registry build-index --out dist/index.json # Build to a custom path
259
272
  \`\`\`
260
273
 
@@ -187,12 +187,14 @@ const skillMdRenderer = {
187
187
  name: "skill-md",
188
188
  buildShowResponse(ctx) {
189
189
  const name = deriveName(ctx);
190
+ const parsed = parseFrontmatter(ctx.content());
190
191
  return {
191
192
  type: "skill",
192
193
  name,
193
194
  path: ctx.absPath,
194
195
  action: "Read and follow the instructions below",
195
- content: ctx.content(),
196
+ description: toStringOrUndefined(parsed.data.description),
197
+ content: parsed.content,
196
198
  };
197
199
  },
198
200
  };
@@ -293,6 +293,10 @@ export function shapeHistoryOutput(result, detail) {
293
293
  ...(result.since !== undefined ? { since: result.since } : {}),
294
294
  totalCount: result.totalCount ?? shapedEntries.length,
295
295
  entries: shapedEntries,
296
+ // `sources` lists the event sources included in this response.
297
+ // Always contains "usage_events"; also "events.jsonl" when
298
+ // --include-proposals was specified.
299
+ ...(Array.isArray(result.sources) ? { sources: result.sources } : {}),
296
300
  ...(Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
297
301
  };
298
302
  }
@@ -301,6 +305,7 @@ export function shapeHistoryOutput(result, detail) {
301
305
  ...(result.since !== undefined ? { since: result.since } : {}),
302
306
  totalCount: result.totalCount ?? shapedEntries.length,
303
307
  entries: shapedEntries,
308
+ ...(Array.isArray(result.sources) ? { sources: result.sources } : {}),
304
309
  ...(Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
305
310
  };
306
311
  }
@@ -399,8 +404,10 @@ export function shapeSearchHit(hit, detail) {
399
404
  return hit;
400
405
  }
401
406
  // Stash hit (local or remote)
407
+ // `ref` is included at `brief` so agents can run `akm show <ref>` without
408
+ // needing --detail full or --for-agent (REC-03).
402
409
  if (detail === "brief")
403
- return pickFields(hit, ["type", "name", "action", "estimatedTokens"]);
410
+ return pickFields(hit, ["type", "name", "ref", "action", "estimatedTokens"]);
404
411
  if (detail === "normal") {
405
412
  // `warnings` is projected at `normal` so non-fatal hit-level issues are
406
413
  // visible without forcing callers up to `--detail full`. Optional