@minhpnq1807/contextos 0.5.45 → 0.5.49

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.49
4
+
5
+ - **Cold-cache MCP smoke:** `npm run test:mcp` now verifies the MCP tool contract without requiring a pre-downloaded ContextOS embedding model. When the model exists it still runs the semantic/performance smoke; otherwise it asserts cold-cache fallback behavior so publish CI does not fail before model warmup.
6
+
7
+ ## 0.5.48
8
+
9
+ - **Publish-safe embedding warm:** `ctx embeddings warm` now skips remote model downloads by default in CI and returns best-effort warm results when local embedding model loading fails, preventing npm publish workflows from failing on Hugging Face 429/rate-limit errors.
10
+ - **CI graph embedding skip:** Code-review-graph embedding refresh is skipped when remote embeddings are disabled, avoiding extra Hugging Face calls during publish jobs.
11
+
12
+ ## 0.5.47
13
+
14
+ - **Semantic-only skill discovery:** Replaced taxonomy/keyword skill ranking with fused semantic retrieval. ContextOS now embeds `prompt + project profile` and compares it against cached skill vectors, so mixed Vietnamese/English prompts can use package, language, and recent-file signals without hard-coded categories.
15
+ - **Project profile cache:** Added a bounded project profiler that builds an embeddable string from root/workspace `package.json` dependencies, scripts, detected languages, and recent git files. The cache invalidates when package metadata or git `HEAD` changes.
16
+ - **Skill dedupe and activation format:** Skill catalogs are deduplicated by normalized skill name before indexing/searching, preferring project and Codex roots. Prompt output now renders skill activations as `$skill-name`.
17
+ - **Refresh rebuilds skill index:** `ctx install` and `ctx refresh` now rebuild the skill embedding index by default because prompt-time skill discovery no longer falls back to taxonomy or keyword ranking for large catalogs.
18
+ - **Skill hook timeout split:** Prompt hooks now use a separate skill embedding timeout, so large indexed skill catalogs can still produce suggestions after direct fallback without slowing rule scoring.
19
+ - **Read-only embedding cache close:** Prompt-time embedding reads no longer rewrite the whole `embeddings.db` when no cache entries changed.
20
+
21
+ ## 0.5.46
22
+
23
+ - **Configurable prompt suggestion limits:** `ctx --config` and interactive `ctx setup` now let users choose how many suggested files, skills, and workflows appear in prompt context. Defaults are five each, with caps of 20 files, 10 skills, and 5 workflows.
24
+ - **Limit-aware prompt hooks and debug:** `UserPromptSubmit` hooks, direct fallback scoring, the private `ctx-mcp` bridge request, and `ctx debug` now all honor the saved suggestion limits instead of using hard-coded counts.
25
+ - **Document authoring skill intent:** Prompts that create, edit, update, or maintain documents, workspace docs, README files, wiki pages, manuals, guides, specs, or ADRs now prioritize documentation skills such as `doc-coauthoring`, `documentation`, `docs-architect`, `readme`, and `wiki-page-writer`.
26
+ - **Safer document skill gating:** Document-processing and workspace-automation skills such as Azure Document Intelligence, DocuSign, Asana, Slack, Google Docs, and Notion no longer win generic document-writing prompts unless the provider or processing task is explicitly named.
27
+ - **Setup summary clarity:** The setup wizard summary now reports the saved prompt suggestion limits alongside the enabled prompt sections so users can review output volume immediately.
28
+
3
29
  ## 0.5.45
4
30
 
5
31
  - **Project-aware MCP skill suggestions:** Skill ranking now reads `package.json` keywords and dependencies such as `@modelcontextprotocol/sdk`. MCP projects can recommend `mcp-builder`, `mcp-management`, `mcp-tool-developer`, and `agent-memory-mcp` for context retrieval, scorer, hook, and prompt-injection debugging tasks even when the prompt does not explicitly say `mcp`.
package/README.md CHANGED
@@ -444,8 +444,8 @@ This warning comes from a transitive dependency in the local embedding/WASM stac
444
444
  | `ctx sync --workflows --dry-run` | Previews workflow sync without writing files. | You want to inspect source workflows and target roots first. | Prints planned sync/index output and skips copying target files. |
445
445
  | `ctx skills` | Installs community skill libraries. | You want curated skills without running the full setup wizard. | Opens the community installer, uses a portable shell on Windows/Linux/macOS, repairs unsafe skill symlinks, and syncs installed skills to selected agents. |
446
446
  | `ctx embeddings warm -- "task"` | Prepares local semantic embedding caches. | First install, CI smoke checks, or after changing AGENTS.md/project files/skills/workflows. | Loads/downloads `Xenova/all-MiniLM-L6-v2` and writes rule, file-path, skill, and workflow vectors to `~/.ctx/contextos/embeddings.db`. |
447
- | `ctx --config` | Opens an interactive multi-select panel for prompt sections. | You want to reduce ContextOS prompt output noise. | Toggles critical rules, suggested files, suggested skills, and suggested workflows globally under `~/.ctx/contextos/output-config.json`. |
448
- | `ctx refresh` | Refreshes the active Codex marketplace plugin and rebuilds local indexes. | Local development updates or a stale file retrieval index. | Copies the current package to `$CODEX_HOME/marketplaces/contextos`, rebuilds file-path embeddings and import adjacency, and refreshes code-review-graph embeddings when available. |
447
+ | `ctx --config` | Opens an interactive panel for prompt sections and suggestion limits. | You want to reduce ContextOS prompt output noise. | Toggles critical rules, suggested files, suggested skills, and suggested workflows globally under `~/.ctx/contextos/output-config.json`, then lets you set suggestion counts for files, skills, and workflows. |
448
+ | `ctx refresh` | Refreshes the active Codex marketplace plugin and rebuilds local indexes. | Local development updates or stale file/skill retrieval indexes. | Copies the current package to `$CODEX_HOME/marketplaces/contextos`, rebuilds file-path embeddings, skill embeddings, import adjacency, and refreshes code-review-graph embeddings when available. |
449
449
  | `ctx ruler -- <args>` | Forwards args to the installed `ruler` CLI. | You need native Ruler commands such as `init`, `apply`, or `revert`. | Preserves Ruler stdout/stderr and exit status. |
450
450
  | `ctx skillshare -- <args>` | Forwards args to the installed `skillshare` CLI. | You need native skillshare commands such as `status`, `target list`, `doctor`, `push`, or `pull`. | Preserves skillshare stdout/stderr and exit status. |
451
451
  | `ctx --version` | Prints the installed ContextOS CLI version. | You want to confirm which npm version is being executed. | Prints the version from package metadata. |
@@ -511,17 +511,23 @@ This keeps the hook fast and local while still using graph semantics when availa
511
511
 
512
512
  Prompt scoring does not walk the repository for file candidates or import expansion. `ctx install` and `ctx embeddings warm` rebuild the persisted file-vector index and one-hop import adjacency index by walking source paths once; prompt hooks query those indexes directly. Rules, files, skills, and workflows are scored concurrently with `Promise.all()`.
513
513
 
514
- `ctx embeddings warm` automatically refreshes the active Codex marketplace payload before rebuilding indexes. Use `ctx refresh` when you want the same marketplace sync plus install-style file/import index and code-review-graph embedding refresh in one command.
514
+ `ctx embeddings warm` automatically refreshes the active Codex marketplace payload before rebuilding indexes. Use `ctx refresh` when you want the same marketplace sync plus install-style file, skill, import, and code-review-graph embedding refresh in one command.
515
515
 
516
516
  If a prompt has no usable context candidates, the hook fails open without emitting an empty `hook context` block, records `emptyContextReason` in the workspace runtime file, and starts a detached `autowarm` rebuild with a cooldown. That background rebuild refreshes file vectors, skill/workflow vectors, import adjacency, and available code-review-graph node embeddings for the next prompt while keeping repository walking out of the current prompt hot path.
517
517
 
518
- Use `ctx --config` to choose which prompt sections ContextOS injects. Interactive `ctx setup` now includes the same multi-select step, while `ctx setup --yes` keeps the current saved config for automation. The panel supports multiple selection with `Space` and persists the global choice in `~/.ctx/contextos/output-config.json`. Disabling rules hides both critical and additional relevant rule sections; compliance metadata remains available for reports.
518
+ Use `ctx --config` to choose which prompt sections ContextOS injects and how many suggestions each section may show. Interactive `ctx setup` includes the same section picker and limit prompts, while `ctx setup --yes` keeps the current saved config for automation. The panel supports multiple selection with `Space` and persists the global choice in `~/.ctx/contextos/output-config.json`. Defaults are five suggested files, five skills, and five workflows; caps are 20 files, 10 skills, and 5 workflows. Disabling rules hides both critical and additional relevant rule sections; compliance metadata remains available for reports.
519
519
 
520
- Injected prompt sections are intentionally compact: rules show only detected rule text, files show basenames without paths, skills show unique names as a comma-separated inline list without descriptions, and workflows show names with their agent chain. Stop hooks persist reports silently; run `ctx report` or `ctx evidence` when you want the detailed compliance output.
520
+ Injected prompt sections are intentionally compact: rules show only detected rule text, files show a comma-separated inline list of basenames without paths, skills show unique `$skill-name` activations as a comma-separated inline list without descriptions, and workflows show names with their agent chain. Stop hooks persist reports silently; run `ctx report` or `ctx evidence` when you want the detailed compliance output.
521
521
 
522
522
  Codex may flatten newlines in its `UserPromptSubmit hook (completed)` preview. The injected `additionalContext` payload remains multiline; this is a Codex preview display limitation.
523
523
 
524
- Skill ranking uses bounded project hints from root/workspace `package.json` files and known mobile config files such as `app.json`, `app.config.*`, and `eas.json`. This lets Expo/EAS tasks activate specialized skills without walking the source tree on every prompt.
524
+ Skill ranking is semantic-only. ContextOS builds a fused query from the user prompt plus a cached project profile, then compares that vector with cached skill vectors:
525
+
526
+ ```text
527
+ embed(prompt + project profile) -> cosine -> embed(skill name + description)
528
+ ```
529
+
530
+ The project profile is an embeddable string built from bounded root/workspace `package.json` metadata, dependencies, scripts, detected languages, and recent git files. It is cached under the ContextOS workspace data directory and invalidated when package metadata or git `HEAD` changes. ContextOS does not maintain a skill taxonomy or domain gate list for ranking; if the skill index is cold for a large catalog, prompt hooks fail open instead of falling back to arbitrary keyword matches. Skill catalogs are deduplicated by normalized skill name before indexing and rendering.
525
531
 
526
532
  After `ctx refresh`, ContextOS invalidates the private hook bridge socket so prompts fall back to direct scoring until Codex restarts the long-running `ctx-mcp` process. Hook clients also discard a same-inode socket if an older bridge revision is detected.
527
533
 
@@ -538,6 +544,8 @@ CONTEXTOS_HOOK_DEADLINE_MS=8500 hard fail-open deadline for prompt hooks
538
544
  CONTEXTOS_DIRECT_FALLBACK_TIMEOUT_MS=6000 direct scoring timeout when the bridge is unavailable
539
545
  CONTEXTOS_HOOK_EMBEDDING_TIMEOUT_MS=500 rule embedding timeout during hook direct fallback
540
546
  CONTEXTOS_EMBEDDING_TIMEOUT_MS=800 embedding scoring timeout inside ctx-mcp/debug
547
+ CONTEXTOS_HOOK_SKILL_EMBEDDING_TIMEOUT_MS=2000 skill retrieval timeout during hook direct fallback
548
+ CONTEXTOS_SKILL_EMBEDDING_TIMEOUT_MS=2000 skill retrieval timeout inside ctx-mcp/debug
541
549
  CONTEXTOS_FILE_EMBEDDINGS=0 disable file-path embedding retrieval
542
550
  CONTEXTOS_HOOK_FILE_EMBEDDING_TIMEOUT_MS=500 file retrieval timeout during hook direct fallback
543
551
  CONTEXTOS_FILE_EMBEDDING_TIMEOUT_MS=1000 file-path embedding retrieval timeout
package/bin/ctx.js CHANGED
@@ -34,7 +34,7 @@ import { scanSkills, warmSkillEmbeddings } from "../plugins/ctx/lib/skill-discov
34
34
  import { parsePassthroughArgs, runPassthrough } from "../plugins/ctx/lib/passthrough.js";
35
35
  import { parseAgentList, parseSetupArgs, setupSummaryLines } from "../plugins/ctx/lib/setup-wizard.js";
36
36
  import { multiSelect } from "../plugins/ctx/lib/multi-select.js";
37
- import { configureOutputSections, enabledOutputSectionsLabel, loadOutputConfig } from "../plugins/ctx/lib/output-config.js";
37
+ import { configureOutputSections, enabledOutputSectionsLabel, loadOutputConfig, outputConfigLimits, outputConfigLimitsLabel } from "../plugins/ctx/lib/output-config.js";
38
38
  import { syncWorkflows, warmWorkflowEmbeddings } from "../plugins/ctx/lib/workflow-discoverer.js";
39
39
  import { checkForUpdate } from "../plugins/ctx/lib/update-notifier.js";
40
40
  import { fetchSkillsForAgents, printSkillRecommendations, getAllLibraries, getInstallCommands } from "../plugins/ctx/lib/skill-library.js";
@@ -487,6 +487,7 @@ function graphStrategyForInstall() {
487
487
  async function warmInstallEmbeddings() {
488
488
  const dataDir = contextOSDataDir();
489
489
  const modelReady = isModelCacheReady(dataDir);
490
+ const allowRemote = shouldAllowRemoteWarm(modelReady);
490
491
  const result = await warmRuleEmbeddings({
491
492
  rules: [
492
493
  { content: "Always use project rules that are semantically relevant to the user prompt." },
@@ -496,29 +497,29 @@ async function warmInstallEmbeddings() {
496
497
  task: "kiểm duyệt upload moderation semantic code search",
497
498
  dataDir,
498
499
  sources: [],
499
- allowRemote: !modelReady
500
+ allowRemote
500
501
  });
501
502
  const fileResult = await warmFileEmbeddings({
502
503
  cwd: process.cwd(),
503
504
  dataDir,
504
- allowRemote: !modelReady
505
+ allowRemote
506
+ });
507
+ const skillResult = await warmSkillEmbeddings({
508
+ cwd: process.cwd(),
509
+ dataDir,
510
+ allowRemote
505
511
  });
506
512
  const warmDiscovery = process.env.CONTEXTOS_INSTALL_WARM_DISCOVERY === "1";
507
- const skillResult = warmDiscovery
508
- ? await warmSkillEmbeddings({
509
- cwd: process.cwd(),
510
- dataDir,
511
- allowRemote: !modelReady
512
- })
513
- : { count: 0 };
514
513
  const workflowResult = warmDiscovery
515
514
  ? await warmWorkflowEmbeddings({
516
515
  cwd: process.cwd(),
517
516
  dataDir,
518
- allowRemote: !modelReady
517
+ allowRemote
519
518
  })
520
519
  : { count: 0 };
521
- const graphEmbedding = embedCodeReviewGraph({ cwd: process.cwd() });
520
+ const graphEmbedding = allowRemote
521
+ ? embedCodeReviewGraph({ cwd: process.cwd() })
522
+ : { status: "skipped", reason: "remote-embedding-disabled" };
522
523
  return { ...result, modelAlreadyCached: modelReady, fileCount: fileResult.count, skillCount: skillResult.count, workflowCount: workflowResult.count, graphEmbedding };
523
524
  }
524
525
 
@@ -586,18 +587,20 @@ function contextOSWorkspaceDataDir(cwd = process.cwd()) {
586
587
 
587
588
  async function debug(task) {
588
589
  const cwd = process.cwd();
590
+ const limits = outputConfigLimits(loadOutputConfig({ dataRoot: contextOSDataDir() }));
589
591
  const scored = await scoreContext({
590
592
  cwd,
591
593
  prompt: task,
592
594
  dataDir: contextOSDataDir(),
593
- maxFiles: 7,
594
- maxSkills: 7,
595
+ maxFiles: limits.files,
596
+ maxSkills: limits.skills,
597
+ maxWorkflows: limits.workflows,
595
598
  embeddingTimeoutMs: Number(process.env.CONTEXTOS_EMBEDDING_DEBUG_TIMEOUT_MS || 5000)
596
599
  });
597
600
  const rules = scored.scoredRules;
598
- const relevantFiles = scored.suggestedFiles.slice(0, 7);
599
- const suggestedSkills = (scored.suggestedSkills || []).slice(0, 7);
600
- const suggestedWorkflows = (scored.suggestedWorkflows || []).slice(0, 2);
601
+ const relevantFiles = scored.suggestedFiles.slice(0, limits.files);
602
+ const suggestedSkills = (scored.suggestedSkills || []).slice(0, limits.skills);
603
+ const suggestedWorkflows = (scored.suggestedWorkflows || []).slice(0, limits.workflows);
601
604
  const scheduled = scheduleContext({ rules, relevantFiles, suggestedSkills, suggestedWorkflows });
602
605
 
603
606
  console.log("ContextOS debug");
@@ -661,31 +664,36 @@ async function warmEmbeddings(task, { syncMarketplace = true, quiet = false } =
661
664
 
662
665
  async function warmWorkspaceIndexes({ task = "project context" } = {}) {
663
666
  const cwd = process.cwd();
667
+ const dataDir = contextOSDataDir();
668
+ const modelReady = isModelCacheReady(dataDir);
669
+ const allowRemote = shouldAllowRemoteWarm(modelReady);
664
670
  const merged = readAgentsChain({ cwd });
665
671
  const rules = scoreRules(filterActionableRules(parseRules(merged.content)), task, []);
666
672
  const result = await warmRuleEmbeddings({
667
673
  rules,
668
674
  task,
669
- dataDir: contextOSDataDir(),
675
+ dataDir,
670
676
  sources: merged.sources,
671
- allowRemote: true
677
+ allowRemote
672
678
  });
673
679
  const fileResult = await warmFileEmbeddings({
674
680
  cwd,
675
- dataDir: contextOSDataDir(),
676
- allowRemote: true
681
+ dataDir,
682
+ allowRemote
677
683
  });
678
684
  const skillResult = await warmSkillEmbeddings({
679
685
  cwd,
680
- dataDir: contextOSDataDir(),
681
- allowRemote: true
686
+ dataDir,
687
+ allowRemote
682
688
  });
683
689
  const workflowResult = await warmWorkflowEmbeddings({
684
690
  cwd,
685
- dataDir: contextOSDataDir(),
686
- allowRemote: true
691
+ dataDir,
692
+ allowRemote
687
693
  });
688
- const graphEmbedding = embedCodeReviewGraph({ cwd });
694
+ const graphEmbedding = allowRemote
695
+ ? embedCodeReviewGraph({ cwd })
696
+ : { status: "skipped", reason: "remote-embedding-disabled" };
689
697
  return {
690
698
  ruleCount: result.count,
691
699
  fileCount: fileResult.count,
@@ -696,12 +704,28 @@ async function warmWorkspaceIndexes({ task = "project context" } = {}) {
696
704
  };
697
705
  }
698
706
 
707
+ function shouldAllowRemoteWarm(modelReady) {
708
+ if (modelReady) return false;
709
+ if (process.env.CONTEXTOS_EMBEDDING_ALLOW_REMOTE !== undefined) {
710
+ return process.env.CONTEXTOS_EMBEDDING_ALLOW_REMOTE === "1";
711
+ }
712
+ return !isCiEnvironment();
713
+ }
714
+
715
+ function isCiEnvironment() {
716
+ return process.env.CI === "true"
717
+ || process.env.GITHUB_ACTIONS === "true"
718
+ || process.env.CONTINUOUS_INTEGRATION === "true"
719
+ || process.env.BUILD_ID !== undefined
720
+ || process.env.RUN_ID !== undefined;
721
+ }
722
+
699
723
  async function refresh() {
700
724
  const marketplaceSync = syncActiveCodexMarketplace();
701
725
  const invalidatedBridge = invalidateCtxMcpSocket(contextOSDataDir());
702
726
  const warmResult = await warmInstallEmbeddings();
703
727
  console.log(`Marketplace: ${marketplaceSync.synced ? "synced" : "already active"} (${marketplaceSync.targetRoot})`);
704
- console.log(`Indexes: ${warmResult.fileCount || 0} file paths rebuilt`);
728
+ console.log(`Indexes: ${warmResult.fileCount || 0} file paths rebuilt, ${warmResult.skillCount || 0} skills indexed`);
705
729
  console.log(`Graph embeddings: ${formatCodeReviewGraphEmbedding(warmResult.graphEmbedding)}`);
706
730
  if (invalidatedBridge) console.log("Bridge: stale private socket invalidated");
707
731
  console.log("Restart Codex if ctx-mcp was already running.");
@@ -728,6 +752,21 @@ async function askSetupYesNo(rl, question, defaultValue = true) {
728
752
  return !/^n(o)?$/i.test(answer.trim());
729
753
  }
730
754
 
755
+ async function askOutputLimit({ option, currentValue }) {
756
+ if (!process.stdin.isTTY) return currentValue;
757
+ const rl = readline.createInterface({ input, output });
758
+ try {
759
+ const answer = await rl.question(`◇ ${option.label} limit (0-${option.max}, current ${currentValue}): `);
760
+ const trimmed = answer.trim();
761
+ if (!trimmed) return currentValue;
762
+ const value = Number(trimmed);
763
+ if (!Number.isFinite(value)) return currentValue;
764
+ return Math.max(0, Math.min(option.max, Math.trunc(value)));
765
+ } finally {
766
+ rl.close();
767
+ }
768
+ }
769
+
731
770
  async function setup({ args = [], cwd = process.cwd() } = {}) {
732
771
  const options = parseSetupArgs(args);
733
772
  const interactive = !options.yes && process.stdin.isTTY;
@@ -777,7 +816,8 @@ async function setup({ args = [], cwd = process.cwd() } = {}) {
777
816
  console.log("◇ Configure prompt output:");
778
817
  outputConfig = await configureOutputSections({
779
818
  dataRoot: contextOSDataDir(),
780
- select: multiSelect
819
+ select: multiSelect,
820
+ askLimit: askOutputLimit
781
821
  });
782
822
  }
783
823
 
@@ -786,7 +826,8 @@ async function setup({ args = [], cwd = process.cwd() } = {}) {
786
826
  for (const line of setupSummaryLines({
787
827
  cwd,
788
828
  ...options,
789
- promptSections: enabledOutputSectionsLabel(outputConfig)
829
+ promptSections: enabledOutputSectionsLabel(outputConfig),
830
+ promptLimits: outputConfigLimitsLabel(outputConfig)
790
831
  })) console.log(`│ ${line}`);
791
832
  console.log("");
792
833
 
@@ -871,7 +912,8 @@ try {
871
912
  } else if (command === "--config" || command === "config") {
872
913
  await configureOutputSections({
873
914
  dataRoot: contextOSDataDir(),
874
- select: multiSelect
915
+ select: multiSelect,
916
+ askLimit: askOutputLimit
875
917
  });
876
918
  } else if (command === "install") {
877
919
  const copy = args.includes("--copy");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhpnq1807/contextos",
3
- "version": "0.5.45",
3
+ "version": "0.5.49",
4
4
  "description": "Task-aware AGENTS.md context injection and compliance reporting for Codex, Claude Code, and Antigravity.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctx",
3
- "version": "0.5.45",
3
+ "version": "0.5.49",
4
4
  "description": "Inject task-relevant AGENTS.md rules into Codex through plugin hooks.",
5
5
  "author": {
6
6
  "name": "ContextOS"
@@ -65,13 +65,28 @@ export async function warmRuleEmbeddings({
65
65
  ...rules.map((rule) => rule.content || "")
66
66
  ].filter((text) => String(text).trim()))];
67
67
 
68
- const cache = await openEmbeddingCache(dataDir);
69
- const embedder = await getExtractor({ allowRemote, dataDir });
70
- for (const text of texts) {
71
- await getCachedEmbedding({ cache, embedder, text, sources });
68
+ let cache;
69
+ try {
70
+ cache = await openEmbeddingCache(dataDir);
71
+ const embedder = await getExtractor({ allowRemote, dataDir });
72
+ for (const text of texts) {
73
+ await getCachedEmbedding({ cache, embedder, text, sources, flush: false });
74
+ }
75
+ cache.close();
76
+ return { count: texts.length, cachePath: cache.path, status: "enabled" };
77
+ } catch (error) {
78
+ try {
79
+ cache?.close();
80
+ } catch {
81
+ // Ignore close failures while reporting a best-effort warm result.
82
+ }
83
+ return {
84
+ count: 0,
85
+ cachePath: path.join(dataDir, "embeddings.db"),
86
+ status: "warm-failed",
87
+ error: error?.message || String(error)
88
+ };
72
89
  }
73
- cache.close();
74
- return { count: texts.length, cachePath: cache.path };
75
90
  }
76
91
 
77
92
  export async function searchIndexedEmbeddings({
@@ -106,20 +121,35 @@ export async function warmIndexedEmbeddings({
106
121
  return { count: 0, cachePath: path.join(dataDir, "embeddings.db"), status: "missing-model" };
107
122
  }
108
123
 
109
- const cache = await openEmbeddingCache(dataDir);
110
- const embedder = await getExtractor({ allowRemote, dataDir });
111
- if (String(task || "").trim()) await getCachedEmbedding({ cache, embedder, text: task, sources });
112
-
113
- const indexed = [];
114
- for (const item of items) {
115
- const text = String(item.text || "");
116
- if (!item.id || !text.trim()) continue;
117
- const vector = await getCachedEmbedding({ cache, embedder, text, sources });
118
- indexed.push({ id: item.id, text, vector });
124
+ let cache;
125
+ try {
126
+ cache = await openEmbeddingCache(dataDir);
127
+ const embedder = await getExtractor({ allowRemote, dataDir });
128
+ if (String(task || "").trim()) await getCachedEmbedding({ cache, embedder, text: task, sources });
129
+
130
+ const indexed = [];
131
+ for (const item of items) {
132
+ const text = String(item.text || "");
133
+ if (!item.id || !text.trim()) continue;
134
+ const vector = await getCachedEmbedding({ cache, embedder, text, sources, flush: false });
135
+ indexed.push({ id: item.id, text, vector });
136
+ }
137
+ cache.replaceIndex(kind, indexed);
138
+ cache.close();
139
+ return { count: indexed.length, cachePath: cache.path, status: "enabled" };
140
+ } catch (error) {
141
+ try {
142
+ cache?.close();
143
+ } catch {
144
+ // Ignore close failures while reporting a best-effort warm result.
145
+ }
146
+ return {
147
+ count: 0,
148
+ cachePath: path.join(dataDir, "embeddings.db"),
149
+ status: "warm-failed",
150
+ error: error?.message || String(error)
151
+ };
119
152
  }
120
- cache.replaceIndex(kind, indexed);
121
- cache.close();
122
- return { count: indexed.length, cachePath: cache.path };
123
153
  }
124
154
 
125
155
  async function enhanceRuleScores(rules, task, { dataDir, sources, allowRemote }) {
@@ -187,7 +217,10 @@ async function getExtractor({ allowRemote, dataDir }) {
187
217
  return transformers.pipeline("feature-extraction", DEFAULT_MODEL, {
188
218
  quantized: true
189
219
  });
190
- })());
220
+ })().catch((error) => {
221
+ extractorPromises.delete(key);
222
+ throw error;
223
+ }));
191
224
  }
192
225
  return extractorPromises.get(key);
193
226
  }
@@ -206,7 +239,7 @@ export function isModelCacheReady(dataDir = defaultDataRoot()) {
206
239
  ].every((relativePath) => fs.existsSync(path.join(modelDir, relativePath)));
207
240
  }
208
241
 
209
- async function getCachedEmbedding({ cache, embedder, text, sources }) {
242
+ async function getCachedEmbedding({ cache, embedder, text, sources, flush = true }) {
210
243
  const key = cacheKey(text, sources);
211
244
  const existing = cache.get(key);
212
245
  if (existing) return existing;
@@ -216,7 +249,7 @@ async function getCachedEmbedding({ cache, embedder, text, sources }) {
216
249
  normalize: true
217
250
  });
218
251
  const embedding = Array.from(output.data || []);
219
- cache.set(key, embedding);
252
+ cache.set(key, embedding, { flush });
220
253
  return embedding;
221
254
  }
222
255
 
@@ -225,6 +258,7 @@ export async function openEmbeddingCache(dataDir) {
225
258
  const cachePath = path.join(dataDir, "embeddings.db");
226
259
  const SQL = await getSql();
227
260
  const db = initializeEmbeddingDatabase(SQL, cachePath);
261
+ let dirty = false;
228
262
 
229
263
  return {
230
264
  path: cachePath,
@@ -238,12 +272,16 @@ export async function openEmbeddingCache(dataDir) {
238
272
  stmt.free();
239
273
  }
240
274
  },
241
- set(key, vector) {
275
+ set(key, vector, { flush = true } = {}) {
242
276
  db.run(
243
277
  "INSERT OR REPLACE INTO embeddings (key, model, vector, updated_at) VALUES (?, ?, ?, ?)",
244
278
  [key, DEFAULT_MODEL, JSON.stringify(vector), new Date().toISOString()]
245
279
  );
246
- writeDatabaseAtomically(cachePath, db);
280
+ dirty = true;
281
+ if (flush) {
282
+ writeDatabaseAtomically(cachePath, db);
283
+ dirty = false;
284
+ }
247
285
  },
248
286
  listIndexed(kind) {
249
287
  const stmt = db.prepare("SELECT id, text, vector FROM embedding_index WHERE kind = ? AND model = ?");
@@ -268,9 +306,10 @@ export async function openEmbeddingCache(dataDir) {
268
306
  );
269
307
  }
270
308
  writeDatabaseAtomically(cachePath, db);
309
+ dirty = false;
271
310
  },
272
311
  close() {
273
- writeDatabaseAtomically(cachePath, db);
312
+ if (dirty) writeDatabaseAtomically(cachePath, db);
274
313
  db.close();
275
314
  }
276
315
  };
@@ -91,6 +91,7 @@ export function formatCodeReviewGraphEmbedding(result) {
91
91
  }
92
92
  if (result.reason === "missing-graph-index") return "skipped (no .code-review-graph/graph.db)";
93
93
  if (result.reason === "missing-code-review-graph-python") return "skipped (code-review-graph Python unavailable)";
94
+ if (result.reason === "remote-embedding-disabled") return "skipped (remote embedding disabled)";
94
95
  return `skipped (${result.error || result.reason || "unavailable"})`;
95
96
  }
96
97
 
@@ -13,9 +13,16 @@ export const OUTPUT_SECTION_OPTIONS = [
13
13
  { value: "workflows", label: "Suggested workflow for this task", hint: "Include matching workflow recommendations." }
14
14
  ];
15
15
 
16
+ export const OUTPUT_LIMIT_OPTIONS = [
17
+ { value: "files", label: "Suggested files", defaultValue: 5, max: 20 },
18
+ { value: "skills", label: "Suggested skills", defaultValue: 5, max: 10 },
19
+ { value: "workflows", label: "Suggested workflows", defaultValue: 5, max: 5 }
20
+ ];
21
+
16
22
  export function defaultOutputConfig() {
17
23
  return {
18
- sections: Object.fromEntries(OUTPUT_SECTION_OPTIONS.map((option) => [option.value, true]))
24
+ sections: Object.fromEntries(OUTPUT_SECTION_OPTIONS.map((option) => [option.value, true])),
25
+ limits: Object.fromEntries(OUTPUT_LIMIT_OPTIONS.map((option) => [option.value, option.defaultValue]))
19
26
  };
20
27
  }
21
28
 
@@ -49,9 +56,19 @@ export function enabledOutputSectionsLabel(config = loadOutputConfig()) {
49
56
  return enabled.length ? enabled.join(", ") : "(none)";
50
57
  }
51
58
 
59
+ export function outputConfigLimits(config = loadOutputConfig()) {
60
+ return normalizeOutputConfig(config).limits;
61
+ }
62
+
63
+ export function outputConfigLimitsLabel(config = loadOutputConfig()) {
64
+ const limits = outputConfigLimits(config);
65
+ return OUTPUT_LIMIT_OPTIONS.map((option) => `${option.value}: ${limits[option.value]}`).join(", ");
66
+ }
67
+
52
68
  export async function configureOutputSections({
53
69
  dataRoot = defaultDataRoot(),
54
70
  select,
71
+ askLimit,
55
72
  logger = console.log
56
73
  } = {}) {
57
74
  if (typeof select !== "function") throw new Error("configureOutputSections requires a multi-select function");
@@ -64,11 +81,19 @@ export async function configureOutputSections({
64
81
  }))
65
82
  });
66
83
  const selectedSet = new Set(selected);
84
+ const limits = {};
85
+ for (const option of OUTPUT_LIMIT_OPTIONS) {
86
+ limits[option.value] = typeof askLimit === "function"
87
+ ? await askLimit({ option, currentValue: current.limits[option.value] })
88
+ : current.limits[option.value];
89
+ }
67
90
  const saved = saveOutputConfig({
68
- sections: Object.fromEntries(OUTPUT_SECTION_OPTIONS.map((option) => [option.value, selectedSet.has(option.value)]))
91
+ sections: Object.fromEntries(OUTPUT_SECTION_OPTIONS.map((option) => [option.value, selectedSet.has(option.value)])),
92
+ limits
69
93
  }, { dataRoot });
70
94
  logger(`│ Saved ContextOS prompt section config: ${outputConfigPath(dataRoot)}`);
71
95
  logger(`│ Enabled sections: ${enabledOutputSectionsLabel(saved)}`);
96
+ logger(`│ Suggest limits: ${outputConfigLimitsLabel(saved)}`);
72
97
  return saved;
73
98
  }
74
99
 
@@ -80,6 +105,16 @@ function normalizeOutputConfig(config = {}) {
80
105
  typeof config.sections?.[option.value] === "boolean"
81
106
  ? config.sections[option.value]
82
107
  : defaults.sections[option.value]
108
+ ])),
109
+ limits: Object.fromEntries(OUTPUT_LIMIT_OPTIONS.map((option) => [
110
+ option.value,
111
+ normalizeLimit(config.limits?.[option.value], option)
83
112
  ]))
84
113
  };
85
114
  }
115
+
116
+ function normalizeLimit(value, option) {
117
+ const number = Number(value);
118
+ if (!Number.isFinite(number)) return option.defaultValue;
119
+ return Math.max(0, Math.min(option.max, Math.trunc(number)));
120
+ }