Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,34 @@ HEADROOM_LLMLINGUA=false
# Values: auto | cpu | cuda | mps
HEADROOM_LLMLINGUA_DEVICE=auto

# DESCRIPTION: TencentDB-Agent-Memory sidecar (team memory hub: L0-L3 chat
# memory, Skills, Wiki, CodeGraph). When enabled, `lynkr start` launches the
# memory-core + memory-hub containers from Docker Hub (agentmemory/*) — same
# pattern as the Headroom sidecar. Panel UI: http://localhost:8125
# Requires Docker (skips gracefully if unavailable). Containers persist
# across Lynkr restarts (remove with: docker rm -f tdai-memory-core tdai-memory-hub).
# Values: true | false
TENCENTDB_MEMORY_ENABLED=true
# DESCRIPTION: Let Lynkr manage the containers. Set false if you run the
# stack yourself via the project's deploy scripts.
# Values: true | false
# TENCENTDB_MEMORY_DOCKER_ENABLED=true
# DESCRIPTION: LLM endpoint the memory services use for extraction and wiki
# ingest. Defaults to Lynkr's own OpenAI-compatible endpoint (tier routing
# picks the model), so no extra API key is needed. Override to point at a
# provider directly.
# TENCENTDB_MEMORY_LLM_BASE_URL=http://host.docker.internal:8081/v1
# TENCENTDB_MEMORY_LLM_API_KEY=lynkr-local
# TENCENTDB_MEMORY_LLM_MODEL=auto
# DESCRIPTION: Memory extraction style. `code` extracts changes/issues/tool
# usage (coding agents); `chat` extracts general conversational facts.
# Values: code | chat
# TENCENTDB_MEMORY_PROMPT_MODE=code
# DESCRIPTION: Host port overrides (defaults shown).
# TENCENTDB_MEMORY_CORE_PORT=8420
# TENCENTDB_MEMORY_PANEL_PORT=8125
# TENCENTDB_MEMORY_KNOWLEDGE_PORT=8424

# DESCRIPTION: Master switch for the long-term Titans-inspired memory system.
# Values: true | false
MEMORY_ENABLED=true
Expand Down
7 changes: 7 additions & 0 deletions bin/lynkr-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,13 @@ const BASELINE_ENV = {
HEADROOM_CCR: 'true',
HEADROOM_CCR_TTL: '300',

// ── TencentDB-Agent-Memory sidecar (team memory hub) ──────────────────
// Launches agentmemory/memory-core + memory-hub containers on
// `lynkr start` (Panel UI :8125). First start pulls two Docker Hub
// images; skips gracefully when Docker isn't running. See .env.example
// for all knobs.
TENCENTDB_MEMORY_ENABLED: 'true',

// ── Memory + token tracking ───────────────────────────────────────────
MEMORY_ENABLED: 'true',
MEMORY_RETRIEVAL_LIMIT: '5',
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
"dev": "nodemon index.js",
"lint": "eslint src index.js",
"test": "npm run test:unit && npm run test:performance",
"test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js",
"test:memory": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js",
"test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js",
"test:memory": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js",
"test:new-features": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js",
"test:performance": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/hybrid-routing-performance.test.js && DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-tests.js",
"test:benchmark": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-benchmark.js",
Expand Down
29 changes: 29 additions & 0 deletions public/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,35 @@ <h3 class="text-sm font-semibold text-slate-300 mb-4">Routing Accuracy (last ${d
` : emptyState('No routing data for last 24h')}
`, 'mb-6')}

<!-- Cache Economics (cache-aware routing receipt) -->
${card(`
<h3 class="text-sm font-semibold text-slate-300 mb-4">Cache Economics (last ${d.window || '24h'})</h3>
${d.cacheEconomics && d.cacheEconomics.decisions > 0 ? `
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="text-center">
<p class="text-2xl font-bold text-green-400">${fmt.usd(d.cacheEconomics.totalDollarsSaved)}</p>
<p class="text-xs text-slate-500 mt-1">Cache $ saved by routing</p>
<p class="text-xs text-slate-600">(holds + cleared switches)</p>
</div>
<div class="text-center">
<p class="text-2xl font-bold">${fmt.num(d.cacheEconomics.holds)}</p>
<p class="text-xs text-slate-500 mt-1">Switches held</p>
<p class="text-xs text-slate-600">(warm prefix protected)</p>
</div>
<div class="text-center">
<p class="text-2xl font-bold">${fmt.num(d.cacheEconomics.switches)}</p>
<p class="text-xs text-slate-500 mt-1">Switches cleared</p>
<p class="text-xs text-slate-600">(break-even ≤ remaining turns)</p>
</div>
<div class="text-center">
<p class="text-2xl font-bold text-amber-400">${fmt.usd(d.cacheEconomics.dollarsSavedByHolds)}</p>
<p class="text-xs text-slate-500 mt-1">Saved by holding pins</p>
<p class="text-xs text-slate-600">(cache re-writes avoided)</p>
</div>
</div>
` : emptyState('No gated switch decisions yet — appears once a pinned session hits a downgrade decision')}
`, 'mb-6')}

<!-- Provider Stats -->
${card(`
<h3 class="text-sm font-semibold text-slate-300 mb-3">Provider Stats (last ${d.window || '24h'})</h3>
Expand Down
4 changes: 4 additions & 0 deletions src/clients/databricks.js
Original file line number Diff line number Diff line change
Expand Up @@ -2999,6 +2999,7 @@ async function invokeModel(body, options = {}) {
candidates: routingResult.candidates ?? null,
pinned: routingResult.pinned ? 1 : 0,
switch_reason: routingResult.switch_reason ?? null,
cache_decision: routingResult._cacheDecision ?? null,
});

// WS5.4 — feedback loop (success path).
Expand Down Expand Up @@ -3230,6 +3231,7 @@ async function invokeModel(body, options = {}) {
candidates: routingResult.candidates ?? null,
pinned: routingResult.pinned ? 1 : 0,
switch_reason: routingResult.switch_reason ?? null,
cache_decision: routingResult._cacheDecision ?? null,
});

// WS5.4 — feedback loop (primary-failed, no fallback). Low quality
Expand Down Expand Up @@ -3348,6 +3350,7 @@ async function invokeModel(body, options = {}) {
candidates: routingResult.candidates ?? null,
pinned: routingResult.pinned ? 1 : 0,
switch_reason: routingResult.switch_reason ?? null,
cache_decision: routingResult._cacheDecision ?? null,
});

// WS5.4 — feedback loop (fallback success). The served provider
Expand Down Expand Up @@ -3415,6 +3418,7 @@ async function invokeModel(body, options = {}) {
candidates: routingResult.candidates ?? null,
pinned: routingResult.pinned ? 1 : 0,
switch_reason: routingResult.switch_reason ?? null,
cache_decision: routingResult._cacheDecision ?? null,
});

// WS5.4 — feedback loop (double failure). quality=0 is a hard
Expand Down
123 changes: 93 additions & 30 deletions src/clients/prompt-cache-injection.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,24 @@
* Injects `cache_control` breakpoints into requests for providers
* that support explicit prompt caching (Anthropic, Bedrock, Vertex/Gemini).
*
* Strategy: "system_and_3" — places up to 4 breakpoints:
* 1. System prompt (stable across turns — highest cache hit rate)
* 2-4. Last 3 non-system messages (rolling window)
* Strategy: "stable_hierarchy" — up to 4 breakpoints ordered by stability
* (Phase 5, cache-aware routing):
* 1. Tools block — never moves (tools render before system in the
* provider's prefix, so this read point survives system edits)
* 2. System prompt — never moves
* 3. Frozen history boundary — advances only every K user turns
* (K = config.memory.distillation.refreshEveryTurns, default 5; shared
* with the distiller's freeze window). Deterministic from the message
* list, so consecutive requests inside a bucket mark the same bytes.
* 4. Rolling marker on the newest message — pays the 1.25x write on the
* per-turn delta once so the next turn reads it at 0.1x. Kept
* deliberately: dropping it would re-pay full input price on
* everything after the boundary every turn until the next refresh.
*
* The previous "system_and_3" strategy rolled breakpoints 2-4 across the
* last three messages; markers moved every turn, and history-rewriting
* layers (distiller) invalidated the prefix wholesale. Stability of the
* marked bytes is what compounds hits.
*
* Providers with automatic caching (OpenAI, DeepSeek) need no injection.
*
Expand All @@ -17,20 +32,81 @@ const logger = require('../logger');

const CACHE_MARKER = { type: 'ephemeral' };
const MAX_BREAKPOINTS = 4;
const DEFAULT_BOUNDARY_EVERY_TURNS = 5;

function _boundaryEveryTurns() {
try {
const config = require('../config');
const k = config.memory?.distillation?.refreshEveryTurns;
return Number.isFinite(k) && k > 0 ? k : DEFAULT_BOUNDARY_EVERY_TURNS;
} catch {
return DEFAULT_BOUNDARY_EVERY_TURNS;
}
}

/** Mark the last content block of a message; converts string content. */
function _markMessage(msg) {
if (!msg) return false;
if (typeof msg.content === 'string') {
msg.content = [{
type: 'text',
text: msg.content,
cache_control: CACHE_MARKER,
}];
return true;
}
if (Array.isArray(msg.content) && msg.content.length > 0) {
const lastBlock = msg.content[msg.content.length - 1];
if (lastBlock && typeof lastBlock === 'object' && !lastBlock.cache_control) {
lastBlock.cache_control = CACHE_MARKER;
return true;
}
}
return false;
}

/**
* Index of the frozen-boundary message: the bucket-th user-role message,
* where bucket = floor(userTurns / K) * K. Deterministic in the message
* list, so every request inside a K-turn bucket marks the same message —
* the marked prefix bytes stay identical until the bucket advances.
*
* @returns {number} message index, or -1 when the conversation is too
* young (bucket < K) or the boundary can't be placed.
*/
function _frozenBoundaryIndex(messages, everyTurns) {
if (!Array.isArray(messages) || messages.length === 0) return -1;
const userIdx = [];
for (let i = 0; i < messages.length; i++) {
if (messages[i]?.role === 'user') userIdx.push(i);
}
const bucket = Math.floor(userIdx.length / everyTurns) * everyTurns;
if (bucket < everyTurns) return -1;
return userIdx[bucket - 1];
}

/**
* Inject cache_control breakpoints into an Anthropic-format request body.
* Mutates the body in-place for zero-copy performance.
*
* @param {Object} body - Request body with system and messages
* @param {Object} body - Request body with system, tools, and messages
* @returns {number} Number of breakpoints injected
*/
function injectAnthropicCacheBreakpoints(body) {
if (!body) return 0;

let injected = 0;

// Breakpoint 1: System prompt
// Breakpoint 1: tools block — most stable prefix region.
if (Array.isArray(body.tools) && body.tools.length > 0) {
const lastTool = body.tools[body.tools.length - 1];
if (lastTool && typeof lastTool === 'object' && !lastTool.cache_control) {
lastTool.cache_control = CACHE_MARKER;
injected++;
}
}

// Breakpoint 2: system prompt.
if (body.system) {
if (typeof body.system === 'string') {
// Convert string system to array format for cache_control support
Expand All @@ -50,32 +126,19 @@ function injectAnthropicCacheBreakpoints(body) {
}
}

// Breakpoints 2-4: Last 3 non-system messages
if (Array.isArray(body.messages) && body.messages.length > 0) {
const remaining = MAX_BREAKPOINTS - injected;
const messagesToMark = Math.min(remaining, 3, body.messages.length);

for (let i = 0; i < messagesToMark; i++) {
const msgIdx = body.messages.length - 1 - i;
const msg = body.messages[msgIdx];
if (!msg) continue;

if (typeof msg.content === 'string') {
// Convert string content to array for cache_control
msg.content = [{
type: 'text',
text: msg.content,
cache_control: CACHE_MARKER,
}];
injected++;
} else if (Array.isArray(msg.content) && msg.content.length > 0) {
// Mark the last content block in this message
const lastBlock = msg.content[msg.content.length - 1];
if (lastBlock && typeof lastBlock === 'object' && !lastBlock.cache_control) {
lastBlock.cache_control = CACHE_MARKER;
injected++;
}
}
const lastIdx = body.messages.length - 1;

// Breakpoint 3: frozen history boundary (advances every K user turns).
const boundaryIdx = _frozenBoundaryIndex(body.messages, _boundaryEveryTurns());
if (boundaryIdx >= 0 && boundaryIdx < lastIdx && injected < MAX_BREAKPOINTS) {
if (_markMessage(body.messages[boundaryIdx])) injected++;
}

// Breakpoint 4: rolling marker on the newest message — caches this
// turn's delta so the next turn reads it instead of re-paying input.
if (injected < MAX_BREAKPOINTS) {
if (_markMessage(body.messages[lastIdx])) injected++;
}
}

Expand Down
Loading
Loading