@vellumai/assistant 0.11.3-dev.202608132218.8ea1f84 → 0.11.3-dev.202608140136.76df6b7
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/package.json +1 -1
- package/src/__tests__/app-compiler.test.ts +38 -3
- package/src/__tests__/mtime-cache.test.ts +1 -1
- package/src/__tests__/plugin-secret-pattern-contribution.test.ts +1 -1
- package/src/__tests__/user-plugin-loader.test.ts +1 -1
- package/src/bundler/package-resolver.ts +2 -30
- package/src/calls/call-controller.ts +9 -2
- package/src/hooks/hook-loader.ts +3 -3
- package/src/hooks/registry.ts +50 -6
- package/src/live-voice/live-voice-session.ts +11 -1
- package/src/plugins/mtime-cache.ts +11 -49
- package/src/tools/registry.ts +2 -1
- package/src/tts/__tests__/reasoning-tag-filter.test.ts +63 -0
- package/src/tts/reasoning-tag-filter.ts +110 -0
package/package.json
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
packageName,
|
|
21
21
|
resolvePackage,
|
|
22
22
|
} from "../bundler/package-resolver.js";
|
|
23
|
+
import { ensureBun } from "../util/bun-runtime.js";
|
|
23
24
|
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// Shared temp directory
|
|
@@ -56,6 +57,29 @@ async function scaffold(
|
|
|
56
57
|
return appDir;
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Download `pkg` into bun's global module cache via a throwaway install.
|
|
62
|
+
*
|
|
63
|
+
* `resolvePackage` bounds its own install at INSTALL_TIMEOUT_MS (10s), which a
|
|
64
|
+
* cold global cache can exceed for a large package on a slow network. Priming
|
|
65
|
+
* first is unbounded, so the install the test measures resolves from cache and
|
|
66
|
+
* stays well inside that bound.
|
|
67
|
+
*/
|
|
68
|
+
async function primeBunCache(pkg: string): Promise<void> {
|
|
69
|
+
const primeDir = join(tempDir, `prime-${pkg.replaceAll(/[^a-z0-9]/gi, "-")}`);
|
|
70
|
+
await mkdir(primeDir, { recursive: true });
|
|
71
|
+
await writeFile(
|
|
72
|
+
join(primeDir, "package.json"),
|
|
73
|
+
JSON.stringify({ name: "prime-cache", private: true }),
|
|
74
|
+
);
|
|
75
|
+
const proc = Bun.spawn([await ensureBun(), "install", "--no-save", pkg], {
|
|
76
|
+
cwd: primeDir,
|
|
77
|
+
stdout: "ignore",
|
|
78
|
+
stderr: "ignore",
|
|
79
|
+
});
|
|
80
|
+
await proc.exited;
|
|
81
|
+
}
|
|
82
|
+
|
|
59
83
|
const MINIMAL_HTML = `<!DOCTYPE html>
|
|
60
84
|
<html>
|
|
61
85
|
<head><title>Test</title></head>
|
|
@@ -466,10 +490,21 @@ describe("package-resolver", () => {
|
|
|
466
490
|
expect(ALLOWED_PACKAGES).toContain("clsx");
|
|
467
491
|
});
|
|
468
492
|
|
|
469
|
-
// `lucide` (the vanilla package)
|
|
470
|
-
//
|
|
471
|
-
// exports icon data arrays, not Preact components. Apps use inline SVG.
|
|
493
|
+
// `lucide` (the vanilla package) exports icon data arrays, not Preact
|
|
494
|
+
// components, and unpacks to ~29 MB for no benefit. Apps use inline SVG.
|
|
472
495
|
test("ALLOWED_PACKAGES excludes lucide", () => {
|
|
473
496
|
expect(ALLOWED_PACKAGES).not.toContain("lucide");
|
|
474
497
|
});
|
|
498
|
+
|
|
499
|
+
// `chart.js` is a large allowlisted package: it must install into the shared
|
|
500
|
+
// cache and resolve so apps importing it can compile.
|
|
501
|
+
test("resolvePackage installs a large allowlisted package", async () => {
|
|
502
|
+
await primeBunCache("chart.js");
|
|
503
|
+
|
|
504
|
+
const result = await resolvePackage("chart.js");
|
|
505
|
+
expect(result).not.toBeNull();
|
|
506
|
+
expect(existsSync(join(getCacheDir(), "node_modules", "chart.js"))).toBe(
|
|
507
|
+
true,
|
|
508
|
+
);
|
|
509
|
+
}, 60_000);
|
|
475
510
|
});
|
|
@@ -30,10 +30,10 @@ import {
|
|
|
30
30
|
} from "bun:test";
|
|
31
31
|
|
|
32
32
|
import { _inspectHookCacheForTests } from "../hooks/hook-loader.js";
|
|
33
|
+
import { getUserHooksFor } from "../hooks/registry.js";
|
|
33
34
|
import {
|
|
34
35
|
_inspectToolCacheForTests,
|
|
35
36
|
getCachedUserTools,
|
|
36
|
-
getUserHooksFor,
|
|
37
37
|
populateCacheAtBoot,
|
|
38
38
|
reconcilePluginSourcesNow,
|
|
39
39
|
resetPluginCacheForTests,
|
|
@@ -34,8 +34,8 @@ import {
|
|
|
34
34
|
} from "bun:test";
|
|
35
35
|
|
|
36
36
|
import { bootstrapPlugins } from "../daemon/external-plugins-bootstrap.js";
|
|
37
|
+
import { getUserHooksFor } from "../hooks/registry.js";
|
|
37
38
|
import {
|
|
38
|
-
getUserHooksFor,
|
|
39
39
|
populateCacheAtBoot,
|
|
40
40
|
reconcilePluginSourcesNow,
|
|
41
41
|
resetPluginCacheForTests,
|
|
@@ -23,9 +23,9 @@ import { tmpdir } from "node:os";
|
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
25
25
|
|
|
26
|
+
import { getUserHooksFor } from "../hooks/registry.js";
|
|
26
27
|
import {
|
|
27
28
|
getCachedUserTools,
|
|
28
|
-
getUserHooksFor,
|
|
29
29
|
resetPluginCacheForTests,
|
|
30
30
|
} from "../plugins/mtime-cache.js";
|
|
31
31
|
import { loadUserPlugins } from "../plugins/user-loader.js";
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
|
-
import { mkdir
|
|
9
|
+
import { mkdir } from "node:fs/promises";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
|
|
12
12
|
import { ensureBun } from "../util/bun-runtime.js";
|
|
@@ -24,8 +24,8 @@ export const ALLOWED_PACKAGES: readonly string[] = [
|
|
|
24
24
|
"clsx",
|
|
25
25
|
] as const;
|
|
26
26
|
|
|
27
|
+
/** Bounds a runaway install; the allowlist above bounds what may be installed. */
|
|
27
28
|
const INSTALL_TIMEOUT_MS = 10_000;
|
|
28
|
-
const MAX_PACKAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
29
29
|
|
|
30
30
|
/** In-flight install promises keyed by package name, to deduplicate concurrent requests. */
|
|
31
31
|
const inflight = new Map<string, Promise<string | null>>();
|
|
@@ -148,37 +148,9 @@ async function installPackage(
|
|
|
148
148
|
return null;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
// Enforce max size
|
|
152
|
-
if (existsSync(pkgDir)) {
|
|
153
|
-
const size = await dirSize(pkgDir);
|
|
154
|
-
if (size > MAX_PACKAGE_SIZE_BYTES) {
|
|
155
|
-
log.warn({ pkg, size }, "Package exceeds size limit, removing");
|
|
156
|
-
const { rm } = await import("node:fs/promises");
|
|
157
|
-
await rm(pkgDir, { recursive: true, force: true });
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
151
|
return existsSync(pkgDir) ? nodeModulesDir : null;
|
|
163
152
|
} catch (err) {
|
|
164
153
|
log.warn({ pkg, err }, "Package resolution failed");
|
|
165
154
|
return null;
|
|
166
155
|
}
|
|
167
156
|
}
|
|
168
|
-
|
|
169
|
-
/** Recursively sum file sizes under a directory. */
|
|
170
|
-
async function dirSize(dir: string): Promise<number> {
|
|
171
|
-
const { readdir } = await import("node:fs/promises");
|
|
172
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
173
|
-
let total = 0;
|
|
174
|
-
for (const entry of entries) {
|
|
175
|
-
const full = join(dir, entry.name);
|
|
176
|
-
if (entry.isDirectory()) {
|
|
177
|
-
total += await dirSize(full);
|
|
178
|
-
} else {
|
|
179
|
-
const s = await stat(full);
|
|
180
|
-
total += s.size;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return total;
|
|
184
|
-
}
|
|
@@ -21,6 +21,7 @@ import type { TrustContext } from "../daemon/trust-context-types.js";
|
|
|
21
21
|
import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
|
|
22
22
|
import { computeToolApprovalDigest } from "../security/tool-approval-digest.js";
|
|
23
23
|
import { getCatalogProvider } from "../tts/provider-catalog.js";
|
|
24
|
+
import { createReasoningTagFilter } from "../tts/reasoning-tag-filter.js";
|
|
24
25
|
import { extractSpeakableSegments } from "../tts/speakable-segments.js";
|
|
25
26
|
import {
|
|
26
27
|
type AudioStoreSink,
|
|
@@ -728,6 +729,10 @@ export class CallController {
|
|
|
728
729
|
// could be the start of a control marker.
|
|
729
730
|
let ttsBuffer = "";
|
|
730
731
|
let fullResponseText = "";
|
|
732
|
+
// Reasoning models can inline <think> spans in the content stream when a
|
|
733
|
+
// profile has not opted into parseThinkTags; the spoken path must never
|
|
734
|
+
// read them aloud. fullResponseText keeps the raw stream.
|
|
735
|
+
const reasoningFilter = createReasoningTagFilter();
|
|
731
736
|
|
|
732
737
|
// Synthesized path: text is split at speakable boundaries as it streams
|
|
733
738
|
// and each segment is synthesized while the LLM keeps generating. The
|
|
@@ -931,7 +936,7 @@ export class CallController {
|
|
|
931
936
|
return;
|
|
932
937
|
}
|
|
933
938
|
fullResponseText += text;
|
|
934
|
-
ttsBuffer += text;
|
|
939
|
+
ttsBuffer += reasoningFilter.push(text);
|
|
935
940
|
ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
|
|
936
941
|
flushSafeText();
|
|
937
942
|
};
|
|
@@ -1010,7 +1015,9 @@ export class CallController {
|
|
|
1010
1015
|
return fullResponseText;
|
|
1011
1016
|
}
|
|
1012
1017
|
|
|
1013
|
-
// Final sweep:
|
|
1018
|
+
// Final sweep: release any held-back partial tag, then strip any
|
|
1019
|
+
// remaining control markers from the buffer.
|
|
1020
|
+
ttsBuffer += reasoningFilter.flush();
|
|
1014
1021
|
ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
|
|
1015
1022
|
if (ttsBuffer.length > 0) {
|
|
1016
1023
|
emitSafeChunk(ttsBuffer);
|
package/src/hooks/hook-loader.ts
CHANGED
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
* registry, so the next read re-resolves fresh.
|
|
40
40
|
*
|
|
41
41
|
* Plugin *discovery* (which plugin directories exist, in what order) lives in
|
|
42
|
-
* `../plugins/mtime-cache.ts`;
|
|
43
|
-
*
|
|
44
|
-
* this module lets it sit below the plugin cache with no import cycle.
|
|
42
|
+
* `../plugins/mtime-cache.ts`; hook lookup in `./registry.ts` passes the
|
|
43
|
+
* discovered names into {@link collectUserHookEntries}. Keeping discovery out
|
|
44
|
+
* of this module lets it sit below the plugin cache with no import cycle.
|
|
45
45
|
*/
|
|
46
46
|
|
|
47
47
|
import {
|
package/src/hooks/registry.ts
CHANGED
|
@@ -6,20 +6,22 @@
|
|
|
6
6
|
* hook-chain ordering.
|
|
7
7
|
*
|
|
8
8
|
* User-land hooks (from the filesystem) are owned by
|
|
9
|
-
* {@link ./hook-loader.ts} and surfaced through
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* {@link ./hook-loader.ts} and surfaced through {@link getUserHooksFor}.
|
|
10
|
+
* This module owns the in-process hooks that default plugins register at
|
|
11
|
+
* boot, plus the user-land lookup that walks discovered plugin names from
|
|
12
|
+
* the plugin cache.
|
|
12
13
|
*
|
|
13
14
|
* {@link getHooksFor} combines both sources: in-process hooks from this
|
|
14
15
|
* registry (filtered by `isPluginDisabled` at read time) and user-land hooks
|
|
15
16
|
* from the plugin cache. The read-time filtering is what makes `assistant
|
|
16
|
-
* plugins disable default-*` take effect immediately in a running assistant
|
|
17
|
-
*
|
|
17
|
+
* plugins disable default-*` take effect immediately in a running assistant:
|
|
18
|
+
* the hooks stay registered but are filtered out on the next turn.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import { isPluginDisabled } from "../plugins/disabled-state.js";
|
|
21
|
-
import {
|
|
22
|
+
import { getDiscoveredUserPluginNames } from "../plugins/mtime-cache.js";
|
|
22
23
|
import type { HookEntry, HookFunction } from "../plugins/types.js";
|
|
24
|
+
import { collectUserHookEntries } from "./hook-loader.js";
|
|
23
25
|
|
|
24
26
|
// ─── Internal state ──────────────────────────────────────────────────────────
|
|
25
27
|
|
|
@@ -75,6 +77,48 @@ export function unregisterPluginHooks(pluginName: string): void {
|
|
|
75
77
|
|
|
76
78
|
// ─── Queries ─────────────────────────────────────────────────────────────────
|
|
77
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Get all hooks for a given event name from user plugins and standalone
|
|
82
|
+
* workspace hooks, from the hook loader's in-memory cache. Plugin hooks run
|
|
83
|
+
* in install-date order, the workspace hook runs last.
|
|
84
|
+
*
|
|
85
|
+
* This is a pure cache read: it never scans disk, activates a plugin, or
|
|
86
|
+
* runs `init`. Activation happens only at boot (`populateCacheAtBoot`)
|
|
87
|
+
* and through the imperative install/uninstall poke
|
|
88
|
+
* (`reconcilePluginSourcesNow`), both main-daemon paths, so a
|
|
89
|
+
* sidecar process that dispatches hooks (a worker running conversation
|
|
90
|
+
* turns) can never bring a plugin up in its own process.
|
|
91
|
+
*
|
|
92
|
+
* `effectiveEnabledPlugins` carries the per-chat plugin scope: when non-null,
|
|
93
|
+
* user plugins outside the set are skipped (standalone workspace hooks always
|
|
94
|
+
* run). `null`/omitted means no per-chat restriction.
|
|
95
|
+
*/
|
|
96
|
+
export async function getUserHookEntriesFor<TCtx = unknown>(
|
|
97
|
+
hookName: string,
|
|
98
|
+
effectiveEnabledPlugins?: Set<string> | null,
|
|
99
|
+
): Promise<HookEntry<TCtx>[]> {
|
|
100
|
+
return collectUserHookEntries<TCtx>(
|
|
101
|
+
hookName,
|
|
102
|
+
getDiscoveredUserPluginNames(),
|
|
103
|
+
effectiveEnabledPlugins,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* {@link getUserHookEntriesFor} without owner attribution. Returns just the
|
|
109
|
+
* hook functions in the same order.
|
|
110
|
+
*/
|
|
111
|
+
export async function getUserHooksFor<TCtx = unknown>(
|
|
112
|
+
hookName: string,
|
|
113
|
+
effectiveEnabledPlugins?: Set<string> | null,
|
|
114
|
+
): Promise<HookFunction<TCtx>[]> {
|
|
115
|
+
const entries = await getUserHookEntriesFor<TCtx>(
|
|
116
|
+
hookName,
|
|
117
|
+
effectiveEnabledPlugins,
|
|
118
|
+
);
|
|
119
|
+
return entries.map((e) => e.fn);
|
|
120
|
+
}
|
|
121
|
+
|
|
78
122
|
/**
|
|
79
123
|
* Collect every registered hook for the given name, in registration order.
|
|
80
124
|
* Plugins that don't declare a hook for `name` are skipped. Used by the
|
|
@@ -73,6 +73,10 @@ import type {
|
|
|
73
73
|
import { getSubagentManager } from "../subagent/index.js";
|
|
74
74
|
import { liveVoiceEndScreen } from "../telemetry/live-voice-funnel.js";
|
|
75
75
|
import { getToolOwner } from "../tools/registry.js";
|
|
76
|
+
import {
|
|
77
|
+
createReasoningTagFilter,
|
|
78
|
+
type ReasoningTagFilter,
|
|
79
|
+
} from "../tts/reasoning-tag-filter.js";
|
|
76
80
|
import { extractSpeakableSegments } from "../tts/speakable-segments.js";
|
|
77
81
|
import { createAbortReason } from "../util/abort-reasons.js";
|
|
78
82
|
import { hasLocalizedEntry } from "../util/language-subtag.js";
|
|
@@ -717,6 +721,10 @@ interface ActiveAssistantTurn {
|
|
|
717
721
|
// the hand-off idempotent.
|
|
718
722
|
escalationHandedOff: boolean;
|
|
719
723
|
ttsBuffer: string;
|
|
724
|
+
// Strips inline <think> reasoning spans from the spoken stream. Stateful
|
|
725
|
+
// per turn because spans and tags cross delta boundaries; display frames
|
|
726
|
+
// keep the raw text.
|
|
727
|
+
ttsReasoningFilter: ReasoningTagFilter;
|
|
720
728
|
// A non-empty speakable segment reached the TTS queue — gates the eager
|
|
721
729
|
// first-segment flush that trades clause quality for speech onset.
|
|
722
730
|
ttsSegmentEnqueued: boolean;
|
|
@@ -4503,6 +4511,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
4503
4511
|
deltaEpoch: 0,
|
|
4504
4512
|
escalationHandedOff: false,
|
|
4505
4513
|
ttsBuffer: "",
|
|
4514
|
+
ttsReasoningFilter: createReasoningTagFilter(),
|
|
4506
4515
|
ttsSegmentEnqueued: false,
|
|
4507
4516
|
ttsJobs: [],
|
|
4508
4517
|
ttsQueue: Promise.resolve(),
|
|
@@ -5492,7 +5501,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5492
5501
|
return;
|
|
5493
5502
|
}
|
|
5494
5503
|
|
|
5495
|
-
activeTurn.ttsBuffer += text;
|
|
5504
|
+
activeTurn.ttsBuffer += activeTurn.ttsReasoningFilter.push(text);
|
|
5496
5505
|
this.flushTtsBuffer(token, false);
|
|
5497
5506
|
}
|
|
5498
5507
|
|
|
@@ -5503,6 +5512,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5503
5512
|
}
|
|
5504
5513
|
|
|
5505
5514
|
this.clearFillerTimers(activeTurn);
|
|
5515
|
+
activeTurn.ttsBuffer += activeTurn.ttsReasoningFilter.flush();
|
|
5506
5516
|
this.flushTtsBuffer(token, true);
|
|
5507
5517
|
activeTurn.ttsQueue = activeTurn.ttsQueue
|
|
5508
5518
|
.catch(() => {})
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
* - Plugins are never "registered" as a unit — we register their tools into
|
|
27
27
|
* the global tool registry.
|
|
28
28
|
*
|
|
29
|
-
* Tools are populated at boot by `loadUserPlugins()`;
|
|
30
|
-
*
|
|
31
|
-
* each hook through the hook loader on demand.
|
|
29
|
+
* Tools are populated at boot by `loadUserPlugins()`; hook dispatch reads
|
|
30
|
+
* discovered plugin names from this cache via {@link getDiscoveredUserPluginNames}
|
|
31
|
+
* and resolves each hook through the hook loader on demand.
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
@@ -38,7 +38,6 @@ import type { Logger } from "pino";
|
|
|
38
38
|
|
|
39
39
|
import {
|
|
40
40
|
clearPluginHooks,
|
|
41
|
-
collectUserHookEntries,
|
|
42
41
|
evictHooksForOwner,
|
|
43
42
|
hasWorkspaceHooks,
|
|
44
43
|
type HookOwnerKind,
|
|
@@ -47,7 +46,7 @@ import {
|
|
|
47
46
|
runShutdownHook,
|
|
48
47
|
WORKSPACE_HOOKS_OWNER,
|
|
49
48
|
} from "../hooks/hook-loader.js";
|
|
50
|
-
import type {
|
|
49
|
+
import type { ShutdownReason } from "../plugin-api/types.js";
|
|
51
50
|
import {
|
|
52
51
|
registerPluginSecretPatterns,
|
|
53
52
|
resetPluginSecretPatternsForTests,
|
|
@@ -76,11 +75,7 @@ import {
|
|
|
76
75
|
importWithTimeout,
|
|
77
76
|
setSurfaceImportTimeout,
|
|
78
77
|
} from "./surface-import.js";
|
|
79
|
-
import type {
|
|
80
|
-
|
|
81
|
-
// Re-export for type compat — consumers that import HookFunction from
|
|
82
|
-
// the mtime cache module still resolve.
|
|
83
|
-
export type { HookFunction } from "./types.js";
|
|
78
|
+
import type { PluginCredentialKeyPattern } from "./types.js";
|
|
84
79
|
|
|
85
80
|
const log = getLogger("plugin-mtime-cache");
|
|
86
81
|
|
|
@@ -206,48 +201,15 @@ let lastVersions: Record<string, PluginSourceVersion> = {};
|
|
|
206
201
|
/** In-flight reconcile — concurrent imperative pokes await it rather than racing. */
|
|
207
202
|
let reconcileInFlight: Promise<void> | null = null;
|
|
208
203
|
|
|
209
|
-
// ───
|
|
204
|
+
// ─── Discovery reads ─────────────────────────────────────────────────────────
|
|
210
205
|
|
|
211
206
|
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
* This is a pure cache read: it never scans disk, activates a plugin, or
|
|
217
|
-
* runs `init`. Activation happens only at boot ({@link populateCacheAtBoot})
|
|
218
|
-
* and through the imperative install/uninstall poke
|
|
219
|
-
* ({@link reconcilePluginSourcesNow}) — both main-daemon paths — so a
|
|
220
|
-
* sidecar process that dispatches hooks (a worker running conversation
|
|
221
|
-
* turns) can never bring a plugin up in its own process.
|
|
222
|
-
*
|
|
223
|
-
* `effectiveEnabledPlugins` carries the per-chat plugin scope: when non-null,
|
|
224
|
-
* user plugins outside the set are skipped (standalone workspace hooks always
|
|
225
|
-
* run). `null`/omitted means no per-chat restriction.
|
|
207
|
+
* Plugin names currently in the discovery cache, in install-date order.
|
|
208
|
+
* Hook lookup in `hooks/registry.ts` walks this set; the cache itself does
|
|
209
|
+
* not resolve hooks.
|
|
226
210
|
*/
|
|
227
|
-
export
|
|
228
|
-
|
|
229
|
-
effectiveEnabledPlugins?: Set<string> | null,
|
|
230
|
-
): Promise<HookEntry<TCtx>[]> {
|
|
231
|
-
return collectUserHookEntries<TCtx>(
|
|
232
|
-
hookName,
|
|
233
|
-
discoveredPluginDirs.values(),
|
|
234
|
-
effectiveEnabledPlugins,
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* {@link getUserHookEntriesFor} without owner attribution — returns just the
|
|
240
|
-
* hook functions in the same order.
|
|
241
|
-
*/
|
|
242
|
-
export async function getUserHooksFor<TCtx = unknown>(
|
|
243
|
-
hookName: string,
|
|
244
|
-
effectiveEnabledPlugins?: Set<string> | null,
|
|
245
|
-
): Promise<HookFunction<TCtx>[]> {
|
|
246
|
-
const entries = await getUserHookEntriesFor<TCtx>(
|
|
247
|
-
hookName,
|
|
248
|
-
effectiveEnabledPlugins,
|
|
249
|
-
);
|
|
250
|
-
return entries.map((e) => e.fn);
|
|
211
|
+
export function getDiscoveredUserPluginNames(): Iterable<string> {
|
|
212
|
+
return discoveredPluginDirs.values();
|
|
251
213
|
}
|
|
252
214
|
|
|
253
215
|
// ─── Source-versions reconcile ───────────────────────────────────────────────
|
package/src/tools/registry.ts
CHANGED
|
@@ -431,7 +431,8 @@ export function unregisterPluginTools(pluginName: string): void {
|
|
|
431
431
|
* {@link import("../plugins/mtime-cache.js").getActiveUserPluginTools} and diffs
|
|
432
432
|
* the result into the registry: plugins that vanished are unregistered, new
|
|
433
433
|
* plugins register, and a plugin whose per-tool source mtimes moved is
|
|
434
|
-
* re-registered. Mirrors how hooks resolve through `getUserHookEntriesFor
|
|
434
|
+
* re-registered. Mirrors how hooks resolve through `getUserHookEntriesFor`
|
|
435
|
+
* in `hooks/registry.ts`.
|
|
435
436
|
*
|
|
436
437
|
* This is a pure read of the plugin cache — it does NOT reconcile the cache
|
|
437
438
|
* against the source-versions sentinel, so it never activates a plugin or runs
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { createReasoningTagFilter } from "../reasoning-tag-filter.js";
|
|
4
|
+
|
|
5
|
+
function run(chunks: string[]): string {
|
|
6
|
+
const filter = createReasoningTagFilter();
|
|
7
|
+
let out = "";
|
|
8
|
+
for (const chunk of chunks) {
|
|
9
|
+
out += filter.push(chunk);
|
|
10
|
+
}
|
|
11
|
+
out += filter.flush();
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("ReasoningTagFilter", () => {
|
|
16
|
+
test("passes plain text through unchanged", () => {
|
|
17
|
+
expect(run(["Hello ", "there."])).toBe("Hello there.");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("drops a complete think span within one delta", () => {
|
|
21
|
+
expect(run(["<think>hm, let me reason</think>Answer."])).toBe("Answer.");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("drops a reasoning span crossing many deltas", () => {
|
|
25
|
+
expect(
|
|
26
|
+
run(["<thi", "nk>step one", " step two", "</th", "ink>Spoken reply"]),
|
|
27
|
+
).toBe("Spoken reply");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("drops thinking-tag variant and is case-insensitive", () => {
|
|
31
|
+
expect(run(["<Thinking>inner</Thinking>ok"])).toBe("ok");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("keeps text before and after the span", () => {
|
|
35
|
+
expect(run(["Sure. <think>secret</think> Done."])).toBe("Sure. Done.");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("handles multiple spans in one turn", () => {
|
|
39
|
+
expect(run(["a<think>x</think>b", "<think>y", "z</think>c"])).toBe("abc");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("an unclosed span at end of stream stays dropped", () => {
|
|
43
|
+
expect(run(["ok<think>never closes"])).toBe("ok");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a dangling partial open tag is released as literal text at flush", () => {
|
|
47
|
+
expect(run(["price <th"])).toBe("price <th");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("a false partial that disambiguates to text is released", () => {
|
|
51
|
+
expect(run(["a <th", "ree-step plan"])).toBe("a <three-step plan");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("does not touch angle brackets that are not think tags", () => {
|
|
55
|
+
expect(run(["use <b>bold</b> and x < y"])).toBe(
|
|
56
|
+
"use <b>bold</b> and x < y",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("holds back nothing across an empty stream", () => {
|
|
61
|
+
expect(run([""])).toBe("");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming reasoning-tag filter for the TTS pipelines.
|
|
3
|
+
*
|
|
4
|
+
* Some OpenAI-compatible reasoning models emit their chain of thought inline
|
|
5
|
+
* in the content stream as `<think>...</think>` (or `<thinking>...</thinking>`)
|
|
6
|
+
* spans. When a profile has not opted into the chat-completions provider's
|
|
7
|
+
* `parseThinkTags` (which re-routes those spans to `thinking_delta`), the raw
|
|
8
|
+
* reasoning rides `text_delta`, and anything downstream that speaks text
|
|
9
|
+
* deltas would read the model's inner monologue aloud and delay real speech
|
|
10
|
+
* by the full length of the reasoning.
|
|
11
|
+
*
|
|
12
|
+
* This filter is the TTS-boundary guard: voice output must never speak
|
|
13
|
+
* reasoning regardless of profile configuration. It is stateful because a
|
|
14
|
+
* tag or a reasoning span routinely crosses delta boundaries; feed every
|
|
15
|
+
* delta through {@link ReasoningTagFilter.push} and call
|
|
16
|
+
* {@link ReasoningTagFilter.flush} when the turn's stream ends.
|
|
17
|
+
*
|
|
18
|
+
* Display paths are deliberately untouched: `assistant_text_delta` frames
|
|
19
|
+
* keep the raw text, exactly like the markdown handling in
|
|
20
|
+
* `calls/tts-text-sanitizer.ts`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const OPEN_TAGS = ["<think>", "<thinking>"] as const;
|
|
24
|
+
const CLOSE_TAGS = ["</think>", "</thinking>"] as const;
|
|
25
|
+
|
|
26
|
+
/** Longest suffix of `text` that is a proper prefix of any listed tag. */
|
|
27
|
+
function partialTagSuffix(text: string, tags: readonly string[]): number {
|
|
28
|
+
const max = Math.max(...tags.map((tag) => tag.length)) - 1;
|
|
29
|
+
const window = Math.min(max, text.length);
|
|
30
|
+
for (let len = window; len > 0; len -= 1) {
|
|
31
|
+
const suffix = text.slice(text.length - len).toLowerCase();
|
|
32
|
+
if (tags.some((tag) => tag.startsWith(suffix))) {
|
|
33
|
+
return len;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function indexOfAny(
|
|
40
|
+
haystack: string,
|
|
41
|
+
tags: readonly string[],
|
|
42
|
+
): { index: number; tag: string } | null {
|
|
43
|
+
const lower = haystack.toLowerCase();
|
|
44
|
+
let best: { index: number; tag: string } | null = null;
|
|
45
|
+
for (const tag of tags) {
|
|
46
|
+
const index = lower.indexOf(tag);
|
|
47
|
+
if (index >= 0 && (best === null || index < best.index)) {
|
|
48
|
+
best = { index, tag };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return best;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ReasoningTagFilter {
|
|
55
|
+
private insideReasoning = false;
|
|
56
|
+
private pending = "";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Feed one streamed delta; returns the speakable text it releases.
|
|
60
|
+
* Reasoning spans are dropped; a partial tag at the end of the buffer is
|
|
61
|
+
* held back until the next delta disambiguates it.
|
|
62
|
+
*/
|
|
63
|
+
push(text: string): string {
|
|
64
|
+
this.pending += text;
|
|
65
|
+
let out = "";
|
|
66
|
+
for (;;) {
|
|
67
|
+
if (this.insideReasoning) {
|
|
68
|
+
const close = indexOfAny(this.pending, CLOSE_TAGS);
|
|
69
|
+
if (close) {
|
|
70
|
+
this.pending = this.pending.slice(close.index + close.tag.length);
|
|
71
|
+
this.insideReasoning = false;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// Everything buffered is reasoning except a possible partial close
|
|
75
|
+
// tag at the end; drop the reasoning, keep the partial.
|
|
76
|
+
const partial = partialTagSuffix(this.pending, CLOSE_TAGS);
|
|
77
|
+
this.pending = partial > 0 ? this.pending.slice(-partial) : "";
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
const open = indexOfAny(this.pending, OPEN_TAGS);
|
|
81
|
+
if (open) {
|
|
82
|
+
out += this.pending.slice(0, open.index);
|
|
83
|
+
this.pending = this.pending.slice(open.index + open.tag.length);
|
|
84
|
+
this.insideReasoning = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const partial = partialTagSuffix(this.pending, OPEN_TAGS);
|
|
88
|
+
const safeLength = this.pending.length - partial;
|
|
89
|
+
out += this.pending.slice(0, safeLength);
|
|
90
|
+
this.pending = partial > 0 ? this.pending.slice(safeLength) : "";
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* End of stream: release any held-back partial. Outside a reasoning span a
|
|
97
|
+
* dangling "<thin" was ordinary text after all; inside one, the span never
|
|
98
|
+
* closed and its content stays dropped.
|
|
99
|
+
*/
|
|
100
|
+
flush(): string {
|
|
101
|
+
const rest = this.insideReasoning ? "" : this.pending;
|
|
102
|
+
this.pending = "";
|
|
103
|
+
this.insideReasoning = false;
|
|
104
|
+
return rest;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createReasoningTagFilter(): ReasoningTagFilter {
|
|
109
|
+
return new ReasoningTagFilter();
|
|
110
|
+
}
|