cursor-opencode-provider 0.7.5 → 0.8.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 (60) hide show
  1. package/README.md +9 -7
  2. package/dist/agent-url.js +2 -2
  3. package/dist/auth-renewal.d.ts +85 -0
  4. package/dist/auth-renewal.js +284 -0
  5. package/dist/auth.d.ts +38 -22
  6. package/dist/auth.js +79 -96
  7. package/dist/classic-tools.d.ts +15 -0
  8. package/dist/classic-tools.js +45 -0
  9. package/dist/context/build.d.ts +42 -5
  10. package/dist/context/build.js +96 -92
  11. package/dist/context/dynamic-catalog.d.ts +3 -28
  12. package/dist/context/dynamic-catalog.js +25 -130
  13. package/dist/context/epoch.d.ts +6 -4
  14. package/dist/context/epoch.js +22 -10
  15. package/dist/context/frozen.js +37 -9
  16. package/dist/context/index.d.ts +1 -1
  17. package/dist/context/index.js +1 -1
  18. package/dist/context/overlay.d.ts +2 -23
  19. package/dist/context/overlay.js +6 -30
  20. package/dist/context/paths.d.ts +7 -4
  21. package/dist/context/paths.js +13 -9
  22. package/dist/context/rules.d.ts +3 -15
  23. package/dist/context/rules.js +10 -186
  24. package/dist/debug.d.ts +2 -0
  25. package/dist/debug.js +4 -0
  26. package/dist/errors.d.ts +13 -0
  27. package/dist/errors.js +27 -3
  28. package/dist/host-agent-mode.js +2 -2
  29. package/dist/image-save.js +3 -3
  30. package/dist/index.d.ts +10 -0
  31. package/dist/language-model.d.ts +59 -8
  32. package/dist/language-model.js +458 -118
  33. package/dist/opencode2/integration.d.ts +30 -7
  34. package/dist/opencode2/integration.js +75 -46
  35. package/dist/plan-execution-kickoff.js +2 -2
  36. package/dist/plugin-opencode2.js +10 -16
  37. package/dist/plugin.d.ts +0 -10
  38. package/dist/plugin.js +138 -109
  39. package/dist/protocol/blob-store.js +3 -2
  40. package/dist/protocol/conversation-bind.js +1 -1
  41. package/dist/protocol/conversation-persistence.d.ts +2 -0
  42. package/dist/protocol/conversation-persistence.js +11 -1
  43. package/dist/protocol/conversation-state.d.ts +2 -0
  44. package/dist/protocol/conversation-state.js +19 -0
  45. package/dist/protocol/create-plan.js +2 -1
  46. package/dist/protocol/messages.js +22 -4
  47. package/dist/protocol/request.d.ts +11 -9
  48. package/dist/protocol/request.js +15 -25
  49. package/dist/protocol/tools.d.ts +17 -10
  50. package/dist/protocol/tools.js +53 -93
  51. package/dist/protocol/turn-provenance.d.ts +52 -0
  52. package/dist/protocol/turn-provenance.js +174 -0
  53. package/dist/session.d.ts +9 -1
  54. package/dist/shared.d.ts +7 -0
  55. package/dist/shared.js +7 -0
  56. package/package.json +1 -1
  57. package/dist/context/agents.d.ts +0 -12
  58. package/dist/context/agents.js +0 -136
  59. package/dist/context/skills.d.ts +0 -11
  60. package/dist/context/skills.js +0 -110
@@ -1,10 +1,3 @@
1
- export type AgentSkillLike = {
2
- /** OpenCode skill id (frontmatter `name`, else directory name). */
3
- id?: unknown;
4
- full_path?: unknown;
5
- description?: unknown;
6
- content?: unknown;
7
- };
8
1
  /**
9
2
  * Configured MCP servers that own at least one advertised tool. Uses the same
10
3
  * server resolution as the RequestContext descriptors, so guidance never names
@@ -12,32 +5,14 @@ export type AgentSkillLike = {
12
5
  * `sourceName` when present).
13
6
  */
14
7
  export declare function listAdvertisedMcpServers(toolNames: Iterable<string>, knownMcpServers?: Iterable<string>): string[];
15
- /** Skill id OpenCode's `skill` tool expects: the discovered id, else `…/skills/<id>/SKILL.md`. */
16
- export declare function skillNameFromAgentSkill(skill: AgentSkillLike): string | undefined;
17
8
  /**
18
9
  * Shared system-guidance line: name advertised dynamic-catalog tools and prefer
19
10
  * them over Grep/Shell when skills or project rules apply. Concrete skill ids
20
- * are deliberately absent: this line is part of the frozen baseline system
21
- * context, and per-turn ids go through `buildSkillCatalogNudge` instead.
11
+ * stay out of the frozen baseline (they live in the host system prompt / skill
12
+ * tool, and in host `<system-update>` when the catalog changes — same split as
13
+ * OpenCode's SkillGuidance / SkillInstructions).
22
14
  */
23
15
  export declare function buildDynamicCatalogRoutingInstruction(options: {
24
16
  toolNames: Iterable<string>;
25
17
  knownMcpServers?: Iterable<string>;
26
18
  }): string | undefined;
27
- /**
28
- * Mid-Conversation nudge after RequestContext skills are known. Prefer skills
29
- * that match the user turn; otherwise stay quiet unless the user asked about
30
- * skills explicitly (avoids dumping dozens of firecrawl-* ids every turn).
31
- * `hasSkillTool` must reflect what the host permits this turn, not the
32
- * epoch-held advertisement.
33
- */
34
- export declare function buildSkillCatalogNudge(options: {
35
- hasSkillTool: boolean;
36
- skills: readonly AgentSkillLike[];
37
- userText?: string;
38
- /**
39
- * Host skill parameter key from the advertised schema.
40
- * OpenCode 1.x: `name`. OpenCode 2.0: `id`.
41
- */
42
- skillArgKey?: "name" | "id";
43
- }): string | undefined;
@@ -2,43 +2,22 @@ import { resolveToolServerIdentity } from "../protocol/tools.js";
2
2
  /**
3
3
  * Issue #29: Cursor keeps OpenCode `skill` and MCP tools off its native
4
4
  * top-level function list. Both OpenCode 1.x and OpenCode 2.0 reach the model
5
- * as MCP-shaped RequestContext ads, then GetDynamicTools / CallDynamicTool
6
- * (exec `mcp_args`). This module builds the shared routing guidance and the
7
- * skill-aware Mid-Conversation nudge used on both hosts.
5
+ * as names in RequestContext `mcp_meta_tool_options`, then GetDynamicTools /
6
+ * CallDynamicTool (exec `mcp_args`). This module builds the shared routing
7
+ * guidance for both hosts.
8
+ *
9
+ * Skill names and descriptions are not repeated here: OpenCode puts them in
10
+ * its own system prompt (`SystemPrompt.skills` / `SkillGuidance` /
11
+ * `SkillInstructions`) and announces catalog changes itself (OpenCode 2
12
+ * `<system-update>`).
8
13
  *
9
14
  * OpenCode 2 still needs `exposeDirectMcpTools` so MCP tools leave Code Mode
10
15
  * and enter the AI SDK catalog; without that step they never reach this
11
16
  * advertisement path at all.
12
17
  */
13
- const MAX_NAMED_SKILLS = 8;
14
18
  const MAX_MCP_SERVERS_IN_GUIDANCE = 8;
15
19
  /** Synthetic server builtins and unknown tools are advertised under (`toolsToMcpDescriptors`). */
16
20
  const DEFAULT_TOOL_SERVER = "opencode";
17
- /** Common words that carry no skill-matching signal. */
18
- const STOPWORDS = new Set([
19
- "about", "all", "and", "any", "are", "but", "can", "for", "from", "has", "have",
20
- "how", "into", "its", "not", "one", "only", "that", "the", "their", "them",
21
- "then", "there", "these", "this", "use", "used", "uses", "using", "via", "was",
22
- "what", "when", "which", "who", "why", "will", "with", "you", "your",
23
- // Host/MCP instruction boilerplate — otherwise a server-instruction dump
24
- // matches every firecrawl/search skill on "search"/"tools"/"server".
25
- "tool", "tools", "server", "servers", "call", "calls", "fetch", "search",
26
- "documentation", "library", "framework", "query", "please", "through",
27
- ]);
28
- /** An explicit question/request about skills, not incidental prose ("has the skills to"). */
29
- const SKILL_INQUIRY = /\b(?:which|what|list|show|available|any|use|using|call|invoke|load|run)\b[^.?!\n]{0,40}\bskills?\b|\bskills?\b[^.?!\n]{0,40}\b(?:available|installed|loaded|exist|do you have|can you)\b/i;
30
- /** Description-only hits above this look like instruction-dump false positives. */
31
- const MAX_DESCRIPTION_ONLY_MATCHES = 2;
32
- /**
33
- * Match against the live user utterance only. Mid-conversation / MCP instruction
34
- * injections and prior reminders must not re-trigger skill dumps.
35
- */
36
- function skillMatchCorpus(userText) {
37
- return userText
38
- .replace(/<system_reminder\b[^>]*>[\s\S]*?<\/system_reminder>/gi, " ")
39
- .replace(/<system-update\b[^>]*>[\s\S]*?<\/system-update>/gi, " ")
40
- .replace(/New MCP server instructions[\s\S]*?(?=\n[A-Z]|\n\n|$)/gi, " ");
41
- }
42
21
  function listWithOverflow(items, limit) {
43
22
  const shown = items.slice(0, limit).map((item) => `\`${item}\``).join(", ");
44
23
  const hidden = items.length - limit;
@@ -62,22 +41,12 @@ export function listAdvertisedMcpServers(toolNames, knownMcpServers = []) {
62
41
  }
63
42
  return [...servers];
64
43
  }
65
- /** Skill id OpenCode's `skill` tool expects: the discovered id, else `…/skills/<id>/SKILL.md`. */
66
- export function skillNameFromAgentSkill(skill) {
67
- const id = typeof skill.id === "string" ? skill.id.trim() : "";
68
- if (id)
69
- return id;
70
- const fullPath = typeof skill.full_path === "string" ? skill.full_path.trim() : "";
71
- const parts = fullPath.split(/[\\/]+/).filter(Boolean);
72
- if (parts.length < 2 || parts.at(-1).toLowerCase() !== "skill.md")
73
- return undefined;
74
- return parts.at(-2);
75
- }
76
44
  /**
77
45
  * Shared system-guidance line: name advertised dynamic-catalog tools and prefer
78
46
  * them over Grep/Shell when skills or project rules apply. Concrete skill ids
79
- * are deliberately absent: this line is part of the frozen baseline system
80
- * context, and per-turn ids go through `buildSkillCatalogNudge` instead.
47
+ * stay out of the frozen baseline (they live in the host system prompt / skill
48
+ * tool, and in host `<system-update>` when the catalog changes — same split as
49
+ * OpenCode's SkillGuidance / SkillInstructions).
81
50
  */
82
51
  export function buildDynamicCatalogRoutingInstruction(options) {
83
52
  const names = [...options.toolNames];
@@ -91,93 +60,19 @@ export function buildDynamicCatalogRoutingInstruction(options) {
91
60
  if (mcpServers.length > 0) {
92
61
  extras.push(`MCP servers such as ${listWithOverflow(mcpServers, MAX_MCP_SERVERS_IN_GUIDANCE)}`);
93
62
  }
94
- return (`- OpenCode host tools that are not in Cursor's native top-level list (including ${extras.join(" and ")}) ` +
95
- "are reached through GetDynamicTools / CallDynamicTool (or the host's equivalent dynamic catalog). " +
96
- "When a skill matches or project rules name an MCP server, discover and call those tools that way " +
97
- "before Grep/Shell fallbacks. Do not narrate that they are unavailable.");
98
- }
99
- function wordTokens(text) {
100
- return new Set(text
101
- .toLowerCase()
102
- .split(/[^a-z0-9]+/)
103
- .filter((token) => token.length >= 3 && !STOPWORDS.has(token)));
104
- }
105
- function descriptionMatchesUserText(description, userTokens) {
106
- let hits = 0;
107
- let distinctive = false;
108
- for (const token of wordTokens(description)) {
109
- if (!userTokens.has(token))
110
- continue;
111
- hits += 1;
112
- if (token.length >= 8)
113
- distinctive = true;
114
- if (hits >= 2)
115
- return true;
116
- }
117
- // Single distinctive token (library names, skill ids) is enough.
118
- return distinctive;
119
- }
120
- function nameMatchesUserText(name, userText) {
121
- const haystack = userText.toLowerCase();
122
- const candidates = [name.toLowerCase(), ...name.toLowerCase().split(/[-_]+/).filter((part) => part.length >= 5)];
123
- for (const candidate of [...new Set(candidates)]) {
124
- const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
125
- if (new RegExp(`(?<![a-z0-9_-])${escaped}(?![a-z0-9_-])`).test(haystack))
126
- return true;
63
+ const lines = [
64
+ `- OpenCode host tools that are not in Cursor's native top-level list (including ${extras.join(" and ")}) ` +
65
+ "are reached through GetDynamicTools / CallDynamicTool (or the host's equivalent dynamic catalog). " +
66
+ "When a skill matches or project rules name an MCP server, discover and call those tools that way " +
67
+ "before Grep/Shell fallbacks. Do not narrate that they are unavailable.",
68
+ ];
69
+ if (hasSkill) {
70
+ // Mirror OpenCode 1 SystemPrompt.skills / OC2 SkillInstructions.render.
71
+ lines.push("- Skills provide specialized instructions and workflows for specific tasks. " +
72
+ "Use the `skill` tool to load a skill when a task matches its description " +
73
+ "(the host system prompt and `skill` tool carry names and descriptions). " +
74
+ "A skill that is already present in the conversation as a `<skill_content>` block " +
75
+ "does not need to be invoked again.");
127
76
  }
128
- return false;
129
- }
130
- /**
131
- * Mid-Conversation nudge after RequestContext skills are known. Prefer skills
132
- * that match the user turn; otherwise stay quiet unless the user asked about
133
- * skills explicitly (avoids dumping dozens of firecrawl-* ids every turn).
134
- * `hasSkillTool` must reflect what the host permits this turn, not the
135
- * epoch-held advertisement.
136
- */
137
- export function buildSkillCatalogNudge(options) {
138
- if (!options.hasSkillTool || options.skills.length === 0)
139
- return undefined;
140
- const userText = skillMatchCorpus(options.userText ?? "");
141
- const userTokens = wordTokens(userText);
142
- const skillInquiry = SKILL_INQUIRY.test(userText);
143
- const named = options.skills
144
- .map((skill) => {
145
- const name = skillNameFromAgentSkill(skill);
146
- if (!name)
147
- return undefined;
148
- const description = typeof skill.description === "string" ? skill.description : "";
149
- const nameHit = nameMatchesUserText(name, userText);
150
- const descHit = !!description && descriptionMatchesUserText(description, userTokens);
151
- return { name, nameHit, descHit };
152
- })
153
- .filter((row) => !!row);
154
- if (named.length === 0)
155
- return undefined;
156
- const nameMatched = named.filter((row) => row.nameHit);
157
- const descOnly = named.filter((row) => row.descHit && !row.nameHit);
158
- // Mass description-only hits are almost always instruction-dump noise
159
- // (MCP server blurbs sharing "search"/"tools" with firecrawl skills).
160
- const descMatched = descOnly.length <= MAX_DESCRIPTION_ONLY_MATCHES ? descOnly : [];
161
- const selected = nameMatched.length > 0
162
- ? [...nameMatched, ...descMatched]
163
- : skillInquiry
164
- ? named
165
- : descMatched;
166
- if (selected.length === 0)
167
- return undefined;
168
- const ids = [...new Set(selected.map((row) => row.name))];
169
- // Prefer the advertised schema key; fall back to naming both so a stale
170
- // Mid-Conversation hint cannot contradict GetDynamicTools on either host.
171
- const argKey = options.skillArgKey;
172
- const callHint = argKey
173
- ? `Call \`skill\` with \`{ "${argKey}": "<skill-id>" }\` (host schema key is \`${argKey}\`) `
174
- : "Call `skill` with the skill identifier using the parameter key from GetDynamicTools " +
175
- "(`name` on OpenCode 1.x, `id` on OpenCode 2.0) ";
176
- return ("<system_reminder>\n" +
177
- "OpenCode skills are invoked with the host `skill` tool through GetDynamicTools / CallDynamicTool " +
178
- `(MCP server \`${DEFAULT_TOOL_SERVER}\`, tool \`skill\`), not by Grep/Read of SKILL.md. ` +
179
- `Relevant skill id(s) this turn: ${listWithOverflow(ids, MAX_NAMED_SKILLS)}. ` +
180
- callHint +
181
- "before answering from memory or falling back to shell/search tools.\n" +
182
- "</system_reminder>");
77
+ return lines.join("\n");
183
78
  }
@@ -7,10 +7,10 @@ export type ContextSourceSnapshot = {
7
7
  };
8
8
  export type ContextEpoch = {
9
9
  conversationId: string;
10
- /** Exact system text seeded at epoch start. Empty when recovered from checkpoint. */
10
+ /** Exact system text at epoch start. Empty if a legacy checkpoint has no persisted rule. */
11
11
  baselineSystemPrompt: string;
12
12
  baselineHash: string;
13
- /** True when restart hydrated past a checkpoint without the original baseline bytes. */
13
+ /** True when restart hydrated past a checkpoint without the last source snapshot. */
14
14
  recovered: boolean;
15
15
  snapshot: ContextSourceSnapshot;
16
16
  };
@@ -27,6 +27,8 @@ export type AdmitContextEpochInput = {
27
27
  * never folded into the frozen baseline.
28
28
  */
29
29
  oneShotReminders?: readonly string[];
30
+ /** Original system rule restored with RequestContext after a restart. */
31
+ recoveredBaseline?: string;
30
32
  };
31
33
  export type AdmitContextEpochResult = {
32
34
  action: "initialize" | "unchanged" | "updated" | "recovered";
@@ -42,8 +44,8 @@ export type AdmitContextEpochResult = {
42
44
  * - First seed: freeze baseline, return it as seedSystemPrompt; one-shots → mid.
43
45
  * - Checkpoint turn: never returns seedSystemPrompt; source diffs + one-shots → mid.
44
46
  * - Reseed same epoch: return frozen baseline bytes (not live host text).
45
- * - Recovered (restart past a checkpoint, original bytes unknown): never freeze
46
- * live host text as a new baseline and never send a seed systemPrompt.
47
+ * - Recovered: restore the persisted rule and reassert live context once on the
48
+ * user turn. A legacy checkpoint without a rule has no original bytes.
47
49
  */
48
50
  export declare function admitContextEpoch(input: AdmitContextEpochInput): AdmitContextEpochResult;
49
51
  export declare function getContextEpoch(conversationId: string): ContextEpoch | undefined;
@@ -4,11 +4,13 @@
4
4
  * Mirrors OpenCode research CONTEXT.md:
5
5
  * - One Baseline System Context per conversation_id (provider-cache prefix)
6
6
  * - Later Context Source changes → Mid-Conversation System Message on the
7
- * user turn (Cursor checkpoint Runs cannot rewrite systemPrompt)
7
+ * user turn; the baseline itself never changes within the epoch
8
8
  * - Epoch ends on conversation remint (compaction / post-compaction rebase)
9
9
  *
10
- * Cursor constraint: systemPrompt is only sent on seed Runs. Checkpointed
11
- * Runs admit updates exclusively via the live user message.
10
+ * The baseline reaches Cursor as the frozen system-instructions rule in
11
+ * RequestContext (`systemInstructionsRule`), on every Run. `seedSystemPrompt`
12
+ * marks the Runs that start a conversation; it is not sent as a `system`
13
+ * message, which Cursor does not follow.
12
14
  */
13
15
  import { createHash } from "node:crypto";
14
16
  import { trace } from "../debug.js";
@@ -80,8 +82,8 @@ function combineMessages(parts) {
80
82
  * - First seed: freeze baseline, return it as seedSystemPrompt; one-shots → mid.
81
83
  * - Checkpoint turn: never returns seedSystemPrompt; source diffs + one-shots → mid.
82
84
  * - Reseed same epoch: return frozen baseline bytes (not live host text).
83
- * - Recovered (restart past a checkpoint, original bytes unknown): never freeze
84
- * live host text as a new baseline and never send a seed systemPrompt.
85
+ * - Recovered: restore the persisted rule and reassert live context once on the
86
+ * user turn. A legacy checkpoint without a rule has no original bytes.
85
87
  */
86
88
  export function admitContextEpoch(input) {
87
89
  const conversationId = input.conversationId;
@@ -90,17 +92,27 @@ export function admitContextEpoch(input) {
90
92
  const existing = byConversationId.get(conversationId);
91
93
  if (!existing) {
92
94
  if (input.hasCheckpoint) {
93
- // Restart / soft-evict recovery: Cursor already holds the baseline in the
94
- // checkpoint. Record the live snapshot without reseeding systemPrompt.
95
+ // Restart / soft-evict recovery: keep the original rule and reassert the
96
+ // live instructions chronologically without changing the cache prefix.
97
+ const baselineSystemPrompt = input.recoveredBaseline ?? "";
95
98
  const epoch = {
96
99
  conversationId,
97
- baselineSystemPrompt: "",
98
- baselineHash: "",
100
+ baselineSystemPrompt,
101
+ baselineHash: baselineSystemPrompt ? sha(baselineSystemPrompt) : "",
99
102
  recovered: true,
100
103
  snapshot: nextSnap,
101
104
  };
102
105
  touch(epoch);
103
- const midConversationMessage = combineMessages(oneShots);
106
+ // The persisted rule is the original prefix, not necessarily the current
107
+ // host instructions. Reconcile live context once before recording its
108
+ // snapshot; otherwise a changed prompt is silently treated as admitted.
109
+ // Even equality with the original baseline needs an update: the last
110
+ // checkpoint may contain an intervening mode/instruction change.
111
+ const liveBaseline = buildBaseline(input);
112
+ const recoveryUpdate = baselineSystemPrompt && liveBaseline
113
+ ? wrapReminder("Current host system instructions after session recovery:\n\n" + liveBaseline)
114
+ : "";
115
+ const midConversationMessage = combineMessages([recoveryUpdate, ...oneShots]);
104
116
  trace(`context epoch: recovered conversationId=${conversationId} ` +
105
117
  `oneShots=${oneShots.length}`);
106
118
  return { action: "recovered", midConversationMessage, epoch };
@@ -1,4 +1,4 @@
1
- import { buildDynamicRequestContext, buildRequestContext, materializeRequestContext, requestContextBase, } from "./build.js";
1
+ import { buildDynamicRequestContext, buildRequestContext, materializeRequestContext, requestContextBase, withSystemInstructions, } from "./build.js";
2
2
  import { clearContextEpoch, endContextEpoch, resetContextEpochsForTests } from "./epoch.js";
3
3
  import { clearOverlayHold, resetOverlayHoldsForTests, transferOverlayHold, } from "./overlay.js";
4
4
  import { trace } from "../debug.js";
@@ -10,10 +10,10 @@ import { encodeMessage } from "../protocol/messages.js";
10
10
  * base plus live plugin/tool overlays. Rebuilding volatile git/layout data on
11
11
  * every Run shifts the prompt prefix and tanks prompt-cache hits.
12
12
  *
13
- * Skills, subagents, plugin metadata, and tool/MCP capabilities are rediscovered
14
- * each Run, then epoch-held (equal ids keep frozen bytes; new ids append) and
15
- * overlaid on that base. If the encoded overlay bytes did not change, the exact
16
- * prior materialized object is reused.
13
+ * Host-advertised subagents, plugin metadata, and tool/MCP capabilities are
14
+ * rediscovered each Run, then epoch-held (equal ids keep frozen bytes; new ids
15
+ * append) and overlaid on that base. If the encoded overlay bytes did not
16
+ * change, the exact prior materialized object is reused.
17
17
  */
18
18
  const byConversationId = new Map();
19
19
  const materializedByConversationId = new Map();
@@ -84,7 +84,7 @@ export function transferFrozenRequestContext(previousConversationId, nextConvers
84
84
  const base = byConversationId.get(previousConversationId);
85
85
  const materialized = materializedByConversationId.get(previousConversationId);
86
86
  // System Context epoch does not transfer — compaction starts a fresh baseline.
87
- // Overlay hold does transfer: same workspace, same advertised skill/agent/plugin
87
+ // Overlay hold does transfer: same workspace, same advertised agent/plugin
88
88
  // bytes, so the comparison seed can still match. clearFrozenRequestContext
89
89
  // also drops epoch state for each id.
90
90
  endContextEpoch(previousConversationId, nextConversationId);
@@ -120,6 +120,23 @@ function sameBytes(a, b) {
120
120
  return false;
121
121
  return true;
122
122
  }
123
+ function advertisedMetaToolCount(context) {
124
+ const meta = context.mcp_meta_tool_options;
125
+ if (!meta || typeof meta !== "object")
126
+ return 0;
127
+ const descriptors = meta.mcp_descriptors;
128
+ if (!Array.isArray(descriptors))
129
+ return 0;
130
+ let count = 0;
131
+ for (const descriptor of descriptors) {
132
+ if (!descriptor || typeof descriptor !== "object")
133
+ continue;
134
+ const tools = descriptor.tools;
135
+ if (Array.isArray(tools))
136
+ count += tools.length;
137
+ }
138
+ return count;
139
+ }
123
140
  function rememberMaterialized(conversationId, context) {
124
141
  const bytes = encodeMessage("RequestContext", context);
125
142
  const previous = materializedByConversationId.get(conversationId);
@@ -143,12 +160,23 @@ export async function getOrBuildRequestContext(conversationId, input, opts) {
143
160
  if (opts?.refresh && conversationId)
144
161
  clearOverlayHold(conversationId);
145
162
  if (!opts?.refresh && conversationId) {
146
- const base = getFrozenRequestContext(conversationId);
163
+ let base = getFrozenRequestContext(conversationId);
147
164
  if (base) {
165
+ // The system-instructions rule is frozen with the base. A new epoch
166
+ // baseline (compaction rebase, binding reset) replaces it; a recovered
167
+ // epoch only fills a base persisted without one.
168
+ const instructed = withSystemInstructions(base, input.systemInstructions);
169
+ if (instructed !== base) {
170
+ setFrozenRequestContext(conversationId, instructed);
171
+ base = getFrozenRequestContext(conversationId);
172
+ trace(`request_context: system instructions frozen conversationId=${conversationId} ` +
173
+ `len=${input.systemInstructions?.text.length ?? 0} ` +
174
+ `authoritative=${input.systemInstructions?.authoritative ?? false}`);
175
+ }
148
176
  const dynamic = await buildDynamicRequestContext(scoped);
149
177
  const materialized = rememberMaterialized(conversationId, materializeRequestContext(base, dynamic));
150
178
  trace(`request_context: materialized conversationId=${conversationId} ` +
151
- `tools=${Array.isArray(materialized.context.tools) ? materialized.context.tools.length : 0} ` +
179
+ `tools=${advertisedMetaToolCount(materialized.context)} ` +
152
180
  `reused=${materialized.reused}`);
153
181
  return materialized;
154
182
  }
@@ -182,7 +210,7 @@ export async function getOrBuildRequestContext(conversationId, input, opts) {
182
210
  ? rememberMaterialized(conversationId, context)
183
211
  : { context: freezeSnapshot(structuredClone(context)), reused: false };
184
212
  trace(`request_context: built+frozen conversationId=${conversationId || "(none)"} ` +
185
- `tools=${Array.isArray(materialized.context.tools) ? materialized.context.tools.length : 0} ` +
213
+ `tools=${advertisedMetaToolCount(materialized.context)} ` +
186
214
  `refresh=${!!opts?.refresh}`);
187
215
  return { context: materialized.context, reused: false };
188
216
  }
@@ -1,5 +1,5 @@
1
1
  export { buildDynamicRequestContext, buildRequestContext, DYNAMIC_REQUEST_CONTEXT_KEYS, materializeRequestContext, requestContextBase, type BuildRequestContextInput, } from "./build.js";
2
- export { buildDynamicCatalogRoutingInstruction, buildSkillCatalogNudge, listAdvertisedMcpServers, skillNameFromAgentSkill, type AgentSkillLike, } from "./dynamic-catalog.js";
2
+ export { buildDynamicCatalogRoutingInstruction, listAdvertisedMcpServers, } from "./dynamic-catalog.js";
3
3
  export { clearFrozenRequestContext, getFrozenRequestContext, getOrBuildRequestContext, MAX_FROZEN_REQUEST_CONTEXTS, resetFrozenRequestContextsForTests, setFrozenRequestContext, transferFrozenRequestContext, } from "./frozen.js";
4
4
  export { MAX_OVERLAY_HOLDS, holdCapabilityOverlay, resetOverlayHoldsForTests, } from "./overlay.js";
5
5
  export { admitContextEpoch, appendMidConversationMessage, clearContextEpoch, endContextEpoch, getContextEpoch, MAX_CONTEXT_EPOCHS, resetContextEpochsForTests, type AdmitContextEpochInput, type AdmitContextEpochResult, type ContextEpoch, type ContextSourceSnapshot, } from "./epoch.js";
@@ -1,5 +1,5 @@
1
1
  export { buildDynamicRequestContext, buildRequestContext, DYNAMIC_REQUEST_CONTEXT_KEYS, materializeRequestContext, requestContextBase, } from "./build.js";
2
- export { buildDynamicCatalogRoutingInstruction, buildSkillCatalogNudge, listAdvertisedMcpServers, skillNameFromAgentSkill, } from "./dynamic-catalog.js";
2
+ export { buildDynamicCatalogRoutingInstruction, listAdvertisedMcpServers, } from "./dynamic-catalog.js";
3
3
  export { clearFrozenRequestContext, getFrozenRequestContext, getOrBuildRequestContext, MAX_FROZEN_REQUEST_CONTEXTS, resetFrozenRequestContextsForTests, setFrozenRequestContext, transferFrozenRequestContext, } from "./frozen.js";
4
4
  export { MAX_OVERLAY_HOLDS, holdCapabilityOverlay, resetOverlayHoldsForTests, } from "./overlay.js";
5
5
  export { admitContextEpoch, appendMidConversationMessage, clearContextEpoch, endContextEpoch, getContextEpoch, MAX_CONTEXT_EPOCHS, resetContextEpochsForTests, } from "./epoch.js";
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Epoch-hold live RequestContext overlay lists (skills, subagents, plugins).
2
+ * Epoch-hold live RequestContext overlay lists (subagents, plugins).
3
3
  *
4
4
  * Same policy as the tool catalog: first nonempty freeze is UTF-16 by id,
5
5
  * equal ids keep frozen bytes (including content), new ids append at the
@@ -7,12 +7,6 @@
7
7
  * Run; this only canonicalizes what is advertised.
8
8
  */
9
9
  export declare const MAX_OVERLAY_HOLDS = 256;
10
- export type OverlaySkill = {
11
- id: string;
12
- full_path: string;
13
- content: string;
14
- description: string;
15
- };
16
10
  export type OverlaySubagent = {
17
11
  full_path: string;
18
12
  name: string;
@@ -24,26 +18,11 @@ export type OverlayPlugin = {
24
18
  line: string;
25
19
  };
26
20
  export type OverlayHold = {
27
- skills: OverlaySkill[];
28
- subagents: OverlaySubagent[];
29
- plugins: OverlayPlugin[];
30
- };
31
- export type OverlayWire = {
32
- skills: Array<{
33
- full_path: string;
34
- content: string;
35
- description: string;
36
- }>;
37
21
  subagents: OverlaySubagent[];
38
22
  plugins: OverlayPlugin[];
39
23
  };
40
24
  /** Merge live discovery into the conversation overlay epoch. */
41
- export declare function holdCapabilityOverlay(conversationId: string, live: OverlayHold): OverlayWire;
42
- /**
43
- * Epoch-held skills with their OpenCode ids. The wire shape drops `id`
44
- * (`AgentSkill` has no name field), but the host `skill` tool needs it.
45
- */
46
- export declare function getHeldOverlaySkills(conversationId: string): OverlaySkill[];
25
+ export declare function holdCapabilityOverlay(conversationId: string, live: OverlayHold): OverlayHold;
47
26
  export declare function clearOverlayHold(conversationId: string): void;
48
27
  export declare function transferOverlayHold(previousConversationId: string, nextConversationId: string): void;
49
28
  export declare function resetOverlayHoldsForTests(): void;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Epoch-hold live RequestContext overlay lists (skills, subagents, plugins).
2
+ * Epoch-hold live RequestContext overlay lists (subagents, plugins).
3
3
  *
4
4
  * Same policy as the tool catalog: first nonempty freeze is UTF-16 by id,
5
5
  * equal ids keep frozen bytes (including content), new ids append at the
@@ -29,7 +29,6 @@ function holdList(cached, incoming, idOf) {
29
29
  }
30
30
  function remember(conversationId, hold) {
31
31
  const stored = {
32
- skills: structuredClone(hold.skills),
33
32
  subagents: structuredClone(hold.subagents),
34
33
  plugins: structuredClone(hold.plugins),
35
34
  };
@@ -43,47 +42,24 @@ function remember(conversationId, hold) {
43
42
  }
44
43
  return stored;
45
44
  }
46
- function skillId(skill) {
47
- return skill.id || skill.full_path;
48
- }
49
- function toWire(hold) {
50
- return {
51
- skills: hold.skills.map(({ full_path, content, description }) => ({
52
- full_path,
53
- content,
54
- description,
55
- })),
56
- subagents: hold.subagents,
57
- plugins: hold.plugins,
58
- };
59
- }
60
45
  /** Merge live discovery into the conversation overlay epoch. */
61
46
  export function holdCapabilityOverlay(conversationId, live) {
62
47
  if (!conversationId) {
63
- return toWire({
64
- skills: inFixedOrder(live.skills, skillId),
48
+ return {
65
49
  subagents: inFixedOrder(live.subagents, (agent) => agent.name),
66
50
  plugins: inFixedOrder(live.plugins, (plugin) => plugin.id),
67
- });
51
+ };
68
52
  }
69
53
  const cached = byConversationId.get(conversationId);
70
54
  const next = {
71
- skills: holdList(cached?.skills, live.skills, skillId),
72
55
  subagents: holdList(cached?.subagents, live.subagents, (agent) => agent.name),
73
56
  plugins: holdList(cached?.plugins, live.plugins, (plugin) => plugin.id),
74
57
  };
75
- if (next.skills.length === 0 && next.subagents.length === 0 && next.plugins.length === 0) {
58
+ if (next.subagents.length === 0 && next.plugins.length === 0) {
76
59
  byConversationId.delete(conversationId);
77
- return toWire(next);
60
+ return next;
78
61
  }
79
- return toWire(remember(conversationId, next));
80
- }
81
- /**
82
- * Epoch-held skills with their OpenCode ids. The wire shape drops `id`
83
- * (`AgentSkill` has no name field), but the host `skill` tool needs it.
84
- */
85
- export function getHeldOverlaySkills(conversationId) {
86
- return structuredClone(byConversationId.get(conversationId)?.skills ?? []);
62
+ return remember(conversationId, next);
87
63
  }
88
64
  export function clearOverlayHold(conversationId) {
89
65
  byConversationId.delete(conversationId);
@@ -21,8 +21,12 @@ export declare function setHostCacheDirOverride(dir: string | undefined): void;
21
21
  export declare function getHostCacheDirOverride(): string | undefined;
22
22
  /** Resolve the native OpenCode cache root when no host bridge is installed. */
23
23
  export declare function resolveHostCacheDir(env?: HostPathEnv): string;
24
- /** Native OpenCode global config dir. */
25
- export declare function opencodeGlobalConfigDir(): string;
24
+ /**
25
+ * Native OpenCode global config dir: `$XDG_CONFIG_HOME/opencode`, otherwise
26
+ * `~/.config/opencode` — OpenCode's `Global.Path.config` (`xdg-basedir`
27
+ * `xdgConfig`, `packages/core/src/global.ts`), the same in 1.x and 2.0.
28
+ */
29
+ export declare function opencodeGlobalConfigDir(env?: HostPathEnv): string;
26
30
  /**
27
31
  * Host global cache dir for Cursor project metadata + model/version caches.
28
32
  *
@@ -57,7 +61,7 @@ export declare function hostGlobalDataDir(env?: HostPathEnv): string;
57
61
  */
58
62
  export declare function hostPlansDir(_workspaceRoot?: string): string;
59
63
  /**
60
- * Cursor-compatible path slug (`/Users/a/b` → `Users-a-b`).
64
+ * Cursor-compatible path slug (`/workspace/a/b` → `workspace-a-b`).
61
65
  * Used for per-workspace metadata under the host cache.
62
66
  */
63
67
  export declare function slugifyWorkspacePath(workspaceRoot: string): string;
@@ -72,4 +76,3 @@ export declare function slugifyWorkspacePath(workspaceRoot: string): string;
72
76
  export declare function opencodeProjectDir(workspaceRoot: string): string;
73
77
  /** Ensure {@link opencodeProjectDir} exists (mode 0o700) and return it. */
74
78
  export declare function ensureOpencodeProjectDir(workspaceRoot: string): string;
75
- export declare function resolveHomeRelative(p: string): string;
@@ -68,9 +68,18 @@ export function getHostCacheDirOverride() {
68
68
  export function resolveHostCacheDir(env = process.env) {
69
69
  return bridgeGlobalCacheDir() ?? openCodeGlobalCacheDir(env);
70
70
  }
71
- /** Native OpenCode global config dir. */
72
- export function opencodeGlobalConfigDir() {
73
- return path.join(resolveHome(), ".config", "opencode");
71
+ function xdgConfigHome(env = process.env) {
72
+ if (env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.length > 0)
73
+ return env.XDG_CONFIG_HOME;
74
+ return path.join(resolveHome(env), ".config");
75
+ }
76
+ /**
77
+ * Native OpenCode global config dir: `$XDG_CONFIG_HOME/opencode`, otherwise
78
+ * `~/.config/opencode` — OpenCode's `Global.Path.config` (`xdg-basedir`
79
+ * `xdgConfig`, `packages/core/src/global.ts`), the same in 1.x and 2.0.
80
+ */
81
+ export function opencodeGlobalConfigDir(env = process.env) {
82
+ return path.join(xdgConfigHome(env), "opencode");
74
83
  }
75
84
  /**
76
85
  * Host global cache dir for Cursor project metadata + model/version caches.
@@ -116,7 +125,7 @@ export function hostPlansDir(_workspaceRoot) {
116
125
  return path.join(hostGlobalDataDir(), "plans");
117
126
  }
118
127
  /**
119
- * Cursor-compatible path slug (`/Users/a/b` → `Users-a-b`).
128
+ * Cursor-compatible path slug (`/workspace/a/b` → `workspace-a-b`).
120
129
  * Used for per-workspace metadata under the host cache.
121
130
  */
122
131
  export function slugifyWorkspacePath(workspaceRoot) {
@@ -157,8 +166,3 @@ export function ensureOpencodeProjectDir(workspaceRoot) {
157
166
  `xdg_cache_home=${process.env.XDG_CACHE_HOME ?? "(unset)"}`);
158
167
  return dir;
159
168
  }
160
- export function resolveHomeRelative(p) {
161
- if (p.startsWith("~/"))
162
- return path.join(homedir(), p.slice(2));
163
- return p;
164
- }