vibeostheog 0.22.12 → 0.22.15
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 +19 -0
- package/package.json +1 -1
- package/src/lib/api-client.js +6 -0
- package/src/lib/hooks/footer.js +19 -14
- package/src/lib/hooks/tool-execute.js +7 -2
- package/src/lib/state.js +61 -16
- package/src/vibeOS-lib/blackbox/local-stub.js +32 -8
- package/src/vibeOS-lib/blackbox/meta-controller.js +2 -1
- package/src/vibeOS-lib/blackbox/resolution-tracker.js +35 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
## 0.22.15
|
|
2
|
+
|
|
3
|
+
- fix: prefer session cache over global cache in getScratchpadHit()
|
|
4
|
+
swap lookup order on direct-hash and pointer-resolved paths
|
|
5
|
+
so session-scoped outputs always take priority
|
|
6
|
+
- fix: remove user-wide cache fallback from getScratchpadHit()
|
|
7
|
+
no more SCRATCHPAD_GLOBAL_DIR — cache scope is session only
|
|
8
|
+
(callers may pass project-scoped baseDir for project-level cache)
|
|
9
|
+
- fix: setApiToken() now resets _apiFallbackMode / _apiClient / runtime connection state
|
|
10
|
+
so a token update breaks out of permanent API-fallback deadlock
|
|
11
|
+
- fix: syncApiTokenFromDisk() else branch also clears fallback state
|
|
12
|
+
- test: add regression tests for fallback-mode reset behavior
|
|
13
|
+
|
|
14
|
+
## 0.22.14
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## 0.22.13
|
|
18
|
+
|
|
19
|
+
|
|
1
20
|
## 0.22.12
|
|
2
21
|
- fix: harden scratchpad cache
|
|
3
22
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeostheog",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.15",
|
|
4
4
|
"description": "Cost-aware delegation enforcer for OpenCode. Tracks model usage, routes Task subagents to cheaper tiers, surfaces cumulative savings in chat. Includes research audit, reporting framework, project memory, progressive scratchpad decadence, and trinity CLI for brain/medium/cheap slot switching.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"release": "node scripts/release.mjs",
|
package/src/lib/api-client.js
CHANGED
|
@@ -535,6 +535,10 @@ export function setApiToken(newToken) {
|
|
|
535
535
|
persistPrimaryApiEnvState({ token: VIBEOS_API_TOKEN, disabled: false });
|
|
536
536
|
if (_anomalyDetector)
|
|
537
537
|
_anomalyDetector.reset();
|
|
538
|
+
_apiClient = null;
|
|
539
|
+
_apiFallbackMode = false;
|
|
540
|
+
_apiFallbackSince = null;
|
|
541
|
+
resetApiConnection();
|
|
538
542
|
console.error("[vibeOS] API token updated via setApiToken");
|
|
539
543
|
}
|
|
540
544
|
catch (e) {
|
|
@@ -671,6 +675,8 @@ function syncApiTokenFromDisk() {
|
|
|
671
675
|
VIBEOS_API_DISABLED = false;
|
|
672
676
|
VIBEOS_API_TOKEN ||= EMBEDDED_API_TOKEN;
|
|
673
677
|
VIBEOS_API_ENABLED = process.env.VIBEOS_API_ENABLED !== "false" && (!!VIBEOS_API_TOKEN || !!VIBEOS_API_BOOTSTRAP_TOKEN);
|
|
678
|
+
_apiFallbackMode = false;
|
|
679
|
+
_apiFallbackSince = null;
|
|
674
680
|
}
|
|
675
681
|
}
|
|
676
682
|
export function getApiClient() {
|
package/src/lib/hooks/footer.js
CHANGED
|
@@ -201,7 +201,12 @@ async function _appendFooter(input, output, directory) {
|
|
|
201
201
|
liveModel = readConfig(directory) || readConfig(join(process.env.HOME || "", ".config", "opencode")) || process?.env?.OPENCODE_MODEL || "";
|
|
202
202
|
}
|
|
203
203
|
const displayModel = resolveDisplayModelId(liveModel || brainModel || currentModel || "", directory) || liveModel || brainModel || currentModel;
|
|
204
|
-
const
|
|
204
|
+
const resolvedModel = displayModel || liveModel || brainModel || currentModel || "";
|
|
205
|
+
if (resolvedModel && resolvedModel !== currentModel) {
|
|
206
|
+
setCurrentModel(resolvedModel);
|
|
207
|
+
setCurrentTier(classify(resolvedModel));
|
|
208
|
+
}
|
|
209
|
+
const execution = resolveExecutionIdentity(input?.args?.model || resolvedModel || "", directory);
|
|
205
210
|
let modelTag = `[${shortModelName(displayModel)}]`;
|
|
206
211
|
const _workerModel = slot === "brain" ? TRINITY_MEDIUM : null;
|
|
207
212
|
const totalTurns = (sesModelTurns?.brain || 0) + (sesModelTurns?.worker || 0);
|
|
@@ -215,21 +220,21 @@ async function _appendFooter(input, output, directory) {
|
|
|
215
220
|
saveReport({
|
|
216
221
|
type: "session",
|
|
217
222
|
summary: "Session cost: $" + formatUsd(ltCost) + " | cache saved: $" + formatUsd(ltCache) + " | delegation saved: $" + formatUsd(Number(sesTasks || 0)) + " | task delegations: " + Number(sesTaskDelegations || 0),
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
+
metrics: {
|
|
224
|
+
sessionId: _OC_SID,
|
|
225
|
+
projectFingerprint: currentProjectFingerprint || "unknown",
|
|
226
|
+
projectName: currentProjectName || "unknown",
|
|
227
|
+
sessionCost: ltCost,
|
|
223
228
|
cacheSavings: ltCache,
|
|
224
229
|
delegationSavingsUsd: sesTasks,
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
230
|
+
taskDelegationCount: sesTaskDelegations,
|
|
231
|
+
// Backward compatibility (legacy field historically misnamed)
|
|
232
|
+
tasksDelegated: sesTaskDelegations,
|
|
233
|
+
model: resolvedModel || currentModel,
|
|
234
|
+
slot: loadSelection().active_slot || "unknown",
|
|
235
|
+
editSavings: sesEdit,
|
|
236
|
+
creditSavings: sesCredit,
|
|
237
|
+
context7Savings: sesC7,
|
|
233
238
|
quotaSavings: sesQuota,
|
|
234
239
|
},
|
|
235
240
|
tags: ["auto", "cost"],
|
|
@@ -684,7 +684,12 @@ export const onToolExecuteAfter = async (input, output) => {
|
|
|
684
684
|
liveModel = readConfig(projectDirectory) || readConfig(join(process.env.HOME || "", ".config", "opencode")) || process?.env?.OPENCODE_MODEL || "";
|
|
685
685
|
}
|
|
686
686
|
const displayModel = resolveDisplayModelId(liveModel || currentModel || "", projectDirectory) || liveModel || currentModel;
|
|
687
|
-
const
|
|
687
|
+
const resolvedModel = displayModel || liveModel || currentModel || "";
|
|
688
|
+
if (resolvedModel && resolvedModel !== currentModel) {
|
|
689
|
+
setCurrentModel(resolvedModel);
|
|
690
|
+
setCurrentTier(classify(resolvedModel));
|
|
691
|
+
}
|
|
692
|
+
const execution = resolveExecutionIdentity(input?.args?.model || resolvedModel || "", projectDirectory);
|
|
688
693
|
_footerText = `— ${flashIcon ? `${flashIcon} ` : ""}Quality: ${formatQualityName(execution.quality)} | Provider: ${formatProviderName(execution.provider)} | Model: ${execution.model}`;
|
|
689
694
|
if (ltTotal > 0) {
|
|
690
695
|
_footerText += ` | $${formatUsd(ltTotal)} saved`;
|
|
@@ -705,7 +710,7 @@ export const onToolExecuteAfter = async (input, output) => {
|
|
|
705
710
|
if (_autoReportCount % 5 === 0 && ltTotal > 0) {
|
|
706
711
|
saveReport({
|
|
707
712
|
type: "session", summary: `Session cost: $${formatUsd(ltCost)} | cache saved: $${formatUsd(ltCache)} | delegation saved: $${formatUsd(ltTasks)}`,
|
|
708
|
-
metrics: { sessionId: _OC_SID, sessionCost: ltCost, cacheSavings: ltCache, delegationSavingsUsd: ltTasks, model: currentModel, slot: selNow.active_slot || "unknown" },
|
|
713
|
+
metrics: { sessionId: _OC_SID, sessionCost: ltCost, cacheSavings: ltCache, delegationSavingsUsd: ltTasks, model: resolvedModel || currentModel, slot: selNow.active_slot || "unknown" },
|
|
709
714
|
tags: ["auto", "cost"],
|
|
710
715
|
});
|
|
711
716
|
}
|
package/src/lib/state.js
CHANGED
|
@@ -831,6 +831,53 @@ function indexAppend(hash, tool, size, extra) {
|
|
|
831
831
|
}
|
|
832
832
|
// ── Scratchpad hit detection ─────────────────────────────────────────
|
|
833
833
|
const scratchpadHitsSeen = new Set();
|
|
834
|
+
function scanRecentScratchpad(dir, titleCase, maxScan = 2000) {
|
|
835
|
+
try {
|
|
836
|
+
if (!existsSync(dir))
|
|
837
|
+
return null;
|
|
838
|
+
const entries = readdirSync(dir);
|
|
839
|
+
const ptrFiles = entries.filter(e => e.endsWith(".ptr"));
|
|
840
|
+
const ptrCandidates = [];
|
|
841
|
+
for (const pf of ptrFiles) {
|
|
842
|
+
if (ptrCandidates.length >= 50)
|
|
843
|
+
break;
|
|
844
|
+
try {
|
|
845
|
+
const st = statSync(join(dir, pf));
|
|
846
|
+
ptrCandidates.push({ ptrPath: join(dir, pf), mtimeMs: st.mtimeMs });
|
|
847
|
+
}
|
|
848
|
+
catch { }
|
|
849
|
+
}
|
|
850
|
+
ptrCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
851
|
+
let scanned = 0;
|
|
852
|
+
for (const { ptrPath } of ptrCandidates) {
|
|
853
|
+
if (scanned++ >= maxScan)
|
|
854
|
+
break;
|
|
855
|
+
try {
|
|
856
|
+
const ptrData = safeJsonParse(readFileSync(ptrPath, "utf-8"));
|
|
857
|
+
if (!ptrData?.contentHash)
|
|
858
|
+
continue;
|
|
859
|
+
const ptrTool = typeof ptrData.tool === "string" ? (TOOL_NAME_NORMALIZE[ptrData.tool] || ptrData.tool) : null;
|
|
860
|
+
if (titleCase && ptrTool && ptrTool !== titleCase)
|
|
861
|
+
continue;
|
|
862
|
+
const contentHash = String(ptrData.contentHash);
|
|
863
|
+
const f = join(dir, `${contentHash}.txt`);
|
|
864
|
+
if (!existsSync(f))
|
|
865
|
+
continue;
|
|
866
|
+
const st = statSync(f);
|
|
867
|
+
const ageSec = (Date.now() - st.mtimeMs) / 1000;
|
|
868
|
+
if (ageSec > SCRATCHPAD_MAX_AGE_SEC)
|
|
869
|
+
continue;
|
|
870
|
+
const sumPath = join(dir, `${contentHash}.summary.txt`);
|
|
871
|
+
return { hash: contentHash, fullPath: f, sizeBytes: st.size, ageSec: Math.round(ageSec), summaryPath: existsSync(sumPath) ? sumPath : null };
|
|
872
|
+
}
|
|
873
|
+
catch { }
|
|
874
|
+
}
|
|
875
|
+
return null;
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
834
881
|
function getScratchpadHit(toolLower, args, baseDir = null) {
|
|
835
882
|
if (!SCRATCHPAD_TOOLS.has(toolLower))
|
|
836
883
|
return null;
|
|
@@ -838,15 +885,12 @@ function getScratchpadHit(toolLower, args, baseDir = null) {
|
|
|
838
885
|
const inputJson = stableJson(args ?? {});
|
|
839
886
|
const hash = createHash("sha256").update(`${titleCase}\n${inputJson}\n`).digest("hex").slice(0, 16);
|
|
840
887
|
const sessionDir = baseDir || getSessionScratchpadDir();
|
|
841
|
-
const globalDir = SCRATCHPAD_GLOBAL_DIR;
|
|
842
888
|
const sessionPath = join(sessionDir, `${hash}.txt`);
|
|
843
|
-
|
|
844
|
-
let fullPath = existsSync(globalPath) ? globalPath : (existsSync(sessionPath) ? sessionPath : null);
|
|
889
|
+
let fullPath = existsSync(sessionPath) ? sessionPath : null;
|
|
845
890
|
if (!fullPath) {
|
|
846
891
|
// Try pointer files (created by compressToolOutputs mapping input hash -> content hash)
|
|
847
892
|
const ptrSessionPath = join(sessionDir, `${hash}.ptr`);
|
|
848
|
-
const
|
|
849
|
-
const ptrPath = existsSync(ptrSessionPath) ? ptrSessionPath : (existsSync(ptrGlobalPath) ? ptrGlobalPath : null);
|
|
893
|
+
const ptrPath = existsSync(ptrSessionPath) ? ptrSessionPath : null;
|
|
850
894
|
let resolvedHash = hash;
|
|
851
895
|
if (ptrPath) {
|
|
852
896
|
try {
|
|
@@ -854,27 +898,28 @@ function getScratchpadHit(toolLower, args, baseDir = null) {
|
|
|
854
898
|
if (ptrData?.contentHash) {
|
|
855
899
|
resolvedHash = ptrData.contentHash;
|
|
856
900
|
const rSessionPath = join(sessionDir, `${resolvedHash}.txt`);
|
|
857
|
-
|
|
858
|
-
fullPath = existsSync(rGlobalPath) ? rGlobalPath : (existsSync(rSessionPath) ? rSessionPath : null);
|
|
901
|
+
fullPath = existsSync(rSessionPath) ? rSessionPath : null;
|
|
859
902
|
}
|
|
860
903
|
}
|
|
861
904
|
catch { }
|
|
862
|
-
}
|
|
863
|
-
if (!fullPath) {
|
|
864
|
-
return null;
|
|
865
|
-
}
|
|
866
905
|
}
|
|
906
|
+
if (!fullPath) {
|
|
907
|
+
const recent = scanRecentScratchpad(sessionDir, titleCase, 2000);
|
|
908
|
+
if (recent)
|
|
909
|
+
return recent;
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
867
913
|
try {
|
|
868
914
|
const st = statSync(fullPath);
|
|
869
915
|
const ageSec = (Date.now() - st.mtimeMs) / 1000;
|
|
870
916
|
if (ageSec > SCRATCHPAD_MAX_AGE_SEC)
|
|
871
917
|
return null;
|
|
872
|
-
const
|
|
873
|
-
const
|
|
874
|
-
const summaryPath = existsSync(sessionSummaryPath) ? sessionSummaryPath : (existsSync(globalSummaryPath) ? globalSummaryPath : null);
|
|
918
|
+
const summaryPath = join(sessionDir, `${hash}.summary.txt`);
|
|
919
|
+
const finalSummary = existsSync(summaryPath) ? summaryPath : null;
|
|
875
920
|
return {
|
|
876
921
|
hash, fullPath, sizeBytes: st.size, ageSec: Math.round(ageSec),
|
|
877
|
-
summaryPath,
|
|
922
|
+
summaryPath: finalSummary,
|
|
878
923
|
};
|
|
879
924
|
}
|
|
880
925
|
catch {
|
|
@@ -1740,7 +1785,7 @@ LEDGER_BUFFER_MAX, LEDGER_BUFFER_FLUSH_MS, _ledgerBuffer, _ledgerBufferTimer, _f
|
|
|
1740
1785
|
// Stable JSON
|
|
1741
1786
|
stableJson, _readHead, indexAppend,
|
|
1742
1787
|
// Scratchpad hits
|
|
1743
|
-
scratchpadHitsSeen, getScratchpadHit, recordScratchpadObservation, _pruneScratchpadDir, runDecadenceCycle, applyDecadence, cleanupStaleSessionScratchpads, pruneScratchpadOnce,
|
|
1788
|
+
scratchpadHitsSeen, scanRecentScratchpad, getScratchpadHit, recordScratchpadObservation, _pruneScratchpadDir, runDecadenceCycle, applyDecadence, cleanupStaleSessionScratchpads, pruneScratchpadOnce,
|
|
1744
1789
|
// Active jobs
|
|
1745
1790
|
loadActiveJobs, getActiveJobForProject, saveActiveJobForProject, saveJobRecord, loadJobRecord,
|
|
1746
1791
|
// Project memory
|
|
@@ -10,6 +10,28 @@ class LocalBlackboxStub {
|
|
|
10
10
|
this.history = [];
|
|
11
11
|
this.loopCount = 0;
|
|
12
12
|
}
|
|
13
|
+
normalizeText(text) {
|
|
14
|
+
return (text || "")
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9\s]+/g, " ")
|
|
17
|
+
.replace(/\s+/g, " ")
|
|
18
|
+
.trim();
|
|
19
|
+
}
|
|
20
|
+
getRepeatStreak() {
|
|
21
|
+
if (this.history.length < 2)
|
|
22
|
+
return 0;
|
|
23
|
+
const normalizedLast = this.normalizeText(this.history[this.history.length - 1].text);
|
|
24
|
+
if (!normalizedLast)
|
|
25
|
+
return 0;
|
|
26
|
+
let streak = 1;
|
|
27
|
+
for (let i = this.history.length - 2; i >= 0; i--) {
|
|
28
|
+
const normalized = this.normalizeText(this.history[i].text);
|
|
29
|
+
if (!normalized || normalized !== normalizedLast)
|
|
30
|
+
break;
|
|
31
|
+
streak++;
|
|
32
|
+
}
|
|
33
|
+
return streak;
|
|
34
|
+
}
|
|
13
35
|
extractFeatures(text) {
|
|
14
36
|
if (!text || typeof text !== "string")
|
|
15
37
|
return {};
|
|
@@ -78,10 +100,11 @@ class LocalBlackboxStub {
|
|
|
78
100
|
const action = this.classifyAction(text);
|
|
79
101
|
const entropy = this.computeEntropy(features);
|
|
80
102
|
const uncertainty = this.computeUncertainty(features);
|
|
81
|
-
const isLooping = this.detectBasicLoop(text);
|
|
82
103
|
this.history.push({ text, timestamp: Date.now() / 1000 });
|
|
83
104
|
if (this.history.length > 10)
|
|
84
105
|
this.history.shift();
|
|
106
|
+
const repeatStreak = this.getRepeatStreak();
|
|
107
|
+
const isLooping = repeatStreak >= 2 || this.detectBasicLoop(text);
|
|
85
108
|
if (isLooping)
|
|
86
109
|
this.loopCount++;
|
|
87
110
|
else
|
|
@@ -95,7 +118,8 @@ class LocalBlackboxStub {
|
|
|
95
118
|
continuity_state: "MEDIUM",
|
|
96
119
|
is_looping: isLooping,
|
|
97
120
|
loop_consecutive: this.loopCount,
|
|
98
|
-
|
|
121
|
+
repeat_streak: repeatStreak,
|
|
122
|
+
loop_intervention_level: repeatStreak >= 3 || this.loopCount >= 3 ? "escalated" : repeatStreak >= 2 || this.loopCount >= 2 ? "assertive" : this.loopCount >= 1 ? "gentle" : "none",
|
|
99
123
|
pivot_detected: false,
|
|
100
124
|
pivot_score: 0.0,
|
|
101
125
|
outcome: null,
|
|
@@ -109,8 +133,8 @@ class LocalBlackboxStub {
|
|
|
109
133
|
detectBasicLoop(text, threshold = 0.5) {
|
|
110
134
|
if (this.history.length < 3)
|
|
111
135
|
return false;
|
|
112
|
-
const currWords = new Set(
|
|
113
|
-
const pastWords = new Set(this.history[this.history.length - 3].text
|
|
136
|
+
const currWords = new Set(this.normalizeText(text).split(/\s+/).filter(w => w.length > 3));
|
|
137
|
+
const pastWords = new Set(this.normalizeText(this.history[this.history.length - 3].text).split(/\s+/).filter(w => w.length > 3));
|
|
114
138
|
if (currWords.size === 0 || pastWords.size === 0)
|
|
115
139
|
return false;
|
|
116
140
|
const intersection = new Set([...currWords].filter(w => pastWords.has(w)));
|
|
@@ -121,11 +145,11 @@ class LocalBlackboxStub {
|
|
|
121
145
|
if (this.loopCount < 1)
|
|
122
146
|
return null;
|
|
123
147
|
const interventions = {
|
|
124
|
-
gentle: { level: "gentle", directive: "You may be repeating
|
|
125
|
-
assertive: { level: "assertive", directive: "You are stuck in a loop.
|
|
126
|
-
escalated: { level: "escalated", directive: "CRITICAL:
|
|
148
|
+
gentle: { level: "gentle", directive: "You may be repeating the same answer path — stop and restate the core question from a new angle.", resetSuggested: false },
|
|
149
|
+
assertive: { level: "assertive", directive: "You are stuck in a loop. STOP repeating the current answer path and list 3 alternative approaches.", resetSuggested: false },
|
|
150
|
+
escalated: { level: "escalated", directive: "CRITICAL: repeated loop detected. STOP the current approach entirely and SWITCH topics or reset strategy.", resetSuggested: true },
|
|
127
151
|
};
|
|
128
|
-
return this.loopCount >= 3 ? interventions.escalated : this.loopCount >= 2 ? interventions.assertive : interventions.gentle;
|
|
152
|
+
return this.getRepeatStreak() >= 3 || this.loopCount >= 3 ? interventions.escalated : this.getRepeatStreak() >= 2 || this.loopCount >= 2 ? interventions.assertive : interventions.gentle;
|
|
129
153
|
}
|
|
130
154
|
getPivotDirective() { return null; }
|
|
131
155
|
recordOutcome(_outcome) { }
|
|
@@ -270,7 +270,8 @@ function buildDirectives(cv, regime, state, action, optimizationMode) {
|
|
|
270
270
|
if (state.is_looping && state.loop_intervention_level && state.loop_intervention_level !== "none") {
|
|
271
271
|
const severity = state.loop_intervention_level === "escalated" ? "CRITICAL"
|
|
272
272
|
: state.loop_intervention_level === "assertive" ? "WARNING" : "NOTICE";
|
|
273
|
-
|
|
273
|
+
const repeatNote = state.repeat_streak >= 2 ? ` Repeated prompt streak: ${state.repeat_streak}.` : "";
|
|
274
|
+
d.push(`[loop prevention: ${severity}] The conversation may be looping — stop repeating the same answer path and try a different approach.${repeatNote} (level: ${state.loop_intervention_level})`);
|
|
274
275
|
}
|
|
275
276
|
if (optimizationMode && optimizationMode !== "balanced") {
|
|
276
277
|
d.push(`[optimization: ${optimizationMode}] Session optimization mode is "${optimizationMode}". This overrides default per-regime behavior.`);
|
|
@@ -21,6 +21,28 @@ export class ResolutionTracker {
|
|
|
21
21
|
this.outcomeHistory = [];
|
|
22
22
|
this.calibratedWeights = null;
|
|
23
23
|
}
|
|
24
|
+
normalizeText(text) {
|
|
25
|
+
return (text || "")
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.replace(/[^a-z0-9\s]+/g, " ")
|
|
28
|
+
.replace(/\s+/g, " ")
|
|
29
|
+
.trim();
|
|
30
|
+
}
|
|
31
|
+
getRepeatStreak() {
|
|
32
|
+
if (this.history.length < 2)
|
|
33
|
+
return 0;
|
|
34
|
+
const normalizedLast = this.normalizeText(this.history[this.history.length - 1].text);
|
|
35
|
+
if (!normalizedLast)
|
|
36
|
+
return 0;
|
|
37
|
+
let streak = 1;
|
|
38
|
+
for (let i = this.history.length - 2; i >= 0; i--) {
|
|
39
|
+
const normalized = this.normalizeText(this.history[i].text);
|
|
40
|
+
if (!normalized || normalized !== normalizedLast)
|
|
41
|
+
break;
|
|
42
|
+
streak++;
|
|
43
|
+
}
|
|
44
|
+
return streak;
|
|
45
|
+
}
|
|
24
46
|
update(userText, features, action, entropy, uncertainty, embedding = null) {
|
|
25
47
|
const entry = {
|
|
26
48
|
text: userText,
|
|
@@ -90,6 +112,7 @@ export class ResolutionTracker {
|
|
|
90
112
|
const entropyTrend = this.calcEntropyTrend();
|
|
91
113
|
const featureContradiction = this.calcFeatureContradiction();
|
|
92
114
|
const embeddingDelta = this.calcEmbeddingDelta();
|
|
115
|
+
const repeatStreak = this.getRepeatStreak();
|
|
93
116
|
const isLooping = this.detectLoop();
|
|
94
117
|
const intentState = this.computeIntentState();
|
|
95
118
|
const continuityState = this.continuityState(intentState);
|
|
@@ -135,9 +158,9 @@ export class ResolutionTracker {
|
|
|
135
158
|
const momentum = this.calcMomentum(entropyTrend, actionConsistency, embeddingDelta, isLooping, lastEntry.action, lastEntry.entropy);
|
|
136
159
|
let loopLevel = "none";
|
|
137
160
|
if (isLooping) {
|
|
138
|
-
if (this.loopCount >= 4)
|
|
161
|
+
if (repeatStreak >= 3 || this.loopCount >= 4)
|
|
139
162
|
loopLevel = "escalated";
|
|
140
|
-
else if (this.loopCount >= 3)
|
|
163
|
+
else if (repeatStreak >= 2 || this.loopCount >= 3)
|
|
141
164
|
loopLevel = "assertive";
|
|
142
165
|
else if (this.loopCount >= 2)
|
|
143
166
|
loopLevel = "suggestive";
|
|
@@ -165,6 +188,7 @@ export class ResolutionTracker {
|
|
|
165
188
|
continuity_state: continuityState,
|
|
166
189
|
is_looping: isLooping,
|
|
167
190
|
loop_consecutive: this.loopCount,
|
|
191
|
+
repeat_streak: repeatStreak,
|
|
168
192
|
loop_intervention_level: loopLevel,
|
|
169
193
|
pivot_detected: pivotDetected,
|
|
170
194
|
pivot_score: Math.round(pivotScore * 10000) / 10000,
|
|
@@ -224,10 +248,13 @@ export class ResolutionTracker {
|
|
|
224
248
|
detectLoop(k = 3, threshold = 0.6) {
|
|
225
249
|
const effectiveThreshold = this.calibratedWeights?.loopJaccard ?? threshold;
|
|
226
250
|
const effectiveK = this.calibratedWeights?.loopK ?? k;
|
|
251
|
+
const repeatStreak = this.getRepeatStreak();
|
|
252
|
+
if (repeatStreak >= 2)
|
|
253
|
+
return true;
|
|
227
254
|
if (this.history.length < effectiveK + 1)
|
|
228
255
|
return false;
|
|
229
|
-
const currWords = new Set(this.history[this.history.length - 1].text
|
|
230
|
-
const pastWords = new Set(this.history[this.history.length - (effectiveK + 1)].text
|
|
256
|
+
const currWords = new Set(this.normalizeText(this.history[this.history.length - 1].text).split(/\s+/).filter(Boolean));
|
|
257
|
+
const pastWords = new Set(this.normalizeText(this.history[this.history.length - (effectiveK + 1)].text).split(/\s+/).filter(Boolean));
|
|
231
258
|
if (currWords.size === 0 || pastWords.size === 0)
|
|
232
259
|
return false;
|
|
233
260
|
const intersection = new Set([...currWords].filter(w => pastWords.has(w)));
|
|
@@ -353,19 +380,19 @@ export class ResolutionTracker {
|
|
|
353
380
|
return null;
|
|
354
381
|
const interventions = {
|
|
355
382
|
gentle: {
|
|
356
|
-
directive: "You may be repeating
|
|
383
|
+
directive: "You may be repeating the same answer path — stop and restate the core question from a new angle before continuing.",
|
|
357
384
|
resetSuggested: false,
|
|
358
385
|
},
|
|
359
386
|
suggestive: {
|
|
360
|
-
directive: "The conversation is looping. Step back
|
|
387
|
+
directive: "The conversation is looping. Do not continue the same answer path. Step back, identify what new information is missing, and ask for a different constraint or approach.",
|
|
361
388
|
resetSuggested: false,
|
|
362
389
|
},
|
|
363
390
|
assertive: {
|
|
364
|
-
directive: "You are stuck in a loop.
|
|
391
|
+
directive: "You are stuck in a loop. STOP repeating the current answer path. PIVOT: list 3 alternative approaches you have not tried and choose one.",
|
|
365
392
|
resetSuggested: false,
|
|
366
393
|
},
|
|
367
394
|
escalated: {
|
|
368
|
-
directive: "CRITICAL:
|
|
395
|
+
directive: "CRITICAL: repeated loop detected. STOP the current approach entirely. Reset the strategy, SWITCH topics or scope, and do not continue the same line of reasoning.",
|
|
369
396
|
resetSuggested: true,
|
|
370
397
|
},
|
|
371
398
|
};
|