@cortexkit/opencode-magic-context 0.24.1 → 0.26.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 (142) hide show
  1. package/README.md +4 -2
  2. package/dist/agents/magic-context-prompt.d.ts.map +1 -1
  3. package/dist/agents/permissions.d.ts +6 -0
  4. package/dist/agents/permissions.d.ts.map +1 -1
  5. package/dist/config/schema/magic-context.d.ts +22 -0
  6. package/dist/config/schema/magic-context.d.ts.map +1 -1
  7. package/dist/features/magic-context/compartment-chunk-embedding.d.ts +25 -0
  8. package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
  9. package/dist/features/magic-context/compartment-embedding.d.ts.map +1 -1
  10. package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
  11. package/dist/features/magic-context/memory/embedding-local.d.ts +7 -3
  12. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  13. package/dist/features/magic-context/memory/embedding-openai.d.ts +8 -4
  14. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  15. package/dist/features/magic-context/memory/embedding-provider.d.ts +8 -4
  16. package/dist/features/magic-context/memory/embedding-provider.d.ts.map +1 -1
  17. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  18. package/dist/features/magic-context/memory/relocate-memory.d.ts +58 -0
  19. package/dist/features/magic-context/memory/relocate-memory.d.ts.map +1 -0
  20. package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts +6 -0
  21. package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts.map +1 -1
  22. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  23. package/dist/features/magic-context/project-embedding-registry.d.ts +41 -3
  24. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  25. package/dist/features/magic-context/storage-db.d.ts +2 -1
  26. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  27. package/dist/features/magic-context/storage-meta-persisted.d.ts +37 -0
  28. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  29. package/dist/features/magic-context/storage-meta-session.d.ts +1 -0
  30. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  31. package/dist/features/magic-context/storage-meta-shared.d.ts +4 -1
  32. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  33. package/dist/features/magic-context/storage-meta.d.ts +2 -2
  34. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  35. package/dist/features/magic-context/storage-tags.d.ts +58 -2
  36. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  37. package/dist/features/magic-context/storage.d.ts +3 -3
  38. package/dist/features/magic-context/storage.d.ts.map +1 -1
  39. package/dist/features/magic-context/tagger.d.ts +1 -1
  40. package/dist/features/magic-context/tagger.d.ts.map +1 -1
  41. package/dist/features/magic-context/transform-decision-log.d.ts +49 -0
  42. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -0
  43. package/dist/features/magic-context/types.d.ts +1 -0
  44. package/dist/features/magic-context/types.d.ts.map +1 -1
  45. package/dist/features/magic-context/v22-deferred-backfill.d.ts.map +1 -1
  46. package/dist/hooks/magic-context/apply-operations.d.ts +3 -25
  47. package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
  48. package/dist/hooks/magic-context/auto-search-runner.d.ts.map +1 -1
  49. package/dist/hooks/magic-context/caveman-cleanup.d.ts +1 -0
  50. package/dist/hooks/magic-context/caveman-cleanup.d.ts.map +1 -1
  51. package/dist/hooks/magic-context/channel2-delivery.d.ts +2 -0
  52. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  53. package/dist/hooks/magic-context/command-handler.d.ts +7 -5
  54. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  55. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  56. package/dist/hooks/magic-context/compartment-trigger.d.ts +1 -1
  57. package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
  58. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +7 -2
  59. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
  60. package/dist/hooks/magic-context/derive-budgets.d.ts +5 -9
  61. package/dist/hooks/magic-context/derive-budgets.d.ts.map +1 -1
  62. package/dist/hooks/magic-context/embed-session-state.d.ts +14 -0
  63. package/dist/hooks/magic-context/embed-session-state.d.ts.map +1 -0
  64. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  65. package/dist/hooks/magic-context/event-payloads.d.ts +1 -0
  66. package/dist/hooks/magic-context/event-payloads.d.ts.map +1 -1
  67. package/dist/hooks/magic-context/format-embed-status.d.ts +9 -0
  68. package/dist/hooks/magic-context/format-embed-status.d.ts.map +1 -0
  69. package/dist/hooks/magic-context/heuristic-cleanup.d.ts +2 -0
  70. package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  72. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  73. package/dist/hooks/magic-context/protected-tail-boundary.d.ts +10 -0
  74. package/dist/hooks/magic-context/protected-tail-boundary.d.ts.map +1 -1
  75. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  76. package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts +1 -1
  77. package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts.map +1 -1
  78. package/dist/hooks/magic-context/strip-content.d.ts +0 -1
  79. package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
  80. package/dist/hooks/magic-context/tag-content-primitives.d.ts +2 -0
  81. package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
  82. package/dist/hooks/magic-context/tag-id-fallback.d.ts.map +1 -1
  83. package/dist/hooks/magic-context/tag-messages.d.ts +10 -0
  84. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  85. package/dist/hooks/magic-context/tool-drop-target.d.ts +1 -1
  86. package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
  87. package/dist/hooks/magic-context/tool-reclaim.d.ts +12 -0
  88. package/dist/hooks/magic-context/tool-reclaim.d.ts.map +1 -0
  89. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +32 -1
  90. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  91. package/dist/hooks/magic-context/transform-operations.d.ts +1 -1
  92. package/dist/hooks/magic-context/transform-operations.d.ts.map +1 -1
  93. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +6 -0
  94. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  95. package/dist/hooks/magic-context/transform.d.ts +2 -0
  96. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +1767 -613
  99. package/dist/plugin/conflict-warning-hook.d.ts.map +1 -1
  100. package/dist/plugin/dream-timer.d.ts.map +1 -1
  101. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  102. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  103. package/dist/shared/announcement.d.ts +1 -1
  104. package/dist/shared/index.d.ts +0 -1
  105. package/dist/shared/index.d.ts.map +1 -1
  106. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  107. package/dist/shared/resolve-fallbacks.d.ts +16 -16
  108. package/dist/shared/resolve-fallbacks.d.ts.map +1 -1
  109. package/dist/shared/rpc-types.d.ts +20 -0
  110. package/dist/shared/rpc-types.d.ts.map +1 -1
  111. package/dist/shared/sqlite.d.ts +5 -1
  112. package/dist/shared/sqlite.d.ts.map +1 -1
  113. package/dist/tools/ctx-expand/constants.d.ts +1 -1
  114. package/dist/tools/ctx-expand/constants.d.ts.map +1 -1
  115. package/dist/tools/ctx-expand/render.d.ts +43 -0
  116. package/dist/tools/ctx-expand/render.d.ts.map +1 -0
  117. package/dist/tools/ctx-expand/tools.d.ts.map +1 -1
  118. package/dist/tools/ctx-expand/types.d.ts +6 -2
  119. package/dist/tools/ctx-expand/types.d.ts.map +1 -1
  120. package/dist/tools/ctx-reduce/constants.d.ts +1 -1
  121. package/dist/tools/ctx-reduce/constants.d.ts.map +1 -1
  122. package/dist/tools/ctx-search/tools.d.ts.map +1 -1
  123. package/dist/tui/data/context-db.d.ts +4 -2
  124. package/dist/tui/data/context-db.d.ts.map +1 -1
  125. package/package.json +1 -1
  126. package/src/shared/announcement.ts +6 -6
  127. package/src/shared/index.ts +0 -1
  128. package/src/shared/model-suggestion-retry.test.ts +61 -1
  129. package/src/shared/model-suggestion-retry.ts +22 -0
  130. package/src/shared/resolve-fallbacks.test.ts +37 -71
  131. package/src/shared/resolve-fallbacks.ts +16 -26
  132. package/src/shared/rpc-types.ts +11 -0
  133. package/src/shared/sqlite-bind-style.test.ts +82 -0
  134. package/src/shared/sqlite.ts +30 -1
  135. package/src/shared/tag-transcript.test.ts +3 -1
  136. package/src/shared/tag-transcript.ts +19 -17
  137. package/src/tui/data/context-db.ts +34 -2
  138. package/src/tui/index.tsx +58 -4
  139. package/src/tui/slots/sidebar-content.tsx +18 -9
  140. package/dist/shared/model-requirements.d.ts +0 -26
  141. package/dist/shared/model-requirements.d.ts.map +0 -1
  142. package/src/shared/model-requirements.ts +0 -86
@@ -1,98 +1,64 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { DREAMER_AGENT } from "../agents/dreamer";
4
- import { HISTORIAN_AGENT } from "../agents/historian";
5
- import { SIDEKICK_AGENT } from "../agents/sidekick";
6
3
  import { parseProviderModel, resolveFallbackChain } from "./resolve-fallbacks";
7
4
 
8
5
  describe("resolveFallbackChain", () => {
9
- test("returns builtin chain when user provides nothing", () => {
10
- const chain = resolveFallbackChain(DREAMER_AGENT, undefined);
11
- // Builtin DREAMER_FALLBACK_CHAIN expands to multiple provider/model entries.
12
- expect(chain.length).toBeGreaterThan(2);
13
- // Every entry must be in "provider/model" form.
14
- for (const entry of chain) {
15
- expect(entry.indexOf("/")).toBeGreaterThan(0);
16
- }
6
+ // Policy: user-config-only. There is NO builtin provider-agnostic chain
7
+ // when the user configures no fallback_models the result is EMPTY, and the
8
+ // runner's session-model last resort (a model the user actually has) is the
9
+ // only fallback. A hardcoded chain named providers the user may not have and
10
+ // produced `Model not found` retry storms.
11
+ test("returns empty when user provides nothing (no builtin chain)", () => {
12
+ expect(resolveFallbackChain(undefined)).toEqual([]);
17
13
  });
18
14
 
19
- test("returns builtin chain for empty string", () => {
20
- const chain = resolveFallbackChain(DREAMER_AGENT, "");
21
- expect(chain.length).toBeGreaterThan(0);
15
+ test("returns empty for empty string", () => {
16
+ expect(resolveFallbackChain("")).toEqual([]);
22
17
  });
23
18
 
24
- test("returns builtin chain for empty array", () => {
25
- const chain = resolveFallbackChain(DREAMER_AGENT, []);
26
- expect(chain.length).toBeGreaterThan(0);
19
+ test("returns empty for empty array", () => {
20
+ expect(resolveFallbackChain([])).toEqual([]);
27
21
  });
28
22
 
29
23
  test("user-only when user provides valid fallback_models string", () => {
30
- const chain = resolveFallbackChain(DREAMER_AGENT, "anthropic/claude-sonnet-4-6");
31
- expect(chain).toEqual(["anthropic/claude-sonnet-4-6"]);
24
+ expect(resolveFallbackChain("anthropic/claude-sonnet-4-6")).toEqual([
25
+ "anthropic/claude-sonnet-4-6",
26
+ ]);
32
27
  });
33
28
 
34
29
  test("user-only when user provides valid fallback_models array", () => {
35
- const chain = resolveFallbackChain(DREAMER_AGENT, [
36
- "anthropic/claude-sonnet-4-6",
37
- "google/gemini-3-flash",
38
- ]);
39
- expect(chain).toEqual(["anthropic/claude-sonnet-4-6", "google/gemini-3-flash"]);
30
+ expect(
31
+ resolveFallbackChain(["anthropic/claude-sonnet-4-6", "google/gemini-3-flash"]),
32
+ ).toEqual(["anthropic/claude-sonnet-4-6", "google/gemini-3-flash"]);
40
33
  });
41
34
 
42
35
  test("dedupes user-provided list", () => {
43
- const chain = resolveFallbackChain(DREAMER_AGENT, [
44
- "anthropic/claude-sonnet-4-6",
45
- "anthropic/claude-sonnet-4-6",
46
- "google/gemini-3-flash",
47
- ]);
48
- expect(chain).toEqual(["anthropic/claude-sonnet-4-6", "google/gemini-3-flash"]);
36
+ expect(
37
+ resolveFallbackChain([
38
+ "anthropic/claude-sonnet-4-6",
39
+ "anthropic/claude-sonnet-4-6",
40
+ "google/gemini-3-flash",
41
+ ]),
42
+ ).toEqual(["anthropic/claude-sonnet-4-6", "google/gemini-3-flash"]);
49
43
  });
50
44
 
51
45
  test("strips invalid 'provider/model' entries", () => {
52
- const chain = resolveFallbackChain(DREAMER_AGENT, [
53
- "anthropic/claude-sonnet-4-6",
54
- "no-slash-here",
55
- "/leading-slash",
56
- "trailing-slash/",
57
- "",
58
- " ",
59
- ]);
60
- expect(chain).toEqual(["anthropic/claude-sonnet-4-6"]);
46
+ expect(
47
+ resolveFallbackChain([
48
+ "anthropic/claude-sonnet-4-6",
49
+ "no-slash-here",
50
+ "/leading-slash",
51
+ "trailing-slash/",
52
+ "",
53
+ " ",
54
+ ]),
55
+ ).toEqual(["anthropic/claude-sonnet-4-6"]);
61
56
  });
62
57
 
63
58
  test("trims whitespace in user entries", () => {
64
- const chain = resolveFallbackChain(DREAMER_AGENT, [
65
- " anthropic/claude-sonnet-4-6 ",
66
- "\tgoogle/gemini-3-flash\n",
67
- ]);
68
- expect(chain).toEqual(["anthropic/claude-sonnet-4-6", "google/gemini-3-flash"]);
69
- });
70
-
71
- test("returns empty array for unknown agent with no user fallbacks", () => {
72
- const chain = resolveFallbackChain("unknown-agent", undefined);
73
- expect(chain).toEqual([]);
74
- });
75
-
76
- test("returns user fallbacks for unknown agent when provided", () => {
77
- const chain = resolveFallbackChain("unknown-agent", ["foo/bar"]);
78
- expect(chain).toEqual(["foo/bar"]);
79
- });
80
-
81
- test("HISTORIAN_AGENT has builtin chain", () => {
82
- const chain = resolveFallbackChain(HISTORIAN_AGENT, undefined);
83
- expect(chain.length).toBeGreaterThan(0);
84
- });
85
-
86
- test("SIDEKICK_AGENT has builtin chain", () => {
87
- const chain = resolveFallbackChain(SIDEKICK_AGENT, undefined);
88
- expect(chain.length).toBeGreaterThan(0);
89
- });
90
-
91
- test("user-only policy: builtin not appended even if user set short list", () => {
92
- const chain = resolveFallbackChain(DREAMER_AGENT, ["anthropic/claude-sonnet-4-6"]);
93
- expect(chain).toEqual(["anthropic/claude-sonnet-4-6"]);
94
- // Confirm length is exactly 1, not user+builtin
95
- expect(chain.length).toBe(1);
59
+ expect(
60
+ resolveFallbackChain([" anthropic/claude-sonnet-4-6 ", "\tgoogle/gemini-3-flash\n"]),
61
+ ).toEqual(["anthropic/claude-sonnet-4-6", "google/gemini-3-flash"]);
96
62
  });
97
63
  });
98
64
 
@@ -1,37 +1,27 @@
1
- import { getAgentFallbackModels } from "./model-requirements";
2
-
3
1
  /**
4
- * Resolve the final fallback model list to attempt for an OpenCode subagent
5
- * call.
6
- *
7
- * Policy (decided 2026-05-10):
8
- * - If user configured explicit `fallback_models` in their magic-context.jsonc
9
- * for this agent: use ONLY those. Respects user intent, no surprise
10
- * providers.
11
- * - If user did NOT configure any: fall back to the plugin's builtin
12
- * provider-agnostic chain (`AGENT_MODEL_REQUIREMENTS`).
2
+ * Resolve the fallback model list to attempt for a hidden-agent (historian /
3
+ * dreamer / sidekick) call when its configured primary fails.
13
4
  *
14
- * The returned list does NOT include the primary model it's the ordered
15
- * list of *alternates* to try after the primary fails. Each entry is
16
- * "provider/modelID" form.
5
+ * Policy: ONLY the user's explicitly-configured `fallback_models` for this
6
+ * agent. There is NO builtin provider-agnostic chain a hardcoded chain
7
+ * inevitably names providers the user doesn't have (e.g. a metapi-only user got
8
+ * a chain of google/github-copilot/opencode entries, every one a
9
+ * `Model not found` retry), which produced confusing errors and wasted
10
+ * attempts. If the user configured nothing, this returns an empty list and the
11
+ * runner's session-model last resort (the model the user is actually using) is
12
+ * the only fallback.
17
13
  *
18
- * Duplicates and empty strings are filtered. Entries that don't match the
19
- * "provider/modelID" shape (must contain a "/" with non-empty parts) are
20
- * also dropped defensive guard against malformed user config.
14
+ * The returned list does NOT include the primary model — it's the ordered list
15
+ * of *alternates* to try after the primary fails. Each entry is
16
+ * "provider/modelID" form. Duplicates and empty strings are filtered; entries
17
+ * that don't match the "provider/modelID" shape (a "/" with non-empty parts) are
18
+ * dropped as a defensive guard against malformed user config.
21
19
  */
22
20
  export function resolveFallbackChain(
23
- agentName: string,
24
21
  userFallbacks: readonly string[] | string | undefined,
25
22
  ): string[] {
26
23
  const userList = normalizeUserFallbacks(userFallbacks);
27
-
28
- if (userList.length > 0) {
29
- return dedupe(userList.filter(isValidModelSpec));
30
- }
31
-
32
- const builtin = getAgentFallbackModels(agentName);
33
- if (!builtin || builtin.length === 0) return [];
34
- return dedupe(builtin.filter(isValidModelSpec));
24
+ return dedupe(userList.filter(isValidModelSpec));
35
25
  }
36
26
 
37
27
  function normalizeUserFallbacks(userFallbacks: readonly string[] | string | undefined): string[] {
@@ -123,6 +123,17 @@ export interface StatusDetail extends SidebarSnapshot {
123
123
  compressionUsage: string | null;
124
124
  }
125
125
 
126
+ /** Embedding coverage for `/ctx-embed` status (mirrors getEmbeddingCoverageStatus). */
127
+ export interface EmbedDetail {
128
+ enabled: boolean;
129
+ model: string;
130
+ provider: string;
131
+ session: { embedded: number; total: number };
132
+ memories: { embedded: number; total: number };
133
+ commits: { embedded: number; total: number; gitEnabled: boolean };
134
+ statusText: string;
135
+ }
136
+
126
137
  export interface RpcNotificationMessage {
127
138
  id: number;
128
139
  type: string;
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Guard: SQLite statement binds must use SPREAD positional args
7
+ * (`stmt.run(a, b)`), never the ARRAY form (`stmt.run([a, b])`).
8
+ *
9
+ * Why this is a CI guard, not just a style preference: bun:sqlite (OpenCode)
10
+ * binds a lone array positionally, but node:sqlite (Pi, OpenCode Desktop) reads
11
+ * it as NAMED params ("0","1") and throws `Unknown named parameter '0'`. An
12
+ * array-form bind therefore passes every OpenCode/Bun test yet breaks Pi/Desktop
13
+ * silently — exactly how issue #151 (/ctx-dream) shipped. The sqlite.ts
14
+ * chokepoint now normalizes array binds for node:sqlite so it can no longer
15
+ * CRASH, but the array form is still discouraged: it relies on the shim, reads
16
+ * inconsistently with the rest of the codebase (all spread), and the guard
17
+ * catches it at PR time so the shim stays a safety net, not a crutch.
18
+ */
19
+
20
+ // Scan EVERY package's src, not just plugin's. pi-plugin and cli have no
21
+ // sqlite.ts of their own — they run on the shared chokepoint — so an array-form
22
+ // bind written in their code would break under node:sqlite (Pi/Desktop) exactly
23
+ // like #151, and the plugin-only scan would never see it. Roots are resolved
24
+ // relative to plugin/src so the guard works from the plugin package.
25
+ const PLUGIN_SRC = join(import.meta.dir, "..");
26
+ const SCAN_ROOTS = [
27
+ PLUGIN_SRC,
28
+ join(PLUGIN_SRC, "../../pi-plugin/src"),
29
+ join(PLUGIN_SRC, "../../cli/src"),
30
+ ].filter((dir) => existsSync(dir));
31
+ // The chokepoint itself documents the pattern in prose/comments; the guard's own
32
+ // source mentions it; allow those two (relative to whichever root contains them).
33
+ const ALLOWED = new Set(["shared/sqlite.ts", "shared/sqlite-bind-style.test.ts"]);
34
+ // `stmt.run([` / `.get([` / `.all([` — but `.all([` also matches Promise.all([.
35
+ const BIND_PATTERN = /\.(run|get|all)\(\[/;
36
+
37
+ function collectTsFiles(dir: string, acc: string[] = []): string[] {
38
+ for (const entry of readdirSync(dir)) {
39
+ const full = join(dir, entry);
40
+ const st = statSync(full);
41
+ if (st.isDirectory()) {
42
+ collectTsFiles(full, acc);
43
+ } else if (entry.endsWith(".ts") && !entry.endsWith(".test.ts")) {
44
+ acc.push(full);
45
+ }
46
+ }
47
+ return acc;
48
+ }
49
+
50
+ describe("sqlite bind style", () => {
51
+ it("uses spread positional binds, never the array form", () => {
52
+ const violations: string[] = [];
53
+ for (const root of SCAN_ROOTS) {
54
+ for (const file of collectTsFiles(root)) {
55
+ const rel = file.slice(root.length + 1);
56
+ if (ALLOWED.has(rel)) continue;
57
+ const lines = readFileSync(file, "utf8").split("\n");
58
+ lines.forEach((line, i) => {
59
+ if (!BIND_PATTERN.test(line)) return;
60
+ // Promise.all([...]) is not a SQLite statement bind.
61
+ if (line.includes("Promise.all(")) return;
62
+ // Skip comment lines.
63
+ const trimmed = line.trim();
64
+ if (trimmed.startsWith("//") || trimmed.startsWith("*")) return;
65
+ // Disambiguate which package the hit is in.
66
+ const pkg = root.includes("pi-plugin")
67
+ ? "pi-plugin"
68
+ : root.includes("cli")
69
+ ? "cli"
70
+ : "plugin";
71
+ violations.push(`${pkg}/${rel}:${i + 1} ${trimmed}`);
72
+ });
73
+ }
74
+ }
75
+ expect(
76
+ violations,
77
+ `Array-form SQLite binds found — use spread positional .run(a, b) ` +
78
+ `instead of .run([a, b]) (breaks under node:sqlite on Pi/Desktop):\n` +
79
+ violations.join("\n"),
80
+ ).toEqual([]);
81
+ });
82
+ });
@@ -25,12 +25,16 @@
25
25
  * - db.transaction(fn) → wrapped function ← shimmed for node:sqlite
26
26
  * - db.close()
27
27
  *
28
- * The two backend differences we bridge for node:sqlite:
28
+ * The three backend differences we bridge for node:sqlite:
29
29
  * 1. node:sqlite has no `db.transaction(fn)` helper — we add a savepoint-aware
30
30
  * shim (below) that matches better-sqlite3/bun semantics.
31
31
  * 2. node:sqlite's constructor option is `readOnly` (camel-case), not
32
32
  * better-sqlite3/bun's `readonly` — we translate it so call sites are
33
33
  * unchanged.
34
+ * 3. node:sqlite reads a lone array bind arg (`.run([a,b])`) as NAMED params
35
+ * and throws `Unknown named parameter '0'`; bun binds it positionally. We
36
+ * normalize it in the `prepare()` override (below) so the bind surface is
37
+ * identical (issue #151 / Pi /ctx-dream).
34
38
  * Everything else (named params with bare keys, ATTACH under defensive mode,
35
39
  * `run()` → {changes,lastInsertRowid}) is identical and was verified directly.
36
40
  */
@@ -104,6 +108,31 @@ function buildNodeSqliteDatabaseClass(DatabaseSync: any): typeof BetterSqlite3 {
104
108
  super(typeof filename === "string" ? filename : ":memory:", translated);
105
109
  }
106
110
 
111
+ // Normalize a single ARRAY bind arg to spread positional, matching
112
+ // bun:sqlite. bun's `.run([a,b])` binds positionally; node:sqlite instead
113
+ // reads a lone array as NAMED params with keys "0","1" and throws
114
+ // `Unknown named parameter '0'`. That divergence let an array-form bind
115
+ // (e.g. `.run([x, y])`) silently work on OpenCode/Bun yet break Pi and
116
+ // OpenCode Desktop (both node:sqlite) — issue #151 (/ctx-dream). Wrapping
117
+ // every prepared statement here keeps the two backends' bind surface
118
+ // truly identical so this whole class is impossible regardless of how a
119
+ // call site writes its bind. Named-object binds (`.run({k:v})`), no-arg
120
+ // calls, and already-spread positional args are passed through unchanged;
121
+ // the normalization only triggers on the exact 1-array shape. Overhead
122
+ // measured at ~12ns/call against real node:sqlite (negligible).
123
+ // biome-ignore lint/suspicious/noExplicitAny: node:sqlite StatementSync has no shipped types here.
124
+ prepare(sql: string): any {
125
+ const stmt = super.prepare(sql);
126
+ for (const method of ["run", "get", "all"] as const) {
127
+ const original = stmt[method].bind(stmt);
128
+ stmt[method] = (...args: unknown[]): unknown =>
129
+ args.length === 1 && Array.isArray(args[0])
130
+ ? original(...args[0])
131
+ : original(...args);
132
+ }
133
+ return stmt;
134
+ }
135
+
107
136
  // biome-ignore lint/suspicious/noExplicitAny: mirrors better-sqlite3's generic transaction(fn) signature.
108
137
  transaction<F extends (...args: any[]) => any>(fn: F): F {
109
138
  // biome-ignore lint/suspicious/noExplicitAny: faithful pass-through of this/args to fn.
@@ -203,7 +203,9 @@ describe("tagTranscript tool aggregation", () => {
203
203
  result = targets.get(tag ?? -1)?.truncate?.();
204
204
  }).not.toThrow();
205
205
  expect(result).toBe("truncated");
206
- expect(toolUse.getText()).toBe("[truncated]");
206
+ // Skeleton-drop now renders the one canonical placeholder, not a
207
+ // separate "[truncated]" vocabulary.
208
+ expect(toolUse.getText()).toBe(`[dropped \u00a7${tag}\u00a7]`);
207
209
  });
208
210
 
209
211
  it("drops every contiguous folded tool_result block for the paired callId", () => {
@@ -611,7 +611,7 @@ function tagToolPart(args: TagToolPartArgs): void {
611
611
  args.part.setText(tagged);
612
612
  }
613
613
 
614
- args.targets.set(tagId, buildToolTarget(args.part, args.message));
614
+ args.targets.set(tagId, buildToolTarget(args.part, args.message, tagId));
615
615
  }
616
616
 
617
617
  function setToolContentOrText(part: TranscriptPart, content: string): boolean {
@@ -679,10 +679,11 @@ function buildAggregateTarget(tagId: number, occurrences: ToolOccurrence[]): Tag
679
679
  return any ? "removed" : "absent";
680
680
  },
681
681
  truncate(): "truncated" | "absent" {
682
- // Truncate BOTH halves. For tool_use, this typically truncates
683
- // the args; for tool_result, the output. The sentinel string
684
- // matches OpenCode's truncate sentinel exactly.
685
- const sentinel = "[truncated]";
682
+ // Skeleton-drop: replace BOTH halves' content with the one
683
+ // canonical `[dropped §N§]` placeholder (byte-identical to a full
684
+ // drop and to OpenCode). Frozen by the dropMode column → replays
685
+ // the same string every pass. The tool_use call survives intact.
686
+ const sentinel = `[dropped \u00a7${tagId}\u00a7]`;
686
687
  let any = false;
687
688
  for (const occ of occurrences) {
688
689
  if (setToolContentOrText(occ.part, sentinel)) {
@@ -739,16 +740,17 @@ function buildTextTarget(
739
740
  }
740
741
 
741
742
  /**
742
- * TagTarget for a tag-eligible tool part. Tool parts get full-drop
743
- * (replace with `[dropped §N§]`) or truncated-drop (replace with
744
- * `[truncated]`) treatment from `applyFlushedStatuses` based on the
745
- * stored `drop_mode` column. We expose both via the standard target
746
- * surface; replaceWithSentinel is the canonical mutation, with
747
- * truncated-drop using the "[truncated]" string.
743
+ * TagTarget for a tag-eligible tool part. Tool parts get full-drop or
744
+ * skeleton-drop treatment from `applyFlushedStatuses` based on the stored
745
+ * `drop_mode` column. Both render the SAME canonical `[dropped §N§]`
746
+ * placeholder full-drop replaces the whole pair, skeleton-drop keeps the
747
+ * tool_use call and replaces only its output. One placeholder string,
748
+ * byte-identical across passes and across harnesses.
748
749
  */
749
750
  function buildToolTarget(
750
751
  part: TranscriptPart,
751
752
  message: { info: { id?: string; role: string } },
753
+ tagId: number,
752
754
  ): TagTarget {
753
755
  return {
754
756
  setContent(content: string): boolean {
@@ -765,15 +767,15 @@ function buildToolTarget(
765
767
  // For Pi the current Transcript contract treats both
766
768
  // invocation and result parts symmetrically — both expose
767
769
  // setText / setToolOutput.
768
- const replaced = part.replaceWithSentinel(`[dropped \u00a7${part.id ?? "?"}\u00a7]`);
770
+ const replaced = part.replaceWithSentinel(`[dropped \u00a7${tagId}\u00a7]`);
769
771
  return replaced ? "removed" : "absent";
770
772
  },
771
773
  truncate(): "truncated" | "absent" {
772
- // Truncate the tool output to a fixed sentinel string. Done
773
- // via setToolOutput so the underlying tool_result content
774
- // gets the truncation; falls back to setText for cases
775
- // where the part type doesn't support setToolOutput.
776
- const ok = setToolContentOrText(part, "[truncated]");
774
+ // Skeleton-drop: replace the tool output with the one canonical
775
+ // `[dropped §N§]` placeholder (byte-identical to a full drop and to
776
+ // OpenCode). Frozen by the dropMode column, so it replays the same
777
+ // string every pass. The tool_use call itself survives intact.
778
+ const ok = setToolContentOrText(part, `[dropped \u00a7${tagId}\u00a7]`);
777
779
  return ok ? "truncated" : "absent";
778
780
  },
779
781
  message: {
@@ -5,9 +5,14 @@
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { MagicContextRpcClient } from "../../shared/rpc-client";
8
- import type { RpcNotificationMessage, SidebarSnapshot, StatusDetail } from "../../shared/rpc-types";
8
+ import type {
9
+ EmbedDetail,
10
+ RpcNotificationMessage,
11
+ SidebarSnapshot,
12
+ StatusDetail,
13
+ } from "../../shared/rpc-types";
9
14
 
10
- export type { SidebarSnapshot, StatusDetail };
15
+ export type { EmbedDetail, SidebarSnapshot, StatusDetail };
11
16
 
12
17
  let rpcClient: MagicContextRpcClient | null = null;
13
18
  let rpcGeneration = 0;
@@ -218,6 +223,33 @@ export async function loadStatusDetail(
218
223
  }
219
224
  }
220
225
 
226
+ const EMPTY_EMBED_DETAIL: EmbedDetail = {
227
+ enabled: false,
228
+ model: "off",
229
+ provider: "off",
230
+ session: { embedded: 0, total: 0 },
231
+ memories: { embedded: 0, total: 0 },
232
+ commits: { embedded: 0, total: 0, gitEnabled: false },
233
+ statusText: "Embedding is off (no provider configured).",
234
+ };
235
+
236
+ /** Fetch embedding coverage status for `/ctx-embed` via RPC. */
237
+ export async function loadEmbedDetail(sessionId: string, directory: string): Promise<EmbedDetail> {
238
+ if (!rpcClient) return EMPTY_EMBED_DETAIL;
239
+ try {
240
+ const result = await rpcClient.call<EmbedDetail>("embed-detail", {
241
+ sessionId,
242
+ directory,
243
+ });
244
+ if ((result as unknown as Record<string, unknown>).error) {
245
+ return EMPTY_EMBED_DETAIL;
246
+ }
247
+ return result;
248
+ } catch {
249
+ return EMPTY_EMBED_DETAIL;
250
+ }
251
+ }
252
+
221
253
  /** Get compartment count via RPC. */
222
254
  export async function getCompartmentCount(sessionId: string): Promise<number> {
223
255
  if (!rpcClient) return 0;
package/src/tui/index.tsx CHANGED
@@ -6,7 +6,7 @@ import { createMemo } from "solid-js"
6
6
  import type { TuiPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
7
7
  import { createSidebarContentSlot, kickRecompProgressRefresh } from "./slots/sidebar-content"
8
8
  import packageJson from "../../package.json"
9
- import { closeRpc, consumeTuiMessages, dismissUpgradeReminder, getAnnouncement, getCompartmentCount, getRpcGeneration, initRpcClient, loadStatusDetail, markAnnounced, markTuiMessagesHandled, requestRecomp, requestUpgrade, type TuiMessage, type StatusDetail } from "./data/context-db"
9
+ import { closeRpc, consumeTuiMessages, dismissUpgradeReminder, getAnnouncement, getCompartmentCount, getRpcGeneration, initRpcClient, loadEmbedDetail, loadStatusDetail, markAnnounced, markTuiMessagesHandled, requestRecomp, requestUpgrade, type EmbedDetail, type TuiMessage, type StatusDetail } from "./data/context-db"
10
10
  import { formatThresholdPercent } from "../shared/format-threshold"
11
11
  import { detectConflicts } from "../shared/conflict-detector"
12
12
  import { fixConflicts } from "../shared/conflict-fixer"
@@ -353,7 +353,7 @@ const StatusDialog = (props: { api: TuiPluginApi; s: StatusDetail }) => {
353
353
  }
354
354
  if (p.phase === "migration") return <R t={t()} l="Status" v={p.note ?? "Migrating memories ⟳"} fg={t().warning} />
355
355
  if (p.phase === "done") return <R t={t()} l="Status" v={`✓ ${verb} complete`} fg={t().accent} />
356
- if (p.phase === "skipped") return <R t={t()} l="Status" v={`${verb} skipped — retry shortly${p.message ? `: ${p.message}` : ""}`} fg={t().textMuted} />
356
+ if (p.phase === "skipped") return <R t={t()} l="Status" v={p.message ?? `${verb} stopped early`} fg={t().textMuted} />
357
357
  return <R t={t()} l="Status" v={`✗ ${verb} failed${p.message ? `: ${p.message}` : ""}`} fg={t().error} />
358
358
  })()}
359
359
  </box>
@@ -580,14 +580,53 @@ async function showStatusDialog(api: TuiPluginApi, targetSessionId = getSessionI
580
580
  const directory = api.state.path.directory ?? ""
581
581
  const modelKey = getModelKeyFromMessages(api, sessionId)
582
582
  const detail = await loadStatusDetail(sessionId, directory, modelKey)
583
- // Ack only after the dialog is actually shown for the same active session;
584
- // route switches while the RPC detail load is in flight must leave it pending.
585
583
  if (getSessionId(api) !== sessionId) return false
586
584
 
587
585
  api.ui.dialog.replace(() => <StatusDialog api={api} s={detail} />)
588
586
  return true
589
587
  }
590
588
 
589
+ const EmbedDialog = (props: { api: TuiPluginApi; detail: EmbedDetail }) => {
590
+ const theme = createMemo(() => (props.api as any).theme.current)
591
+ const t = () => theme()
592
+ const lines = () => props.detail.statusText.split("\n")
593
+ return (
594
+ <box flexDirection="column" width="100%" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
595
+ <box justifyContent="center" width="100%" marginBottom={1}>
596
+ <text fg={t().accent}><b>Embedding</b></text>
597
+ </box>
598
+ {lines().map((line) => (
599
+ <text fg={t().text}>{line}</text>
600
+ ))}
601
+ </box>
602
+ )
603
+ }
604
+
605
+ async function showEmbedDialog(api: TuiPluginApi, targetSessionId = getSessionId(api)): Promise<boolean> {
606
+ const sessionId = targetSessionId
607
+ if (!sessionId) {
608
+ api.ui.toast({ message: "No active session", variant: "warning" })
609
+ return false
610
+ }
611
+ const directory = api.state.path.directory ?? ""
612
+ const detail = await loadEmbedDetail(sessionId, directory)
613
+ if (getSessionId(api) !== sessionId) return false
614
+ api.ui.dialog.replace(() => <EmbedDialog api={api} detail={detail} />)
615
+ return true
616
+ }
617
+
618
+ function showResultDialog(api: TuiPluginApi, title: string, message: string): boolean {
619
+ api.ui.dialog.replace(() => (
620
+ <api.ui.DialogAlert
621
+ title={title}
622
+ message={message}
623
+ onConfirm={() => {}}
624
+ />
625
+ ))
626
+ return true
627
+ }
628
+
629
+
591
630
  /**
592
631
  * Register Magic Context command palette entries, preferring the v1.14.42+
593
632
  * `keymap.registerLayer` API and falling back to the legacy
@@ -842,6 +881,21 @@ const tui: TuiPlugin = async (api, _options, meta) => {
842
881
  if (showUpgradeDialog(api, resume, requestedSessionId)) {
843
882
  handledMessageIds.add(msg.id)
844
883
  }
884
+ } else if (action === "show-embed-dialog") {
885
+ if (await showEmbedDialog(api, requestedSessionId)) {
886
+ handledMessageIds.add(msg.id)
887
+ }
888
+ } else if (action === "show-flush-dialog") {
889
+ const flushMsg = String(msg.payload?.message ?? "Flushed.")
890
+ if (showResultDialog(api, "Flush", flushMsg)) {
891
+ handledMessageIds.add(msg.id)
892
+ }
893
+ } else if (action === "show-result-dialog") {
894
+ const title = String(msg.payload?.title ?? "Magic Context")
895
+ const body = String(msg.payload?.message ?? "")
896
+ if (showResultDialog(api, title, body)) {
897
+ handledMessageIds.add(msg.id)
898
+ }
845
899
  }
846
900
  }
847
901
  }
@@ -1,5 +1,5 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
2
+ import { Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
3
3
  import type { TuiSlotPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
4
4
  import packageJson from "../../../package.json"
5
5
  import { loadSidebarSnapshot, type SidebarSnapshot } from "../data/context-db"
@@ -394,8 +394,13 @@ const RecompProgressSection = (props: {
394
394
  case "done":
395
395
  return { text: `✓ ${verb()} complete`, color: props.theme.success ?? props.theme.accent }
396
396
  case "skipped":
397
- // Transient (lease busy) neutral, not an error.
398
- return { text: `${verb()} skipped retry shortly`, color: props.theme.textMuted }
397
+ // Neutral terse status next to the bold verb header; the full,
398
+ // self-contained reason (lease-busy "try again shortly" vs a
399
+ // partial-stall "run /ctx-embed start again") renders on its own
400
+ // line below. Don't re-prepend verb here (it's already the bold
401
+ // header — doing so read as "EmbedEmbed"), and don't hardcode
402
+ // "retry shortly" (wrong for a partial stall).
403
+ return { text: "stopped", color: props.theme.textMuted }
399
404
  case "failed":
400
405
  return { text: `✗ ${verb()} failed`, color: props.theme.error }
401
406
  }
@@ -750,9 +755,11 @@ const SidebarContent = (props: {
750
755
  C:{s()?.compartmentCount ?? 0} Q:{s()?.pendingOpsCount ?? 0} N:{s()?.sessionNoteCount ?? 0}
751
756
  </text>
752
757
  </box>
753
- {s()?.recompProgress && (
754
- <RecompProgressSection theme={props.theme} progress={s()!.recompProgress!} />
755
- )}
758
+ <Show when={s()?.recompProgress}>
759
+ {(progress) => (
760
+ <RecompProgressSection theme={props.theme} progress={progress()} />
761
+ )}
762
+ </Show>
756
763
  </box>
757
764
  )}
758
765
 
@@ -784,9 +791,11 @@ const SidebarContent = (props: {
784
791
  />
785
792
 
786
793
  {/* Recomp / session-upgrade live progress */}
787
- {s()?.recompProgress && (
788
- <RecompProgressSection theme={props.theme} progress={s()!.recompProgress!} />
789
- )}
794
+ <Show when={s()?.recompProgress}>
795
+ {(progress) => (
796
+ <RecompProgressSection theme={props.theme} progress={progress()} />
797
+ )}
798
+ </Show>
790
799
  </>
791
800
  )}
792
801