vibeostheog 0.22.11 → 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 CHANGED
@@ -1,3 +1,26 @@
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
+
20
+ ## 0.22.12
21
+ - fix: harden scratchpad cache
22
+
23
+
1
24
  ## 0.22.11
2
25
  - fix: harden blackbox pivot detection and add regression coverage
3
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeostheog",
3
- "version": "0.22.11",
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",
@@ -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() {
@@ -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 execution = resolveExecutionIdentity(input?.args?.model || liveModel || brainModel || currentModel || displayModel || "", directory);
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
- metrics: {
219
- sessionId: _OC_SID,
220
- projectFingerprint: currentProjectFingerprint || "unknown",
221
- projectName: currentProjectName || "unknown",
222
- sessionCost: ltCost,
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
- taskDelegationCount: sesTaskDelegations,
226
- // Backward compatibility (legacy field historically misnamed)
227
- tasksDelegated: sesTaskDelegations,
228
- model: currentModel,
229
- slot: loadSelection().active_slot || "unknown",
230
- editSavings: sesEdit,
231
- creditSavings: sesCredit,
232
- context7Savings: sesC7,
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 execution = resolveExecutionIdentity(input?.args?.model || liveModel || currentModel || displayModel || "", projectDirectory);
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
@@ -837,7 +837,6 @@ function scanRecentScratchpad(dir, titleCase, maxScan = 2000) {
837
837
  return null;
838
838
  const entries = readdirSync(dir);
839
839
  const ptrFiles = entries.filter(e => e.endsWith(".ptr"));
840
- // Try .ptr files first (created by compressToolOutputs mapping input hash -> content hash)
841
840
  const ptrCandidates = [];
842
841
  for (const pf of ptrFiles) {
843
842
  if (ptrCandidates.length >= 50)
@@ -849,14 +848,18 @@ function scanRecentScratchpad(dir, titleCase, maxScan = 2000) {
849
848
  catch { }
850
849
  }
851
850
  ptrCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
851
+ let scanned = 0;
852
852
  for (const { ptrPath } of ptrCandidates) {
853
+ if (scanned++ >= maxScan)
854
+ break;
853
855
  try {
854
856
  const ptrData = safeJsonParse(readFileSync(ptrPath, "utf-8"));
855
857
  if (!ptrData?.contentHash)
856
858
  continue;
857
- if (titleCase && ptrData.tool && TOOL_NAME_NORMALIZE[ptrData.tool] !== titleCase)
859
+ const ptrTool = typeof ptrData.tool === "string" ? (TOOL_NAME_NORMALIZE[ptrData.tool] || ptrData.tool) : null;
860
+ if (titleCase && ptrTool && ptrTool !== titleCase)
858
861
  continue;
859
- const contentHash = ptrData.contentHash;
862
+ const contentHash = String(ptrData.contentHash);
860
863
  const f = join(dir, `${contentHash}.txt`);
861
864
  if (!existsSync(f))
862
865
  continue;
@@ -869,28 +872,6 @@ function scanRecentScratchpad(dir, titleCase, maxScan = 2000) {
869
872
  }
870
873
  catch { }
871
874
  }
872
- // Fallback: scan .txt files
873
- const txtFiles = entries.filter(e => e.endsWith(".txt") && !e.endsWith(".summary.txt"));
874
- if (txtFiles.length === 0)
875
- return null;
876
- const candidateHashes = [];
877
- for (let i = txtFiles.length - 1; i >= 0; i--) {
878
- const f = txtFiles[i];
879
- if (candidateHashes.length > 50)
880
- break;
881
- candidateHashes.push(f.replace(/\.txt$/, ""));
882
- }
883
- for (const hash of candidateHashes) {
884
- const f = join(dir, `${hash}.txt`);
885
- if (!existsSync(f))
886
- continue;
887
- const st = statSync(f);
888
- const ageSec = (Date.now() - st.mtimeMs) / 1000;
889
- if (ageSec > SCRATCHPAD_MAX_AGE_SEC)
890
- continue;
891
- const sumPath = join(dir, `${hash}.summary.txt`);
892
- return { hash, fullPath: f, sizeBytes: st.size, ageSec: Math.round(ageSec), summaryPath: existsSync(sumPath) ? sumPath : null };
893
- }
894
875
  return null;
895
876
  }
896
877
  catch {
@@ -904,15 +885,12 @@ function getScratchpadHit(toolLower, args, baseDir = null) {
904
885
  const inputJson = stableJson(args ?? {});
905
886
  const hash = createHash("sha256").update(`${titleCase}\n${inputJson}\n`).digest("hex").slice(0, 16);
906
887
  const sessionDir = baseDir || getSessionScratchpadDir();
907
- const globalDir = SCRATCHPAD_GLOBAL_DIR;
908
888
  const sessionPath = join(sessionDir, `${hash}.txt`);
909
- const globalPath = join(globalDir, `${hash}.txt`);
910
- let fullPath = existsSync(globalPath) ? globalPath : (existsSync(sessionPath) ? sessionPath : null);
889
+ let fullPath = existsSync(sessionPath) ? sessionPath : null;
911
890
  if (!fullPath) {
912
891
  // Try pointer files (created by compressToolOutputs mapping input hash -> content hash)
913
892
  const ptrSessionPath = join(sessionDir, `${hash}.ptr`);
914
- const ptrGlobalPath = join(globalDir, `${hash}.ptr`);
915
- const ptrPath = existsSync(ptrSessionPath) ? ptrSessionPath : (existsSync(ptrGlobalPath) ? ptrGlobalPath : null);
893
+ const ptrPath = existsSync(ptrSessionPath) ? ptrSessionPath : null;
916
894
  let resolvedHash = hash;
917
895
  if (ptrPath) {
918
896
  try {
@@ -920,30 +898,28 @@ function getScratchpadHit(toolLower, args, baseDir = null) {
920
898
  if (ptrData?.contentHash) {
921
899
  resolvedHash = ptrData.contentHash;
922
900
  const rSessionPath = join(sessionDir, `${resolvedHash}.txt`);
923
- const rGlobalPath = join(globalDir, `${resolvedHash}.txt`);
924
- fullPath = existsSync(rGlobalPath) ? rGlobalPath : (existsSync(rSessionPath) ? rSessionPath : null);
901
+ fullPath = existsSync(rSessionPath) ? rSessionPath : null;
925
902
  }
926
903
  }
927
904
  catch { }
928
- }
929
- if (!fullPath) {
930
- const recent = scanRecentScratchpad(sessionDir, titleCase, 2000) || scanRecentScratchpad(globalDir, titleCase, 2000);
931
- if (recent)
932
- return recent;
933
- return null;
934
- }
935
905
  }
906
+ if (!fullPath) {
907
+ const recent = scanRecentScratchpad(sessionDir, titleCase, 2000);
908
+ if (recent)
909
+ return recent;
910
+ return null;
911
+ }
912
+ }
936
913
  try {
937
914
  const st = statSync(fullPath);
938
915
  const ageSec = (Date.now() - st.mtimeMs) / 1000;
939
916
  if (ageSec > SCRATCHPAD_MAX_AGE_SEC)
940
917
  return null;
941
- const sessionSummaryPath = join(sessionDir, `${hash}.summary.txt`);
942
- const globalSummaryPath = join(globalDir, `${hash}.summary.txt`);
943
- const summaryPath = existsSync(sessionSummaryPath) ? sessionSummaryPath : (existsSync(globalSummaryPath) ? globalSummaryPath : null);
918
+ const summaryPath = join(sessionDir, `${hash}.summary.txt`);
919
+ const finalSummary = existsSync(summaryPath) ? summaryPath : null;
944
920
  return {
945
921
  hash, fullPath, sizeBytes: st.size, ageSec: Math.round(ageSec),
946
- summaryPath,
922
+ summaryPath: finalSummary,
947
923
  };
948
924
  }
949
925
  catch {
@@ -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
- loop_intervention_level: this.loopCount >= 3 ? "escalated" : this.loopCount >= 2 ? "assertive" : this.loopCount >= 1 ? "gentle" : "none",
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(text.toLowerCase().split(/\s+/).filter(w => w.length > 3));
113
- const pastWords = new Set(this.history[this.history.length - 3].text.toLowerCase().split(/\s+/).filter(w => w.length > 3));
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 yourselftry rephrasing the core question.", resetSuggested: false },
125
- assertive: { level: "assertive", directive: "You are stuck in a loop. List 3 alternative approaches.", resetSuggested: false },
126
- escalated: { level: "escalated", directive: "CRITICAL: Loop detected. STOP the current approach and SWITCH topics.", resetSuggested: true },
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
- d.push(`[loop prevention: ${severity}] The conversation may be looping try a different approach. (level: ${state.loop_intervention_level})`);
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.toLowerCase().split(/\s+/));
230
- const pastWords = new Set(this.history[this.history.length - (effectiveK + 1)].text.toLowerCase().split(/\s+/));
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 yourselftry rephrasing the core question differently or approaching from a new angle.",
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 and identify what new information you need. Consider asking a different question or taking a break from this topic.",
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. The current approach is not productive. PIVOT: list 3 alternative approaches you haven't tried and pick one.",
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: You have been looping for several turns. STOP the current approach entirely. Either SWITCH to a completely different topic or reset your strategy. Continued looping wastes time and tokens.",
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
  };