lynkr 9.7.2 → 9.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -19
- package/bin/cli.js +11 -0
- package/bin/lynkr-init.js +14 -1
- package/bin/lynkr-usage.js +78 -0
- package/bin/wrap.js +60 -35
- package/config/difficulty-anchors.json +22 -0
- package/package.json +24 -3
- package/scripts/audit-log-reader.js +399 -0
- package/scripts/calibrate-thresholds.js +38 -157
- package/scripts/compact-dictionary.js +204 -0
- package/scripts/test-deduplication.js +448 -0
- package/scripts/ws7-anchor-replay.js +108 -0
- package/skills/lynkr/SKILL.md +195 -0
- package/src/agents/context-manager.js +18 -2
- package/src/agents/definitions/loader.js +90 -0
- package/src/agents/executor.js +24 -2
- package/src/agents/index.js +9 -1
- package/src/agents/parallel-coordinator.js +2 -2
- package/src/agents/reflector.js +11 -1
- package/src/api/middleware/loop-guard.js +87 -0
- package/src/api/middleware/request-logging.js +5 -64
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +120 -101
- package/src/api/providers-handler.js +27 -2
- package/src/api/router.js +450 -125
- package/src/budget/index.js +2 -19
- package/src/cache/semantic.js +9 -0
- package/src/clients/databricks.js +459 -146
- package/src/clients/gpt-utils.js +11 -105
- package/src/clients/openai-format.js +10 -3
- package/src/clients/openrouter-utils.js +49 -24
- package/src/clients/prompt-cache-injection.js +1 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/responses-format.js +34 -3
- package/src/clients/routing.js +15 -0
- package/src/config/index.js +36 -2
- package/src/context/gcf.js +275 -0
- package/src/context/tool-result-compressor.js +51 -9
- package/src/dashboard/api.js +1 -0
- package/src/logger/index.js +14 -1
- package/src/memory/search.js +12 -40
- package/src/memory/tools.js +3 -24
- package/src/orchestrator/bypass.js +4 -2
- package/src/orchestrator/index.js +144 -88
- package/src/routing/affinity-store.js +194 -0
- package/src/routing/agentic-detector.js +36 -6
- package/src/routing/bandit.js +25 -6
- package/src/routing/calibration.js +212 -0
- package/src/routing/client-profiles.js +292 -0
- package/src/routing/complexity-analyzer.js +48 -11
- package/src/routing/deescalator.js +148 -0
- package/src/routing/degradation.js +109 -0
- package/src/routing/feedback.js +157 -0
- package/src/routing/index.js +897 -87
- package/src/routing/intent-score.js +339 -0
- package/src/routing/interaction.js +3 -0
- package/src/routing/knn-router.js +70 -21
- package/src/routing/model-registry.js +28 -7
- package/src/routing/model-tiers.js +25 -2
- package/src/routing/reward-pipeline.js +68 -2
- package/src/routing/risk-analyzer.js +30 -1
- package/src/routing/risk-classifier.js +6 -2
- package/src/routing/session-affinity.js +162 -34
- package/src/routing/telemetry.js +298 -10
- package/src/routing/verifier.js +267 -0
- package/src/server.js +66 -21
- package/src/sessions/cleanup.js +17 -0
- package/src/tools/index.js +1 -15
- package/src/tools/smart-selection.js +10 -0
- package/src/tools/web-client.js +3 -3
- package/.eslintrc.cjs +0 -12
- package/benchmark-configs/litellm_config.yaml +0 -86
- package/benchmark-configs/lynkr.env +0 -48
- package/benchmark-configs/portkey-config.json +0 -60
- package/benchmark-configs/portkey-docker.sh +0 -23
- package/benchmark-tier-routing.js +0 -449
- package/funding.json +0 -110
- package/src/api/middleware/validation.js +0 -261
- package/src/routing/drift-monitor.js +0 -113
- package/src/workers/helpers.js +0 -185
|
@@ -8,19 +8,30 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Normalisation uses running min/max so we don't need to pre-compute global
|
|
10
10
|
* scales.
|
|
11
|
+
*
|
|
12
|
+
* WS5.1 — state persists to data/reward-state.json so the normaliser ranges
|
|
13
|
+
* survive process restarts. Without this, the first N requests after every
|
|
14
|
+
* restart get scaled against a re-learned range and produce noisy rewards.
|
|
11
15
|
*/
|
|
12
16
|
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
13
19
|
const logger = require('../logger');
|
|
14
20
|
|
|
21
|
+
const STATE_PATH = path.join(__dirname, '../../data/reward-state.json');
|
|
15
22
|
const DEFAULT_LAMBDA = 0.3;
|
|
16
23
|
const DEFAULT_MU = 0.1;
|
|
24
|
+
const SAVE_EVERY = 25;
|
|
17
25
|
|
|
18
26
|
class RewardPipeline {
|
|
19
|
-
constructor({ lambda = DEFAULT_LAMBDA, mu = DEFAULT_MU } = {}) {
|
|
27
|
+
constructor({ lambda = DEFAULT_LAMBDA, mu = DEFAULT_MU, statePath = STATE_PATH } = {}) {
|
|
20
28
|
this.lambda = lambda;
|
|
21
29
|
this.mu = mu;
|
|
22
30
|
this.costRange = { min: Infinity, max: -Infinity };
|
|
23
31
|
this.latencyRange = { min: Infinity, max: -Infinity };
|
|
32
|
+
this.observations = 0;
|
|
33
|
+
this.statePath = statePath;
|
|
34
|
+
this._load();
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
observe({ cost, latency }) {
|
|
@@ -46,11 +57,66 @@ class RewardPipeline {
|
|
|
46
57
|
*/
|
|
47
58
|
reward(obs) {
|
|
48
59
|
this.observe(obs);
|
|
60
|
+
this.observations++;
|
|
61
|
+
// Periodic save mirrors bandit.js: cheap enough to write inline every
|
|
62
|
+
// N observations, no risk of blocking the response path since callers
|
|
63
|
+
// invoke reward() from setImmediate.
|
|
64
|
+
if (this.observations % SAVE_EVERY === 0) this._save();
|
|
49
65
|
const q = typeof obs.quality === 'number' ? obs.quality : 50;
|
|
50
66
|
const cn = this._normalize(obs.cost ?? 0, this.costRange);
|
|
51
67
|
const ln = this._normalize(obs.latency ?? 0, this.latencyRange);
|
|
52
68
|
return Math.max(0, Math.min(100, q - this.lambda * cn * 100 - this.mu * ln * 100));
|
|
53
69
|
}
|
|
70
|
+
|
|
71
|
+
_save() {
|
|
72
|
+
try {
|
|
73
|
+
fs.mkdirSync(path.dirname(this.statePath), { recursive: true });
|
|
74
|
+
fs.writeFileSync(this.statePath, JSON.stringify({
|
|
75
|
+
savedAt: Date.now(),
|
|
76
|
+
lambda: this.lambda,
|
|
77
|
+
mu: this.mu,
|
|
78
|
+
observations: this.observations,
|
|
79
|
+
// Serialise Infinity as null; _load() re-hydrates.
|
|
80
|
+
costRange: {
|
|
81
|
+
min: isFinite(this.costRange.min) ? this.costRange.min : null,
|
|
82
|
+
max: isFinite(this.costRange.max) ? this.costRange.max : null,
|
|
83
|
+
},
|
|
84
|
+
latencyRange: {
|
|
85
|
+
min: isFinite(this.latencyRange.min) ? this.latencyRange.min : null,
|
|
86
|
+
max: isFinite(this.latencyRange.max) ? this.latencyRange.max : null,
|
|
87
|
+
},
|
|
88
|
+
}, null, 0));
|
|
89
|
+
} catch (err) {
|
|
90
|
+
logger.debug({ err: err.message }, '[Reward] State save failed');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_load() {
|
|
95
|
+
try {
|
|
96
|
+
if (!fs.existsSync(this.statePath)) return;
|
|
97
|
+
const raw = JSON.parse(fs.readFileSync(this.statePath, 'utf8'));
|
|
98
|
+
if (raw.costRange) {
|
|
99
|
+
this.costRange = {
|
|
100
|
+
min: raw.costRange.min == null ? Infinity : raw.costRange.min,
|
|
101
|
+
max: raw.costRange.max == null ? -Infinity : raw.costRange.max,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (raw.latencyRange) {
|
|
105
|
+
this.latencyRange = {
|
|
106
|
+
min: raw.latencyRange.min == null ? Infinity : raw.latencyRange.min,
|
|
107
|
+
max: raw.latencyRange.max == null ? -Infinity : raw.latencyRange.max,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
this.observations = raw.observations || 0;
|
|
111
|
+
logger.info({
|
|
112
|
+
cost: this.costRange,
|
|
113
|
+
latency: this.latencyRange,
|
|
114
|
+
observations: this.observations,
|
|
115
|
+
}, '[Reward] State loaded');
|
|
116
|
+
} catch (err) {
|
|
117
|
+
logger.debug({ err: err.message }, '[Reward] State load failed');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
54
120
|
}
|
|
55
121
|
|
|
56
122
|
let _instance = null;
|
|
@@ -59,4 +125,4 @@ function getRewardPipeline() {
|
|
|
59
125
|
return _instance;
|
|
60
126
|
}
|
|
61
127
|
|
|
62
|
-
module.exports = { RewardPipeline, getRewardPipeline };
|
|
128
|
+
module.exports = { RewardPipeline, getRewardPipeline, SAVE_EVERY };
|
|
@@ -114,6 +114,34 @@ function findHits(keywords, haystack) {
|
|
|
114
114
|
return Array.from(hits).sort();
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Strip harness-injected <system-reminder> blocks from instruction text
|
|
119
|
+
* before risk scanning. Claude Code appends these blocks to the latest
|
|
120
|
+
* user message (CLAUDE.md contents, MCP-server auth notices, tool-search
|
|
121
|
+
* hints). Their boilerplate routinely contains words like "authentication",
|
|
122
|
+
* "credential", and "security" — live traffic showed a bare "23+45" turn
|
|
123
|
+
* force-escalated to COMPLEX on instructionHits ["credential","security"]
|
|
124
|
+
* that the user never typed. Risk must reflect what the USER is asking,
|
|
125
|
+
* not what the harness injected; genuinely risky asks live in the typed
|
|
126
|
+
* text and still fire. (The intent scorers have stripped these blocks
|
|
127
|
+
* since WS3 — this brings the risk scan in line.)
|
|
128
|
+
* @param {string} text
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
function stripSystemReminders(text) {
|
|
132
|
+
if (typeof text !== 'string' || !text) return '';
|
|
133
|
+
return text
|
|
134
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ' ')
|
|
135
|
+
// Codex harness blocks arrive as user-role messages and get merged into
|
|
136
|
+
// the typed text by the orchestrator's consecutive-role coalescing.
|
|
137
|
+
// <environment_context> carries <permission_profile> ("permission" is a
|
|
138
|
+
// high-risk keyword — live traffic showed every Codex turn, including
|
|
139
|
+
// "Hi", force-escalated to COMPLEX on it) and <user_instructions>
|
|
140
|
+
// carries AGENTS.md contents the user never typed this turn.
|
|
141
|
+
.replace(/<environment_context>[\s\S]*?<\/environment_context>/g, ' ')
|
|
142
|
+
.replace(/<user_instructions>[\s\S]*?<\/user_instructions>/g, ' ');
|
|
143
|
+
}
|
|
144
|
+
|
|
117
145
|
/**
|
|
118
146
|
* Analyze the risk level of a request.
|
|
119
147
|
*
|
|
@@ -131,7 +159,7 @@ function findHits(keywords, haystack) {
|
|
|
131
159
|
* paths: string[] }}
|
|
132
160
|
*/
|
|
133
161
|
function analyzeRisk(payload) {
|
|
134
|
-
const instructionText = extractContent(payload) || '';
|
|
162
|
+
const instructionText = stripSystemReminders(extractContent(payload) || '');
|
|
135
163
|
const lowText = instructionText.toLowerCase();
|
|
136
164
|
|
|
137
165
|
const textPaths = extractPathsFromText(instructionText);
|
|
@@ -193,6 +221,7 @@ module.exports = {
|
|
|
193
221
|
analyzeRisk,
|
|
194
222
|
PROTECTED_PATH_KEYWORDS,
|
|
195
223
|
HIGH_RISK_INSTRUCTION_KEYWORDS,
|
|
224
|
+
stripSystemReminders,
|
|
196
225
|
// Exposed for tests
|
|
197
226
|
extractPathsFromText,
|
|
198
227
|
extractPathsFromToolUses,
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const path = require('path');
|
|
17
17
|
const logger = require('../logger');
|
|
18
|
-
const { analyzeRisk: regexAnalyzeRisk } = require('./risk-analyzer');
|
|
18
|
+
const { analyzeRisk: regexAnalyzeRisk, stripSystemReminders } = require('./risk-analyzer');
|
|
19
19
|
|
|
20
20
|
const MODEL_PATH = path.join(__dirname, '../../data/risk-classifier.json');
|
|
21
21
|
const DECISION_THRESHOLD = 0.5;
|
|
@@ -84,7 +84,10 @@ function analyzeRisk(payload) {
|
|
|
84
84
|
const model = _loadModel();
|
|
85
85
|
if (!model) return regexResult;
|
|
86
86
|
|
|
87
|
-
// Build the text we feed to the classifier: latest user message +
|
|
87
|
+
// Build the text we feed to the classifier: latest user message + system
|
|
88
|
+
// fingerprint. Harness-injected <system-reminder> blocks are stripped for
|
|
89
|
+
// the same reason as in the regex analyzer — their boilerplate contains
|
|
90
|
+
// risk keywords ("authentication", "credential") the user never typed.
|
|
88
91
|
let text = '';
|
|
89
92
|
if (Array.isArray(payload?.messages)) {
|
|
90
93
|
for (let i = payload.messages.length - 1; i >= 0; i--) {
|
|
@@ -98,6 +101,7 @@ function analyzeRisk(payload) {
|
|
|
98
101
|
}
|
|
99
102
|
}
|
|
100
103
|
}
|
|
104
|
+
text = stripSystemReminders(text);
|
|
101
105
|
if (typeof payload?.system === 'string') text += ' ' + payload.system;
|
|
102
106
|
|
|
103
107
|
const prob = _predict(text, model);
|
|
@@ -9,88 +9,216 @@
|
|
|
9
9
|
* Azure: 400 "No tool call found for function call output with call_id …"
|
|
10
10
|
* Moonshot: 400 "Invalid request: tool_call_id is not found"
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* WS1 extends this from a per-turn tool-safety guard into a cache-aware sticky
|
|
13
|
+
* routing pin: routing decisions are made **once per session** (persisted to
|
|
14
|
+
* SQLite so restarts don't lose them) and re-evaluated only at explicit
|
|
15
|
+
* triggers — compaction, guard escalation, provider unavailable, or an
|
|
16
|
+
* economic downgrade that beats the estimated cold-cache re-read cost.
|
|
17
|
+
*
|
|
18
|
+
* The in-memory Map is a read-through cache over `affinity-store` (SQLite);
|
|
19
|
+
* the store is authoritative across restarts, the Map keeps hot sessions
|
|
20
|
+
* off the SQLite path.
|
|
15
21
|
*
|
|
16
22
|
* @module routing/session-affinity
|
|
17
23
|
*/
|
|
18
24
|
|
|
25
|
+
const store = require("./affinity-store");
|
|
26
|
+
|
|
19
27
|
const MAX_ENTRIES = 2000;
|
|
20
|
-
|
|
28
|
+
// 6h TTL — long enough that a working session (dev machine idled overnight)
|
|
29
|
+
// keeps its pin, short enough that abandoned sessions eventually clear.
|
|
30
|
+
// Override with LYNKR_STICKY_TTL_MS.
|
|
31
|
+
function _ttlMs() {
|
|
32
|
+
const raw = Number(process.env.LYNKR_STICKY_TTL_MS);
|
|
33
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 6 * 60 * 60 * 1000;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Pin entry shape.
|
|
38
|
+
* @typedef {Object} Pin
|
|
39
|
+
* @property {string} provider
|
|
40
|
+
* @property {string|null} model
|
|
41
|
+
* @property {string|null} tier
|
|
42
|
+
* @property {number|null} score - decision.score at pin time (so pinned turns can display the original intent score in the badge instead of a misleading full-payload complexity score)
|
|
43
|
+
* @property {number|null} messageCount - payload.messages.length at pin time
|
|
44
|
+
* @property {number|null} promptTokensEst - countPayloadTokens estimate at pin time
|
|
45
|
+
* @property {number} ts
|
|
46
|
+
*/
|
|
21
47
|
|
|
22
|
-
/** @type {Map<string,
|
|
48
|
+
/** @type {Map<string, Pin>} */
|
|
23
49
|
const pins = new Map();
|
|
24
50
|
|
|
25
51
|
function _evictIfNeeded() {
|
|
26
52
|
if (pins.size <= MAX_ENTRIES) return;
|
|
27
|
-
// Map preserves insertion order — drop the oldest.
|
|
28
53
|
const oldest = pins.keys().next().value;
|
|
29
54
|
if (oldest !== undefined) pins.delete(oldest);
|
|
30
55
|
}
|
|
31
56
|
|
|
32
57
|
/**
|
|
33
|
-
* True when the payload
|
|
34
|
-
*
|
|
35
|
-
* tool-call IDs break if the provider changes.
|
|
58
|
+
* True when the payload is MID tool exchange — the LAST message is
|
|
59
|
+
* submitting tool_result blocks (or replaying a dangling tool_use). Those
|
|
60
|
+
* are the frames whose tool-call IDs break if the provider changes.
|
|
61
|
+
*
|
|
62
|
+
* COMPLETED tool exchanges earlier in the history are safe to carry across
|
|
63
|
+
* providers: the tool_use/tool_result pairs are internally consistent and
|
|
64
|
+
* any Anthropic-format backend accepts them.
|
|
65
|
+
*
|
|
66
|
+
* The original implementation matched tool blocks ANYWHERE in history,
|
|
67
|
+
* which meant one tool call permanently welded the session to its pin:
|
|
68
|
+
* every subsequent frame — including freshly typed user messages — took
|
|
69
|
+
* the unconditional serve, silently disabling the risk/context/vision
|
|
70
|
+
* guards AND the WS1.5 drift check for the rest of the session. Live
|
|
71
|
+
* symptom (2026-07-07): a heavyweight typed ask displayed
|
|
72
|
+
* "score 0 · pin@0" because drift never ran after the session's first
|
|
73
|
+
* tool use.
|
|
36
74
|
* @param {object} payload
|
|
37
75
|
* @returns {boolean}
|
|
38
76
|
*/
|
|
39
77
|
function payloadHasToolHistory(payload) {
|
|
40
78
|
const messages = payload?.messages;
|
|
41
|
-
if (!Array.isArray(messages)) return false;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
for (const block of content) {
|
|
46
|
-
const t = block?.type;
|
|
47
|
-
if (t === "tool_use" || t === "tool_result") return true;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return false;
|
|
79
|
+
if (!Array.isArray(messages) || messages.length === 0) return false;
|
|
80
|
+
const content = messages[messages.length - 1]?.content;
|
|
81
|
+
if (!Array.isArray(content)) return false;
|
|
82
|
+
return content.some((b) => b?.type === "tool_result" || b?.type === "tool_use");
|
|
51
83
|
}
|
|
52
84
|
|
|
53
85
|
/**
|
|
54
|
-
*
|
|
86
|
+
* Load a pin: memory first, then SQLite. Returns null on TTL expiry or miss.
|
|
55
87
|
* @param {string} sessionId
|
|
88
|
+
* @returns {Pin|null}
|
|
56
89
|
*/
|
|
57
|
-
function
|
|
90
|
+
function getPin(sessionId) {
|
|
58
91
|
if (!sessionId) return null;
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
92
|
+
const ttl = _ttlMs();
|
|
93
|
+
|
|
94
|
+
const inMem = pins.get(sessionId);
|
|
95
|
+
if (inMem) {
|
|
96
|
+
if (Date.now() - inMem.ts > ttl) {
|
|
97
|
+
pins.delete(sessionId);
|
|
98
|
+
store.remove(sessionId);
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return inMem;
|
|
64
102
|
}
|
|
65
|
-
|
|
103
|
+
|
|
104
|
+
// Cold cache — read through the store.
|
|
105
|
+
const fromStore = store.load(sessionId, ttl);
|
|
106
|
+
if (!fromStore) return null;
|
|
107
|
+
pins.set(sessionId, fromStore);
|
|
108
|
+
_evictIfNeeded();
|
|
109
|
+
return fromStore;
|
|
66
110
|
}
|
|
67
111
|
|
|
68
112
|
/**
|
|
69
|
-
*
|
|
113
|
+
* Write-through: update the in-memory Map and persist to SQLite.
|
|
114
|
+
* Accepts a routing decision plus session-scoped stats used by the re-pin
|
|
115
|
+
* heuristics.
|
|
116
|
+
*
|
|
70
117
|
* @param {string} sessionId
|
|
71
118
|
* @param {{provider:string, model?:string|null, tier?:string|null}} decision
|
|
119
|
+
* @param {{messageCount?:number|null, promptTokensEst?:number|null}} [stats]
|
|
72
120
|
*/
|
|
73
|
-
function
|
|
121
|
+
function setPin(sessionId, decision, stats = {}) {
|
|
74
122
|
if (!sessionId || !decision?.provider) return;
|
|
75
|
-
|
|
76
|
-
pins.delete(sessionId);
|
|
77
|
-
pins.set(sessionId, {
|
|
123
|
+
const pin = {
|
|
78
124
|
provider: decision.provider,
|
|
79
125
|
model: decision.model ?? null,
|
|
80
126
|
tier: decision.tier ?? null,
|
|
127
|
+
score: typeof decision.score === 'number' ? decision.score : null,
|
|
128
|
+
messageCount: stats.messageCount ?? null,
|
|
129
|
+
promptTokensEst: stats.promptTokensEst ?? null,
|
|
81
130
|
ts: Date.now(),
|
|
82
|
-
}
|
|
131
|
+
};
|
|
132
|
+
// Refresh insertion order so active sessions aren't evicted.
|
|
133
|
+
pins.delete(sessionId);
|
|
134
|
+
pins.set(sessionId, pin);
|
|
83
135
|
_evictIfNeeded();
|
|
136
|
+
store.save(sessionId, pin);
|
|
84
137
|
}
|
|
85
138
|
|
|
86
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* Decide whether the session should be re-routed from its current pin based
|
|
141
|
+
* on the incoming payload. Currently detects "compaction" — the client
|
|
142
|
+
* shrunk its message history (e.g. Claude Code's /compact), which resets
|
|
143
|
+
* the provider's prompt cache anyway, so we're free to re-route without
|
|
144
|
+
* incurring an extra cold-cache read.
|
|
145
|
+
*
|
|
146
|
+
* @param {Pin|null} pin
|
|
147
|
+
* @param {object} payload
|
|
148
|
+
* @returns {{repin: boolean, reason: string|null}}
|
|
149
|
+
*/
|
|
150
|
+
function shouldRepin(pin, payload) {
|
|
151
|
+
if (!pin) return { repin: true, reason: "no_pin" };
|
|
152
|
+
const currentMsgCount = Array.isArray(payload?.messages) ? payload.messages.length : 0;
|
|
153
|
+
const pinnedMsgCount = pin.messageCount ?? null;
|
|
154
|
+
// A 1-2 message payload against a long-conversation pin is NOT compaction
|
|
155
|
+
// — it's a new conversation whose opener collided with an old session's
|
|
156
|
+
// fingerprint (compaction always leaves summary + recent turns, never a
|
|
157
|
+
// bare opener). Mislabeling it lets the compaction floor pin greetings
|
|
158
|
+
// to expensive tiers.
|
|
159
|
+
if (pinnedMsgCount != null && currentMsgCount <= 2 && pinnedMsgCount > 4) {
|
|
160
|
+
return { repin: true, reason: "new_conversation" };
|
|
161
|
+
}
|
|
162
|
+
if (pinnedMsgCount != null && currentMsgCount < pinnedMsgCount - 2) {
|
|
163
|
+
return { repin: true, reason: "compaction" };
|
|
164
|
+
}
|
|
165
|
+
return { repin: false, reason: null };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Test/maintenance helper — clear the in-memory Map only. */
|
|
87
169
|
function _clear() {
|
|
88
170
|
pins.clear();
|
|
89
171
|
}
|
|
90
172
|
|
|
173
|
+
/** Test helper — clear both memory and persistent store. */
|
|
174
|
+
function _clearAll() {
|
|
175
|
+
pins.clear();
|
|
176
|
+
store._clear();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Legacy compatibility surface
|
|
181
|
+
//
|
|
182
|
+
// Preserved so existing call sites and tests keep working during the WS1
|
|
183
|
+
// rollout. New code should use getPin/setPin (which record message_count and
|
|
184
|
+
// prompt tokens for the sticky-routing triggers).
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/** @deprecated use getPin */
|
|
188
|
+
function getPinned(sessionId) {
|
|
189
|
+
const p = getPin(sessionId);
|
|
190
|
+
if (!p) return null;
|
|
191
|
+
return { provider: p.provider, model: p.model, tier: p.tier, ts: p.ts };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Drop a session's pin (memory + store). Used when a mid-tool-exchange
|
|
196
|
+
* frame carries embedded typed text that trips a trigger (force-cloud /
|
|
197
|
+
* autonomous): the frame itself must still serve the pin (tool_use ↔
|
|
198
|
+
* tool_result id linkage breaks on a mid-exchange switch), but the pin
|
|
199
|
+
* must not survive to the next turn boundary.
|
|
200
|
+
* @param {string} sessionId
|
|
201
|
+
*/
|
|
202
|
+
function removePin(sessionId) {
|
|
203
|
+
if (!sessionId) return;
|
|
204
|
+
pins.delete(sessionId);
|
|
205
|
+
store.remove(sessionId);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** @deprecated use setPin */
|
|
209
|
+
function setPinned(sessionId, decision) {
|
|
210
|
+
setPin(sessionId, decision);
|
|
211
|
+
}
|
|
212
|
+
|
|
91
213
|
module.exports = {
|
|
92
214
|
payloadHasToolHistory,
|
|
215
|
+
getPin,
|
|
216
|
+
setPin,
|
|
217
|
+
removePin,
|
|
218
|
+
shouldRepin,
|
|
219
|
+
// legacy
|
|
93
220
|
getPinned,
|
|
94
221
|
setPinned,
|
|
95
222
|
_clear,
|
|
223
|
+
_clearAll,
|
|
96
224
|
};
|