@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.
- package/README.md +4 -2
- package/dist/agents/magic-context-prompt.d.ts.map +1 -1
- package/dist/agents/permissions.d.ts +6 -0
- package/dist/agents/permissions.d.ts.map +1 -1
- package/dist/config/schema/magic-context.d.ts +22 -0
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/features/magic-context/compartment-chunk-embedding.d.ts +25 -0
- package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
- package/dist/features/magic-context/compartment-embedding.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-local.d.ts +7 -3
- package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-openai.d.ts +8 -4
- package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-provider.d.ts +8 -4
- package/dist/features/magic-context/memory/embedding-provider.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
- package/dist/features/magic-context/memory/relocate-memory.d.ts +58 -0
- package/dist/features/magic-context/memory/relocate-memory.d.ts.map +1 -0
- package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts +6 -0
- package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts +41 -3
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +2 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts +37 -0
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-session.d.ts +1 -0
- package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-shared.d.ts +4 -1
- package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta.d.ts +2 -2
- package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
- package/dist/features/magic-context/storage-tags.d.ts +58 -2
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/storage.d.ts +3 -3
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/tagger.d.ts +1 -1
- package/dist/features/magic-context/tagger.d.ts.map +1 -1
- package/dist/features/magic-context/transform-decision-log.d.ts +49 -0
- package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -0
- package/dist/features/magic-context/types.d.ts +1 -0
- package/dist/features/magic-context/types.d.ts.map +1 -1
- package/dist/features/magic-context/v22-deferred-backfill.d.ts.map +1 -1
- package/dist/hooks/magic-context/apply-operations.d.ts +3 -25
- package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
- package/dist/hooks/magic-context/auto-search-runner.d.ts.map +1 -1
- package/dist/hooks/magic-context/caveman-cleanup.d.ts +1 -0
- package/dist/hooks/magic-context/caveman-cleanup.d.ts.map +1 -1
- package/dist/hooks/magic-context/channel2-delivery.d.ts +2 -0
- package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
- package/dist/hooks/magic-context/command-handler.d.ts +7 -5
- package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-trigger.d.ts +1 -1
- package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
- package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +7 -2
- package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
- package/dist/hooks/magic-context/derive-budgets.d.ts +5 -9
- package/dist/hooks/magic-context/derive-budgets.d.ts.map +1 -1
- package/dist/hooks/magic-context/embed-session-state.d.ts +14 -0
- package/dist/hooks/magic-context/embed-session-state.d.ts.map +1 -0
- package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/event-payloads.d.ts +1 -0
- package/dist/hooks/magic-context/event-payloads.d.ts.map +1 -1
- package/dist/hooks/magic-context/format-embed-status.d.ts +9 -0
- package/dist/hooks/magic-context/format-embed-status.d.ts.map +1 -0
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts +2 -0
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/protected-tail-boundary.d.ts +10 -0
- package/dist/hooks/magic-context/protected-tail-boundary.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts +1 -1
- package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts.map +1 -1
- package/dist/hooks/magic-context/strip-content.d.ts +0 -1
- package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-content-primitives.d.ts +2 -0
- package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-id-fallback.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-messages.d.ts +10 -0
- package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
- package/dist/hooks/magic-context/tool-drop-target.d.ts +1 -1
- package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
- package/dist/hooks/magic-context/tool-reclaim.d.ts +12 -0
- package/dist/hooks/magic-context/tool-reclaim.d.ts.map +1 -0
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts +32 -1
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-operations.d.ts +1 -1
- package/dist/hooks/magic-context/transform-operations.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +6 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +2 -0
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1767 -613
- package/dist/plugin/conflict-warning-hook.d.ts.map +1 -1
- package/dist/plugin/dream-timer.d.ts.map +1 -1
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/shared/announcement.d.ts +1 -1
- package/dist/shared/index.d.ts +0 -1
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
- package/dist/shared/resolve-fallbacks.d.ts +16 -16
- package/dist/shared/resolve-fallbacks.d.ts.map +1 -1
- package/dist/shared/rpc-types.d.ts +20 -0
- package/dist/shared/rpc-types.d.ts.map +1 -1
- package/dist/shared/sqlite.d.ts +5 -1
- package/dist/shared/sqlite.d.ts.map +1 -1
- package/dist/tools/ctx-expand/constants.d.ts +1 -1
- package/dist/tools/ctx-expand/constants.d.ts.map +1 -1
- package/dist/tools/ctx-expand/render.d.ts +43 -0
- package/dist/tools/ctx-expand/render.d.ts.map +1 -0
- package/dist/tools/ctx-expand/tools.d.ts.map +1 -1
- package/dist/tools/ctx-expand/types.d.ts +6 -2
- package/dist/tools/ctx-expand/types.d.ts.map +1 -1
- package/dist/tools/ctx-reduce/constants.d.ts +1 -1
- package/dist/tools/ctx-reduce/constants.d.ts.map +1 -1
- package/dist/tools/ctx-search/tools.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts +4 -2
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/shared/announcement.ts +6 -6
- package/src/shared/index.ts +0 -1
- package/src/shared/model-suggestion-retry.test.ts +61 -1
- package/src/shared/model-suggestion-retry.ts +22 -0
- package/src/shared/resolve-fallbacks.test.ts +37 -71
- package/src/shared/resolve-fallbacks.ts +16 -26
- package/src/shared/rpc-types.ts +11 -0
- package/src/shared/sqlite-bind-style.test.ts +82 -0
- package/src/shared/sqlite.ts +30 -1
- package/src/shared/tag-transcript.test.ts +3 -1
- package/src/shared/tag-transcript.ts +19 -17
- package/src/tui/data/context-db.ts +34 -2
- package/src/tui/index.tsx +58 -4
- package/src/tui/slots/sidebar-content.tsx +18 -9
- package/dist/shared/model-requirements.d.ts +0 -26
- package/dist/shared/model-requirements.d.ts.map +0 -1
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
expect(chain.length).toBeGreaterThan(0);
|
|
15
|
+
test("returns empty for empty string", () => {
|
|
16
|
+
expect(resolveFallbackChain("")).toEqual([]);
|
|
22
17
|
});
|
|
23
18
|
|
|
24
|
-
test("returns
|
|
25
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
"anthropic/claude-sonnet-4-6",
|
|
37
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
" anthropic/claude-sonnet-4-6 ",
|
|
66
|
-
|
|
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
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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[] {
|
package/src/shared/rpc-types.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/shared/sqlite.ts
CHANGED
|
@@ -25,12 +25,16 @@
|
|
|
25
25
|
* - db.transaction(fn) → wrapped function ← shimmed for node:sqlite
|
|
26
26
|
* - db.close()
|
|
27
27
|
*
|
|
28
|
-
* The
|
|
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
|
-
|
|
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
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
|
|
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
|
-
*
|
|
744
|
-
* `
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
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${
|
|
770
|
+
const replaced = part.replaceWithSentinel(`[dropped \u00a7${tagId}\u00a7]`);
|
|
769
771
|
return replaced ? "removed" : "absent";
|
|
770
772
|
},
|
|
771
773
|
truncate(): "truncated" | "absent" {
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
const ok = setToolContentOrText(part,
|
|
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 {
|
|
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={
|
|
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
|
-
//
|
|
398
|
-
|
|
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
|
-
|
|
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
|
-
|
|
789
|
-
|
|
794
|
+
<Show when={s()?.recompProgress}>
|
|
795
|
+
{(progress) => (
|
|
796
|
+
<RecompProgressSection theme={props.theme} progress={progress()} />
|
|
797
|
+
)}
|
|
798
|
+
</Show>
|
|
790
799
|
</>
|
|
791
800
|
)}
|
|
792
801
|
|