@oh-my-pi/pi-ai 16.1.22 → 16.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/types/providers/openai-shared.d.ts +3 -1
- package/dist/types/registry/api-key-login.d.ts +1 -0
- package/dist/types/registry/api-key-validation.d.ts +1 -0
- package/dist/types/registry/coreweave.d.ts +7 -0
- package/dist/types/registry/registry.d.ts +4 -0
- package/package.json +4 -4
- package/src/providers/openai-shared.ts +68 -2
- package/src/registry/api-key-login.ts +2 -0
- package/src/registry/api-key-validation.ts +8 -0
- package/src/registry/coreweave.ts +38 -0
- package/src/registry/oauth/oauth.html +177 -59
- package/src/registry/registry.ts +2 -0
- package/src/utils/thinking-loop.ts +94 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.1.23] - 2026-06-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added a third streaming thinking-loop detection heuristic to catch "progress-lexicon stalls" where models endlessly reshuffle motivational filler without introducing new vocabulary or concrete technical references
|
|
10
|
+
- Added branded wordmark and logo animation to authentication flow
|
|
11
|
+
- Added a third streaming thinking-loop detection shape — a *progress-lexicon stall* — alongside verbatim tail repetition and near-duplicate (trigram) segments. It catches reasoning-summarizer loops that reshuffle the same motivational filler ("just doing it, pushing ahead, maintaining momentum") into fresh word order every paragraph: word-trigrams never cluster, but a run of substantial segments that recycle the recent vocabulary and introduce no *new* concrete reference (path / identifier / code-span) trips the guard. Summarizer title/heading lines (`**Bold Title**`, `## Heading`) are stripped before analysis so their ever-changing wording cannot mask the stall by inflating novelty. Calibrated against 537k real non-Gemini reasoning blocks (zero false positives at novelty floor 0.2 / run length 8; the real loop sustains runs of 10+).
|
|
12
|
+
- Added CoreWeave Serverless Inference provider login support via `COREWEAVE_API_KEY` and `WANDB_API_KEY` fallback.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Redesigned the OAuth callback page (`oauth.html`) to match the oh-my-pi web brand language: OKLCH purple-tinted dark neutrals, magenta→iris→cyan brand gradient on the wordmark, frosted-glass card over an ascii grid backdrop, and a colored status halo around the success/error icon. All assets are inlined; the `__OAUTH_STATE__` injection contract and success/error JS logic are unchanged.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Fixed local llama.cpp (and any local OpenAI-compatible server rendering the Qwen3.6+ chat template) re-processing the full prompt every new user message even with `replayReasoningContent` enabled (#3541 follow-up to #3528). Sending `reasoning_content` alone wasn't enough: Qwen3's chat template strips `<think>...</think>` from any assistant turn whose index is `<= last_query_index`, so the moment a new user message (the user's next prompt, or the auto-learn capture-at-stop nudge) lands, every prior assistant turn becomes "older" and is re-rendered without the `<think>` block — diverging from the generation tokens still in the slot's KV cache. The chat-completions encoder now emits `preserve_thinking: true` for Qwen thinking dialects on local servers, route-split the same way the existing `enable_thinking` emission is: the `qwen` dialect rides the top-level field (llama.cpp's `--jinja` hook and Alibaba Cloud Model Studio's compatible-mode), the `qwen-chat-template` dialect (NVIDIA NIM, vLLM/SGLang's chat-template-kwargs path) rides only `chat_template_kwargs.preserve_thinking` because NIM's request schema is `additionalProperties: false` and rejects unknown top-level fields (#2299). The emission is hoisted above the `reasoning.enabled` gate so it fires for THREE cases the original gating missed: (1) runtime-discovered local Qwen models that ship with `reasoning: false` because the upstream `/v1/models` doesn't advertise the capability (same gotcha #3532 fixed for `replayReasoningContent`), (2) caller-disabled reasoning (`/think off`) — the kwarg is a history-rendering knob, not a per-turn thinking switch, and the slot still holds `<think>` tokens from earlier turns, and (3) forced-tool-choice / DeepSeek-style auto-disable. Qwen3.6+ then renders `<think>...</think>` for every assistant turn regardless of position, and the next-turn render matches the cached generation tokens. ([#3541](https://github.com/can1357/oh-my-pi/issues/3541))
|
|
21
|
+
|
|
5
22
|
## [16.1.22] - 2026-06-26
|
|
6
23
|
|
|
7
24
|
### Fixed
|
|
@@ -194,8 +194,10 @@ export type OpenAICompletionsParams = Omit<ChatCompletionCreateParamsStreaming,
|
|
|
194
194
|
keep?: "all";
|
|
195
195
|
};
|
|
196
196
|
enable_thinking?: boolean;
|
|
197
|
+
preserve_thinking?: boolean;
|
|
197
198
|
chat_template_kwargs?: {
|
|
198
|
-
enable_thinking
|
|
199
|
+
enable_thinking?: boolean;
|
|
200
|
+
preserve_thinking?: boolean;
|
|
199
201
|
};
|
|
200
202
|
reasoning?: {
|
|
201
203
|
effort?: string;
|
|
@@ -22,6 +22,7 @@ type ModelsEndpointValidation = {
|
|
|
22
22
|
kind: "models-endpoint";
|
|
23
23
|
provider: string;
|
|
24
24
|
modelsUrl: string;
|
|
25
|
+
headers?: Record<string, string> | (() => Record<string, string> | undefined);
|
|
25
26
|
};
|
|
26
27
|
export type ApiKeyLoginConfig = {
|
|
27
28
|
/** Display name used in error messages, e.g. "Cerebras", "NanoGPT". */
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { OAuthLoginCallbacks } from "./oauth/types";
|
|
2
|
+
export declare const loginCoreWeave: (options: import("./oauth").OAuthController) => Promise<string>;
|
|
3
|
+
export declare const coreWeaveProvider: {
|
|
4
|
+
readonly id: "coreweave";
|
|
5
|
+
readonly name: "CoreWeave Serverless Inference";
|
|
6
|
+
readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
|
|
7
|
+
};
|
|
@@ -37,6 +37,10 @@ declare const ALL: ({
|
|
|
37
37
|
readonly id: "cloudflare-ai-gateway";
|
|
38
38
|
readonly name: "Cloudflare AI Gateway";
|
|
39
39
|
readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
|
|
40
|
+
} | {
|
|
41
|
+
readonly id: "coreweave";
|
|
42
|
+
readonly name: "CoreWeave Serverless Inference";
|
|
43
|
+
readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
|
|
40
44
|
} | {
|
|
41
45
|
readonly id: "cursor";
|
|
42
46
|
readonly name: "Cursor (Claude, GPT, etc.)";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "16.1.
|
|
4
|
+
"version": "16.1.23",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "16.1.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.1.
|
|
43
|
-
"@oh-my-pi/pi-wire": "16.1.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.1.23",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.1.23",
|
|
43
|
+
"@oh-my-pi/pi-wire": "16.1.23",
|
|
44
44
|
"arktype": "^2.2.0",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
|
@@ -13,6 +13,11 @@ import type {
|
|
|
13
13
|
ResolvedOpenAISharedCompat,
|
|
14
14
|
VercelGatewayRouting,
|
|
15
15
|
} from "@oh-my-pi/pi-catalog/types";
|
|
16
|
+
import {
|
|
17
|
+
COREWEAVE_PROJECT_HEADER,
|
|
18
|
+
coreWeaveProjectHeaders,
|
|
19
|
+
hasCoreWeaveProjectHeader,
|
|
20
|
+
} from "@oh-my-pi/pi-catalog/wire/coreweave";
|
|
16
21
|
import { parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot";
|
|
17
22
|
import { $env, extractHttpStatusFromError, logger, structuredCloneJSON } from "@oh-my-pi/pi-utils";
|
|
18
23
|
import {
|
|
@@ -141,6 +146,16 @@ function resolveSakanaRequestBaseUrl(): string | undefined {
|
|
|
141
146
|
return normalizeSakanaRequestBaseUrl($env.SAKANA_BASE_URL) ?? normalizeSakanaRequestBaseUrl($env.FUGU_BASE_URL);
|
|
142
147
|
}
|
|
143
148
|
|
|
149
|
+
function applyCoreWeaveProjectHeader(headers: Record<string, string>): void {
|
|
150
|
+
if (hasCoreWeaveProjectHeader(headers)) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const projectHeaders = coreWeaveProjectHeaders($env);
|
|
154
|
+
if (projectHeaders) {
|
|
155
|
+
headers[COREWEAVE_PROJECT_HEADER] = projectHeaders[COREWEAVE_PROJECT_HEADER];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
144
159
|
export function resolveOpenAIRequestSetup(
|
|
145
160
|
model: OpenAIRequestSetupModel,
|
|
146
161
|
options: OpenAIRequestSetupOptions,
|
|
@@ -160,6 +175,9 @@ export function resolveOpenAIRequestSetup(
|
|
|
160
175
|
Object.assign(headers, getOpenRouterHeaders());
|
|
161
176
|
}
|
|
162
177
|
Object.assign(headers, options.extraHeaders);
|
|
178
|
+
if (model.provider === "coreweave") {
|
|
179
|
+
applyCoreWeaveProjectHeader(headers);
|
|
180
|
+
}
|
|
163
181
|
if (options.prependHeaders) {
|
|
164
182
|
headers = { ...options.prependHeaders(), ...headers };
|
|
165
183
|
}
|
|
@@ -585,7 +603,8 @@ export type OpenAICompletionsParams = Omit<ChatCompletionCreateParamsStreaming,
|
|
|
585
603
|
repetition_penalty?: number;
|
|
586
604
|
thinking?: { type: "enabled" | "disabled"; keep?: "all" };
|
|
587
605
|
enable_thinking?: boolean;
|
|
588
|
-
|
|
606
|
+
preserve_thinking?: boolean;
|
|
607
|
+
chat_template_kwargs?: { enable_thinking?: boolean; preserve_thinking?: boolean };
|
|
589
608
|
reasoning?: { effort?: string } | { enabled: false };
|
|
590
609
|
reasoning_effort?: string | null;
|
|
591
610
|
service_tier?: ResolvedServiceTier;
|
|
@@ -813,6 +832,50 @@ function encodeChatCompletionsDisabledReasoning(
|
|
|
813
832
|
}
|
|
814
833
|
|
|
815
834
|
export function applyChatCompletionsCompatPolicy(params: OpenAICompletionsParams, policy: OpenAICompatPolicy): void {
|
|
835
|
+
// `preserve_thinking` is a chat-template HISTORY knob, not a per-turn
|
|
836
|
+
// thinking switch — it controls whether OLDER assistant turns render
|
|
837
|
+
// with `<think>...</think>` on Qwen3.6+. Emit it BEFORE the reasoning
|
|
838
|
+
// state branches and EVERY early-return below, because the wire shape
|
|
839
|
+
// must carry the kwarg in three cases the auto-detected
|
|
840
|
+
// `qwenPreserveThinking` flag covers but `reasoning.enabled` does not:
|
|
841
|
+
//
|
|
842
|
+
// 1. Discovered local Qwen models. `discoverOpenAICompatibleModels`
|
|
843
|
+
// stamps `reasoning: false` on every spec built from a generic
|
|
844
|
+
// `/v1/models` endpoint (the upstream doesn't advertise the
|
|
845
|
+
// capability), so `model.reasoning === false` → `reasoning.enabled
|
|
846
|
+
// === false`, the body wouldn't otherwise see the kwarg, and the
|
|
847
|
+
// encoder's `replayReasoningContent` branch would keep shipping
|
|
848
|
+
// `reasoning_content` only for the template to strip `<think>` from
|
|
849
|
+
// older turns anyway. Exactly the #3528 / #3541 symptom on every
|
|
850
|
+
// discovered Qwen build.
|
|
851
|
+
// 2. Caller-disabled reasoning. The slot's KV cache still holds prior
|
|
852
|
+
// `<think>...</think>` tokens from earlier thinking turns; the
|
|
853
|
+
// template must keep rendering them or cache invalidates at the
|
|
854
|
+
// first historic `<think>`.
|
|
855
|
+
// 3. Forced-tool-choice / DeepSeek-style auto-disable. Same reasoning
|
|
856
|
+
// as (2) — historic thinking blocks have to survive history replay
|
|
857
|
+
// even when the current turn cannot think.
|
|
858
|
+
//
|
|
859
|
+
// Non-Qwen templates ignore the parameter (jinja `is defined` check
|
|
860
|
+
// silently no-ops), so emitting it unconditionally for the Qwen-family
|
|
861
|
+
// + local-cache compat flag is safe.
|
|
862
|
+
if (policy.compat.qwenPreserveThinking) {
|
|
863
|
+
// Mirror the dialect split that gates `enable_thinking`. The
|
|
864
|
+
// `qwen` dialect rides the top-level field (the only place
|
|
865
|
+
// llama.cpp's `--jinja` hook AND Alibaba Cloud Model Studio's
|
|
866
|
+
// compatible-mode look) while the `qwen-chat-template` dialect
|
|
867
|
+
// (NVIDIA NIM, vLLM/SGLang's chat-template-kwargs path) MUST
|
|
868
|
+
// ride only the kwargs copy — NIM's request schema is
|
|
869
|
+
// `additionalProperties: false` and rejects every unknown
|
|
870
|
+
// top-level field, the very reason `enable_thinking` is
|
|
871
|
+
// route-split this way (#2299, see `catalog/src/compat/openai.ts`
|
|
872
|
+
// thinkingFormat comment).
|
|
873
|
+
if (policy.compat.thinkingFormat === "qwen") {
|
|
874
|
+
params.preserve_thinking = true;
|
|
875
|
+
}
|
|
876
|
+
params.chat_template_kwargs = { ...params.chat_template_kwargs, preserve_thinking: true };
|
|
877
|
+
}
|
|
878
|
+
|
|
816
879
|
const reasoning = policy.reasoning;
|
|
817
880
|
if ((!reasoning.modelSupported && !reasoning.disabled) || !reasoning.supportsParams) return;
|
|
818
881
|
if (reasoning.enabled) {
|
|
@@ -832,7 +895,10 @@ export function applyChatCompletionsCompatPolicy(params: OpenAICompletionsParams
|
|
|
832
895
|
params.enable_thinking = true;
|
|
833
896
|
break;
|
|
834
897
|
case "qwen-template-false":
|
|
835
|
-
|
|
898
|
+
// Spread so the `preserve_thinking` kwarg hoisted above
|
|
899
|
+
// survives the merge — a bare `{ enable_thinking: true }`
|
|
900
|
+
// would clobber it.
|
|
901
|
+
params.chat_template_kwargs = { ...params.chat_template_kwargs, enable_thinking: true };
|
|
836
902
|
break;
|
|
837
903
|
case "openrouter-enabled-false":
|
|
838
904
|
if (reasoning.wireEffort !== undefined) {
|
|
@@ -31,6 +31,7 @@ type ModelsEndpointValidation = {
|
|
|
31
31
|
kind: "models-endpoint";
|
|
32
32
|
provider: string;
|
|
33
33
|
modelsUrl: string;
|
|
34
|
+
headers?: Record<string, string> | (() => Record<string, string> | undefined);
|
|
34
35
|
};
|
|
35
36
|
|
|
36
37
|
export type ApiKeyLoginConfig = {
|
|
@@ -98,6 +99,7 @@ export function createApiKeyLogin(config: ApiKeyLoginConfig): (options: OAuthCon
|
|
|
98
99
|
provider: config.validation.provider,
|
|
99
100
|
apiKey: trimmed,
|
|
100
101
|
modelsUrl: config.validation.modelsUrl,
|
|
102
|
+
headers: config.validation.headers,
|
|
101
103
|
signal: options.signal,
|
|
102
104
|
fetch: options.fetch,
|
|
103
105
|
});
|
|
@@ -21,6 +21,7 @@ type ModelListValidationOptions = {
|
|
|
21
21
|
provider: string;
|
|
22
22
|
apiKey: string;
|
|
23
23
|
modelsUrl: string;
|
|
24
|
+
headers?: Record<string, string> | (() => Record<string, string> | undefined);
|
|
24
25
|
signal?: AbortSignal;
|
|
25
26
|
fetch?: FetchImpl;
|
|
26
27
|
};
|
|
@@ -32,6 +33,12 @@ function normalizeAnthropicCompatibleBaseUrl(baseUrl: string): string {
|
|
|
32
33
|
return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
function resolveValidationHeaders(
|
|
37
|
+
headers: Record<string, string> | (() => Record<string, string> | undefined) | undefined,
|
|
38
|
+
): Record<string, string> | undefined {
|
|
39
|
+
return typeof headers === "function" ? headers() : headers;
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
/**
|
|
36
43
|
* Validate an API key against an OpenAI-compatible chat completions endpoint.
|
|
37
44
|
*
|
|
@@ -129,6 +136,7 @@ export async function validateApiKeyAgainstModelsEndpoint(options: ModelListVali
|
|
|
129
136
|
const response = await fetchImpl(options.modelsUrl, {
|
|
130
137
|
method: "GET",
|
|
131
138
|
headers: {
|
|
139
|
+
...(resolveValidationHeaders(options.headers) ?? {}),
|
|
132
140
|
Authorization: `Bearer ${options.apiKey}`,
|
|
133
141
|
},
|
|
134
142
|
signal,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { coreWeaveProjectHeaders } from "@oh-my-pi/pi-catalog/wire/coreweave";
|
|
2
|
+
import { $env } from "@oh-my-pi/pi-utils";
|
|
3
|
+
import { createApiKeyLogin } from "./api-key-login";
|
|
4
|
+
import type { OAuthLoginCallbacks } from "./oauth/types";
|
|
5
|
+
import type { ProviderDefinition } from "./types";
|
|
6
|
+
|
|
7
|
+
const PROJECT_SETUP_INSTRUCTIONS =
|
|
8
|
+
"Create or select a CoreWeave Serverless Inference project, set COREWEAVE_PROJECT=<team>/<project> for the OpenAI-Project header, then copy your API key from account settings";
|
|
9
|
+
|
|
10
|
+
function requireCoreWeaveProjectHeaders(): Record<string, string> {
|
|
11
|
+
const headers = coreWeaveProjectHeaders($env);
|
|
12
|
+
if (!headers) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
"CoreWeave Serverless Inference requires OpenAI-Project. Set COREWEAVE_PROJECT=<team>/<project> before running /login coreweave.",
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return headers;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const loginCoreWeave = createApiKeyLogin({
|
|
21
|
+
providerLabel: "CoreWeave Serverless Inference",
|
|
22
|
+
authUrl: "https://wandb.ai/settings",
|
|
23
|
+
instructions: PROJECT_SETUP_INSTRUCTIONS,
|
|
24
|
+
promptMessage: "Paste your CoreWeave Serverless Inference API key",
|
|
25
|
+
placeholder: "api-key",
|
|
26
|
+
validation: {
|
|
27
|
+
kind: "models-endpoint",
|
|
28
|
+
provider: "CoreWeave Serverless Inference",
|
|
29
|
+
modelsUrl: "https://api.inference.wandb.ai/v1/models",
|
|
30
|
+
headers: requireCoreWeaveProjectHeaders,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const coreWeaveProvider = {
|
|
35
|
+
id: "coreweave",
|
|
36
|
+
name: "CoreWeave Serverless Inference",
|
|
37
|
+
login: (cb: OAuthLoginCallbacks) => loginCoreWeave(cb),
|
|
38
|
+
} as const satisfies ProviderDefinition;
|
|
@@ -3,71 +3,150 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<title>
|
|
6
|
+
<title>oh my pi · authentication</title>
|
|
7
7
|
<style>
|
|
8
8
|
:root {
|
|
9
|
-
|
|
10
|
-
--
|
|
11
|
-
--
|
|
12
|
-
--
|
|
13
|
-
--
|
|
14
|
-
--
|
|
15
|
-
--
|
|
9
|
+
/* dark, slightly purple-tinted neutrals — never #000 */
|
|
10
|
+
--ink-0: oklch(0.135 0.018 295);
|
|
11
|
+
--ink-1: oklch(0.172 0.022 295);
|
|
12
|
+
--ink-2: oklch(0.21 0.024 295);
|
|
13
|
+
--ink-4: oklch(0.36 0.024 295);
|
|
14
|
+
--ink-5: oklch(0.5 0.022 290);
|
|
15
|
+
--ink-6: oklch(0.66 0.018 290);
|
|
16
|
+
--ink-7: oklch(0.82 0.014 285);
|
|
17
|
+
--ink-9: oklch(0.97 0.008 90);
|
|
18
|
+
|
|
19
|
+
/* brand: magenta lead, cyan reply */
|
|
20
|
+
--magenta: oklch(0.7 0.24 340);
|
|
21
|
+
--iris: oklch(0.62 0.21 295);
|
|
22
|
+
--cyan: oklch(0.81 0.14 200);
|
|
23
|
+
|
|
24
|
+
/* signal */
|
|
25
|
+
--success: oklch(0.78 0.16 150);
|
|
26
|
+
--error: oklch(0.66 0.22 25);
|
|
27
|
+
|
|
28
|
+
--bg: var(--ink-0);
|
|
29
|
+
--fg: var(--ink-9);
|
|
30
|
+
--fg-dim: var(--ink-7);
|
|
31
|
+
--fg-faint: var(--ink-5);
|
|
32
|
+
--rule: oklch(1 0 0 / 0.07);
|
|
33
|
+
--rule-strong: oklch(1 0 0 / 0.14);
|
|
34
|
+
--accent: var(--magenta);
|
|
35
|
+
--accent-2: var(--cyan);
|
|
36
|
+
|
|
37
|
+
color-scheme: dark;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
* {
|
|
41
|
+
box-sizing: border-box;
|
|
16
42
|
}
|
|
43
|
+
|
|
17
44
|
body {
|
|
18
|
-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
19
|
-
background
|
|
20
|
-
|
|
45
|
+
font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
46
|
+
background:
|
|
47
|
+
radial-gradient(ellipse 80vw 60vh at 18% -10%, oklch(0.7 0.24 340 / 0.13), transparent 60%),
|
|
48
|
+
radial-gradient(ellipse 70vw 70vh at 110% 30%, oklch(0.81 0.14 200 / 0.09), transparent 60%),
|
|
49
|
+
var(--bg);
|
|
50
|
+
color: var(--fg);
|
|
21
51
|
display: flex;
|
|
22
52
|
align-items: center;
|
|
23
53
|
justify-content: center;
|
|
24
|
-
height: 100vh;
|
|
54
|
+
min-height: 100vh;
|
|
25
55
|
margin: 0;
|
|
26
|
-
|
|
56
|
+
padding: 1.5rem;
|
|
57
|
+
-webkit-font-smoothing: antialiased;
|
|
58
|
+
text-rendering: optimizeLegibility;
|
|
59
|
+
overflow-x: clip;
|
|
27
60
|
}
|
|
61
|
+
|
|
62
|
+
/* ascii grid backdrop */
|
|
63
|
+
.grid-bg {
|
|
64
|
+
position: fixed;
|
|
65
|
+
inset: 0;
|
|
66
|
+
background-image:
|
|
67
|
+
linear-gradient(to right, oklch(1 0 0 / 0.025) 1px, transparent 1px),
|
|
68
|
+
linear-gradient(to bottom, oklch(1 0 0 / 0.025) 1px, transparent 1px);
|
|
69
|
+
background-size: 56px 56px;
|
|
70
|
+
background-position: -1px -1px;
|
|
71
|
+
mask-image: radial-gradient(ellipse at 50% 30%, #000 30%, transparent 75%);
|
|
72
|
+
pointer-events: none;
|
|
73
|
+
z-index: 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
28
76
|
.container {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
77
|
+
position: relative;
|
|
78
|
+
z-index: 1;
|
|
79
|
+
background: oklch(0.172 0.022 295 / 0.72);
|
|
80
|
+
backdrop-filter: blur(16px);
|
|
81
|
+
-webkit-backdrop-filter: blur(16px);
|
|
82
|
+
border: 1px solid var(--rule);
|
|
83
|
+
border-radius: 16px;
|
|
84
|
+
padding: 2.5rem 2.5rem 2rem;
|
|
33
85
|
width: 100%;
|
|
34
|
-
max-width:
|
|
86
|
+
max-width: 420px;
|
|
35
87
|
text-align: center;
|
|
36
88
|
box-shadow:
|
|
37
|
-
0
|
|
38
|
-
0
|
|
89
|
+
0 24px 48px -12px oklch(0 0 0 / 0.55),
|
|
90
|
+
0 0 0 1px oklch(1 0 0 / 0.03) inset;
|
|
39
91
|
opacity: 0;
|
|
40
92
|
transform: translateY(10px);
|
|
41
|
-
animation:
|
|
93
|
+
animation: rise 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
42
94
|
}
|
|
43
|
-
@keyframes
|
|
95
|
+
@keyframes rise {
|
|
44
96
|
to {
|
|
45
97
|
opacity: 1;
|
|
46
|
-
transform:
|
|
98
|
+
transform: none;
|
|
47
99
|
}
|
|
48
100
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
height: 64px;
|
|
53
|
-
border-radius: 50%;
|
|
101
|
+
|
|
102
|
+
/* brand header */
|
|
103
|
+
.brand {
|
|
54
104
|
display: flex;
|
|
55
105
|
align-items: center;
|
|
56
106
|
justify-content: center;
|
|
107
|
+
gap: 0.5rem;
|
|
108
|
+
margin-bottom: 2rem;
|
|
109
|
+
}
|
|
110
|
+
.brand svg {
|
|
111
|
+
width: 22px;
|
|
112
|
+
height: 22px;
|
|
113
|
+
}
|
|
114
|
+
.brand .wordmark {
|
|
115
|
+
font-size: 13px;
|
|
116
|
+
font-weight: 500;
|
|
117
|
+
letter-spacing: -0.01em;
|
|
118
|
+
color: var(--fg);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* status mark */
|
|
122
|
+
.mark-wrap {
|
|
123
|
+
position: relative;
|
|
124
|
+
width: 72px;
|
|
125
|
+
height: 72px;
|
|
57
126
|
margin: 0 auto 1.5rem;
|
|
58
|
-
|
|
127
|
+
display: flex;
|
|
128
|
+
align-items: center;
|
|
129
|
+
justify-content: center;
|
|
59
130
|
}
|
|
60
|
-
.
|
|
61
|
-
|
|
62
|
-
|
|
131
|
+
.mark-wrap::before {
|
|
132
|
+
content: "";
|
|
133
|
+
position: absolute;
|
|
134
|
+
inset: 0;
|
|
135
|
+
border-radius: 50%;
|
|
136
|
+
background: oklch(1 0 0 / 0.05);
|
|
137
|
+
z-index: 0;
|
|
138
|
+
}
|
|
139
|
+
.mark {
|
|
140
|
+
width: 40px;
|
|
141
|
+
height: 40px;
|
|
63
142
|
z-index: 2;
|
|
64
143
|
}
|
|
65
144
|
.timer-svg {
|
|
66
145
|
position: absolute;
|
|
67
146
|
top: -2px;
|
|
68
147
|
left: -2px;
|
|
69
|
-
width:
|
|
70
|
-
height:
|
|
148
|
+
width: 76px;
|
|
149
|
+
height: 76px;
|
|
71
150
|
transform: rotate(-90deg);
|
|
72
151
|
z-index: 1;
|
|
73
152
|
pointer-events: none;
|
|
@@ -76,7 +155,7 @@
|
|
|
76
155
|
fill: none;
|
|
77
156
|
stroke-width: 2;
|
|
78
157
|
stroke-linecap: round;
|
|
79
|
-
stroke-dasharray:
|
|
158
|
+
stroke-dasharray: 226;
|
|
80
159
|
stroke-dashoffset: 0;
|
|
81
160
|
}
|
|
82
161
|
.countdown .timer-circle {
|
|
@@ -84,43 +163,55 @@
|
|
|
84
163
|
}
|
|
85
164
|
@keyframes countdown {
|
|
86
165
|
to {
|
|
87
|
-
stroke-dashoffset:
|
|
166
|
+
stroke-dashoffset: 226;
|
|
88
167
|
}
|
|
89
168
|
}
|
|
169
|
+
|
|
90
170
|
h1 {
|
|
91
|
-
font-
|
|
92
|
-
font-
|
|
171
|
+
font-family: "Fraunces", "Iowan Old Style", Georgia, serif;
|
|
172
|
+
font-size: 1.6rem;
|
|
173
|
+
font-weight: 500;
|
|
174
|
+
letter-spacing: -0.022em;
|
|
175
|
+
line-height: 1.15;
|
|
93
176
|
margin: 0 0 0.75rem;
|
|
94
|
-
|
|
177
|
+
text-wrap: balance;
|
|
95
178
|
}
|
|
96
179
|
p {
|
|
97
|
-
color: var(--
|
|
98
|
-
line-height: 1.
|
|
99
|
-
margin: 0 0 1.
|
|
180
|
+
color: var(--fg-dim);
|
|
181
|
+
line-height: 1.6;
|
|
182
|
+
margin: 0 0 1.75rem;
|
|
100
183
|
font-size: 0.95rem;
|
|
101
184
|
}
|
|
185
|
+
|
|
102
186
|
.btn {
|
|
103
|
-
display: inline-
|
|
104
|
-
|
|
187
|
+
display: inline-flex;
|
|
188
|
+
align-items: center;
|
|
189
|
+
gap: 0.5rem;
|
|
190
|
+
background: var(--fg);
|
|
105
191
|
color: var(--bg);
|
|
192
|
+
font-family: inherit;
|
|
106
193
|
font-weight: 500;
|
|
107
|
-
padding: 0.
|
|
108
|
-
border-radius:
|
|
194
|
+
padding: 0.65rem 1.4rem;
|
|
195
|
+
border-radius: 8px;
|
|
109
196
|
text-decoration: none;
|
|
110
197
|
font-size: 0.9rem;
|
|
111
|
-
transition: opacity 0.2s;
|
|
198
|
+
transition: opacity 0.2s, transform 0.2s;
|
|
112
199
|
cursor: pointer;
|
|
113
200
|
border: none;
|
|
114
201
|
}
|
|
115
202
|
.btn:hover {
|
|
116
|
-
opacity: 0.
|
|
203
|
+
opacity: 0.88;
|
|
204
|
+
transform: translateY(-1px);
|
|
205
|
+
}
|
|
206
|
+
.btn:active {
|
|
207
|
+
transform: translateY(0);
|
|
117
208
|
}
|
|
118
209
|
|
|
119
210
|
/* State: success */
|
|
120
|
-
.success .
|
|
121
|
-
background:
|
|
211
|
+
.success .mark-wrap::before {
|
|
212
|
+
background: oklch(0.78 0.16 150 / 0.12);
|
|
122
213
|
}
|
|
123
|
-
.success .
|
|
214
|
+
.success .mark {
|
|
124
215
|
color: var(--success);
|
|
125
216
|
}
|
|
126
217
|
.success .timer-circle {
|
|
@@ -131,10 +222,10 @@
|
|
|
131
222
|
}
|
|
132
223
|
|
|
133
224
|
/* State: error */
|
|
134
|
-
.error .
|
|
135
|
-
background:
|
|
225
|
+
.error .mark-wrap::before {
|
|
226
|
+
background: oklch(0.66 0.22 25 / 0.12);
|
|
136
227
|
}
|
|
137
|
-
.error .
|
|
228
|
+
.error .mark {
|
|
138
229
|
color: var(--error);
|
|
139
230
|
}
|
|
140
231
|
.error .timer-circle {
|
|
@@ -144,21 +235,48 @@
|
|
|
144
235
|
.error .timer-svg {
|
|
145
236
|
display: none;
|
|
146
237
|
}
|
|
238
|
+
|
|
239
|
+
@media (prefers-reduced-motion: reduce) {
|
|
240
|
+
.container,
|
|
241
|
+
.countdown .timer-circle {
|
|
242
|
+
animation: none;
|
|
243
|
+
}
|
|
244
|
+
.container {
|
|
245
|
+
opacity: 1;
|
|
246
|
+
transform: none;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
147
249
|
</style>
|
|
148
250
|
</head>
|
|
149
251
|
<body>
|
|
252
|
+
<span aria-hidden="true" class="grid-bg"></span>
|
|
150
253
|
<div id="app" class="container">
|
|
151
|
-
<div class="
|
|
152
|
-
<svg
|
|
153
|
-
<
|
|
254
|
+
<div class="brand">
|
|
255
|
+
<svg viewBox="0 0 64 64" aria-hidden="true">
|
|
256
|
+
<defs>
|
|
257
|
+
<linearGradient id="pi-grad" x1="0" y1="0" x2="1" y2="1">
|
|
258
|
+
<stop offset="0" stop-color="oklch(0.7 0.24 340)" />
|
|
259
|
+
<stop offset=".5" stop-color="oklch(0.62 0.21 295)" />
|
|
260
|
+
<stop offset="1" stop-color="oklch(0.81 0.14 200)" />
|
|
261
|
+
</linearGradient>
|
|
262
|
+
</defs>
|
|
263
|
+
<path fill="url(#pi-grad)" d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
|
|
154
264
|
</svg>
|
|
155
|
-
<
|
|
265
|
+
<span class="wordmark">oh my pi</span>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<div class="mark-wrap">
|
|
269
|
+
<svg class="timer-svg" viewBox="0 0 76 76">
|
|
270
|
+
<circle class="timer-circle" cx="38" cy="38" r="36"></circle>
|
|
271
|
+
</svg>
|
|
272
|
+
<svg class="mark icon-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
156
273
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
157
274
|
</svg>
|
|
158
|
-
<svg class="
|
|
275
|
+
<svg class="mark icon-error" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
159
276
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
160
277
|
</svg>
|
|
161
278
|
</div>
|
|
279
|
+
|
|
162
280
|
<h1 id="title">Authentication</h1>
|
|
163
281
|
<p id="message"></p>
|
|
164
282
|
<button onclick="window.close()" class="btn">Close Window</button>
|
package/src/registry/registry.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { anthropicProvider } from "./anthropic";
|
|
|
6
6
|
import { azureProvider } from "./azure";
|
|
7
7
|
import { cerebrasProvider } from "./cerebras";
|
|
8
8
|
import { cloudflareAiGatewayProvider } from "./cloudflare-ai-gateway";
|
|
9
|
+
import { coreWeaveProvider } from "./coreweave";
|
|
9
10
|
import { cursorProvider } from "./cursor";
|
|
10
11
|
import { deepseekProvider } from "./deepseek";
|
|
11
12
|
import { devinProvider } from "./devin";
|
|
@@ -112,6 +113,7 @@ const ALL = [
|
|
|
112
113
|
syntheticProvider,
|
|
113
114
|
nanogptProvider,
|
|
114
115
|
waferServerlessProvider,
|
|
116
|
+
coreWeaveProvider,
|
|
115
117
|
vercelAiGatewayProvider,
|
|
116
118
|
cloudflareAiGatewayProvider,
|
|
117
119
|
litellmProvider,
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* replay-unsafe and is never retried), so the turn is discarded and re-sampled
|
|
17
17
|
* instead of committing the garbage transcript.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
19
|
+
* Three failure shapes are detected:
|
|
20
20
|
* 1. **Verbatim tail repetition** — a short unit repeated back-to-back (e.g.
|
|
21
21
|
* "🌊 🌊 🌊 …"). Caught from a rolling 250-char tail.
|
|
22
22
|
* 2. **Near-duplicate segments** — paragraphs that normalize to the same
|
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
* paragraphs. Thresholds were calibrated on a real loop transcript plus
|
|
25
25
|
* 13.5k non-loop thinking blocks (zero false positives; hardest negative
|
|
26
26
|
* scored 3 against the trigger of 4).
|
|
27
|
+
* 3. **Progress-lexicon stall** — paragraphs that keep reshuffling the same
|
|
28
|
+
* motivational filler ("just doing it, pushing ahead, maintaining momentum")
|
|
29
|
+
* into fresh word order, so trigrams never match, yet introduce no new
|
|
30
|
+
* vocabulary and name nothing concrete. Caught by a run of low-novelty,
|
|
31
|
+
* anchor-free segments; a segment naming a path/identifier resets the run, so
|
|
32
|
+
* genuine but vocabulary-repetitive work (per-file templates) is spared.
|
|
27
33
|
*
|
|
28
34
|
* Scope is narrow: guarded Gemini/DeepSeek streams before any tool call. Native
|
|
29
35
|
* thinking is checked first; assistant text can also be checked for providers
|
|
@@ -63,6 +69,32 @@ const SEGMENT_MIN_COUNT = 8;
|
|
|
63
69
|
/** Near-duplicate cluster size (current + matches) that trips the loop. */
|
|
64
70
|
const SEGMENT_MIN_CLUSTER = 4;
|
|
65
71
|
|
|
72
|
+
/** Recent segments whose pooled unigram vocabulary is the novelty baseline for
|
|
73
|
+
* progress-lexicon stall detection. */
|
|
74
|
+
const LEX_NOVELTY_WINDOW = 8;
|
|
75
|
+
/** Novelty (fraction of a segment's content words unseen across the recent
|
|
76
|
+
* window) at/below which a segment counts as recycling earlier wording.
|
|
77
|
+
* Calibrated against 536k real non-Gemini reasoning blocks: at 0.2 the longest
|
|
78
|
+
* low-information run any legitimate block reached was 7. */
|
|
79
|
+
const LEX_STALL_NOVELTY_FLOOR = 0.2;
|
|
80
|
+
/** Consecutive low-information segments that trip a progress-lexicon stall. Set
|
|
81
|
+
* to 8 (one above the worst legitimate run observed in the 536k-block corpus) so
|
|
82
|
+
* the heuristic stays clear of focused reasoning that briefly recycles wording;
|
|
83
|
+
* the real reasoning-summarizer loop sustains far longer runs (10+). */
|
|
84
|
+
const LEX_STALL_MIN_RUN = 8;
|
|
85
|
+
|
|
86
|
+
/** A concrete reference the model is actually reasoning about: a code span, a
|
|
87
|
+
* file extension / dotted member, a multi-segment path, or a snake/camel/Pascal
|
|
88
|
+
* identifier. A segment that introduces a NEW one resets the lexical-stall run —
|
|
89
|
+
* this spares genuine per-target work (per-file templates, focused single-symbol
|
|
90
|
+
* debugging) while still catching reworded filler that names nothing new ("just
|
|
91
|
+
* doing it, pushing ahead") or fixates on one unchanging reference. Excludes bare
|
|
92
|
+
* digits, abbreviations, and decimals (e.g. "Step 2", "i.e.", "1.2") so numbered
|
|
93
|
+
* or punctuated filler is not self-anchoring. Global flag: collected with
|
|
94
|
+
* matchAll, so never used with the stateful test(). */
|
|
95
|
+
const CONCRETE_ANCHOR =
|
|
96
|
+
/`[^`]+`|\b\w{2,}\.[a-zA-Z]\w{0,4}\b|[\w-]+(?:\/[\w-]+){2,}|\b\w+_\w+\b|\b[a-z]+[A-Z]\w*\b|\b[A-Z][a-z]+[A-Z]\w*\b/g;
|
|
97
|
+
|
|
66
98
|
const OPENAI_COMPAT_GUARDED_APIS: Partial<Record<Api, true>> = {
|
|
67
99
|
"openai-completions": true,
|
|
68
100
|
"openai-responses": true,
|
|
@@ -113,6 +145,15 @@ export class ThinkingLoopDetector {
|
|
|
113
145
|
#window: Set<string>[] = [];
|
|
114
146
|
/** Count of substantial segments seen so far (warm-up gate). */
|
|
115
147
|
#count = 0;
|
|
148
|
+
/** Unigram word sets of the most recent segments (≤ LEX_NOVELTY_WINDOW); the
|
|
149
|
+
* novelty baseline for progress-lexicon stall detection. */
|
|
150
|
+
#wordWindow: Set<string>[] = [];
|
|
151
|
+
/** Consecutive low-information (low-novelty, anchor-free) segments seen. */
|
|
152
|
+
#lexStallRun = 0;
|
|
153
|
+
/** Concrete anchors seen per recent segment (≤ LEX_NOVELTY_WINDOW). A stall is
|
|
154
|
+
* only broken by a *new* reference, so filler repeating one fixed
|
|
155
|
+
* path/identifier every paragraph is still caught. */
|
|
156
|
+
#anchorWindow: Set<string>[] = [];
|
|
116
157
|
|
|
117
158
|
push(delta: string): string | null {
|
|
118
159
|
if (!delta) return null;
|
|
@@ -167,22 +208,71 @@ export class ThinkingLoopDetector {
|
|
|
167
208
|
return null;
|
|
168
209
|
}
|
|
169
210
|
|
|
170
|
-
#consumeSegment(
|
|
211
|
+
#consumeSegment(raw: string): string | null {
|
|
212
|
+
// Reasoning-summarizer titles ("**Maintaining Momentum**", "## Heading")
|
|
213
|
+
// are per-thought formatting, not chain-of-thought; their ever-changing
|
|
214
|
+
// wording would otherwise mask a loop by inflating novelty. Strip them
|
|
215
|
+
// before analysis (a title-only segment then falls below the length gate).
|
|
216
|
+
const segment = raw.replace(/^[ \t]*#{1,6}[ \t].*$/gm, "").replace(/^[ \t]*\*{2,3}.+?\*{2,3}[ \t]*$/gm, "");
|
|
171
217
|
const normalized = normalizeSegment(segment);
|
|
172
218
|
if (normalized.length < SEGMENT_MIN_NORM_CHARS) return null;
|
|
173
219
|
|
|
220
|
+
// (a) Near-duplicate trigram cluster: the same paragraph reused with
|
|
221
|
+
// cosmetic wording drift (high word-trigram overlap).
|
|
174
222
|
const fingerprint = trigramShingles(normalized);
|
|
175
223
|
let cluster = 1;
|
|
176
224
|
for (const prev of this.#window) {
|
|
177
225
|
if (jaccard(fingerprint, prev) >= SEGMENT_SIMILARITY) cluster++;
|
|
178
226
|
}
|
|
179
227
|
|
|
228
|
+
// (b) Progress-lexicon stall: paragraphs that recycle the recent
|
|
229
|
+
// vocabulary (low novelty) and add no *new* concrete reference — reworded
|
|
230
|
+
// filler that burns budget without advancing. The trigram check above
|
|
231
|
+
// already claims high-overlap near-duplicates; this catches the
|
|
232
|
+
// low-overlap, reshuffled-wording shape it misses. Requiring a NEW anchor
|
|
233
|
+
// (not merely any anchor) still catches filler that name-drops one fixed
|
|
234
|
+
// path/identifier every paragraph, while sparing genuine per-target work
|
|
235
|
+
// that names a fresh file/symbol each time.
|
|
236
|
+
const words = new Set<string>(normalized.split(" ").filter(Boolean));
|
|
237
|
+
const priorVocab = new Set<string>();
|
|
238
|
+
for (const set of this.#wordWindow) for (const w of set) priorVocab.add(w);
|
|
239
|
+
let unseen = 0;
|
|
240
|
+
for (const w of words) if (!priorVocab.has(w)) unseen++;
|
|
241
|
+
const novelty = priorVocab.size === 0 ? 1 : unseen / words.size;
|
|
242
|
+
|
|
243
|
+
const anchors = new Set<string>();
|
|
244
|
+
// Canonicalize so the same reference written as `Foo`, Foo, or FOO is one
|
|
245
|
+
// anchor and cannot masquerade as "new" to keep a fixed-reference stall alive.
|
|
246
|
+
for (const match of segment.matchAll(CONCRETE_ANCHOR)) anchors.add(match[0].replace(/`/g, "").toLowerCase());
|
|
247
|
+
let newAnchor = false;
|
|
248
|
+
for (const anchor of anchors) {
|
|
249
|
+
if (this.#anchorWindow.every(seen => !seen.has(anchor))) {
|
|
250
|
+
newAnchor = true;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (novelty <= LEX_STALL_NOVELTY_FLOOR && !newAnchor) {
|
|
256
|
+
this.#lexStallRun++;
|
|
257
|
+
} else {
|
|
258
|
+
this.#lexStallRun = 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
180
261
|
this.#window.push(fingerprint);
|
|
181
262
|
if (this.#window.length > SEGMENT_WINDOW) this.#window.shift();
|
|
263
|
+
this.#wordWindow.push(words);
|
|
264
|
+
if (this.#wordWindow.length > LEX_NOVELTY_WINDOW) this.#wordWindow.shift();
|
|
265
|
+
this.#anchorWindow.push(anchors);
|
|
266
|
+
if (this.#anchorWindow.length > LEX_NOVELTY_WINDOW) this.#anchorWindow.shift();
|
|
182
267
|
this.#count++;
|
|
183
268
|
|
|
184
|
-
if (this.#count >= SEGMENT_MIN_COUNT
|
|
185
|
-
|
|
269
|
+
if (this.#count >= SEGMENT_MIN_COUNT) {
|
|
270
|
+
if (cluster >= SEGMENT_MIN_CLUSTER) {
|
|
271
|
+
return `${cluster} near-identical segments within the last ${SEGMENT_WINDOW}`;
|
|
272
|
+
}
|
|
273
|
+
if (this.#lexStallRun >= LEX_STALL_MIN_RUN) {
|
|
274
|
+
return `${this.#lexStallRun} low-information segments recycling recent wording`;
|
|
275
|
+
}
|
|
186
276
|
}
|
|
187
277
|
return null;
|
|
188
278
|
}
|