Skip to content

Commit 1303a20

Browse files
iPythoningclaude
andcommitted
feat: cherry-pick 3 ideas from OpenViking — dual-threshold, ranking, expand
1. Dual-threshold compression (proactive-summary.mjs): - 50% BACKGROUND_SAVE: extract key facts to ChromaDB without compressing - 65% COMPRESS: full L2 compression (existing behavior) - Backward-compatible exports (TOKEN_THRESHOLD still available) 2. Memory re-ranking (chroma.mjs): - Replace simple word-count scoring with 3-factor ranking: lexical overlap (normalized) + recency decay + tag weighting - Fix bug: `score + 0.5` → properly integrated into rankResult() 3. Archive expand (chroma.mjs): - New `expand <turn_id>` command to view full original text - Searches all customer directories by ID Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 21e4eca commit 1303a20

3 files changed

Lines changed: 131 additions & 28 deletions

File tree

scripts/proactive-summary.mjs

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,23 @@
22
/**
33
* proactive-summary — L2 Proactive Compaction Trigger
44
*
5-
* Monitors token usage and triggers conversation compression at 65% threshold.
5+
* Dual-threshold token monitoring with background save + forced compression.
66
* Uses a small/fast model (haiku-class) for compression to minimize cost.
77
* Compressed summaries are stored in ChromaDB for long-term retrieval.
88
*
9+
* Thresholds:
10+
* - 50% (BACKGROUND_THRESHOLD): Non-blocking — extract key facts to ChromaDB without compressing
11+
* - 65% (COMPRESS_THRESHOLD): Blocking — full L2 compression (compress + store + trim)
12+
*
913
* Integration:
10-
* - Called by OpenClaw's compaction.memoryFlush hook (softThresholdTokens: 12000)
14+
* - Called by OpenClaw's compaction.memoryFlush hook (softThresholdTokens: 10000)
1115
* - Can also run standalone: node proactive-summary.mjs --context <file> --output <file>
1216
*
1317
* Architecture:
1418
* 1. Read current conversation context
15-
* 2. Estimate token usage (if >= 65% of model's context window, trigger)
16-
* 3. Extract key facts → update MemOS (safety net)
17-
* 4. Compress with haiku-class model (fast, cheap)
19+
* 2. Estimate token usage → tri-state: OK / BACKGROUND_SAVE / COMPRESS
20+
* 3. BACKGROUND_SAVE: Extract key facts to ChromaDB (no compression, no blocking)
21+
* 4. COMPRESS: Full compression via haiku-class model (fast, cheap)
1822
* 5. Store compressed summary in ChromaDB
1923
* 6. Return compressed messages (summary + last 3 raw turns)
2024
*/
@@ -53,7 +57,10 @@ Compress the conversation history into a structured summary with ZERO informatio
5357
- [topic summary]
5458
=== End ===`;
5559

56-
const TOKEN_THRESHOLD = parseFloat(process.env.TOKEN_THRESHOLD || '0.65');
60+
const COMPRESS_THRESHOLD = parseFloat(process.env.COMPRESS_THRESHOLD || '0.65');
61+
const BACKGROUND_THRESHOLD = parseFloat(process.env.BACKGROUND_THRESHOLD || '0.50');
62+
// Backward compat: old env var still works
63+
const TOKEN_THRESHOLD = COMPRESS_THRESHOLD;
5764

5865
const MODEL_CONTEXT_WINDOWS = {
5966
'claude-haiku-4-5': 200000,
@@ -73,15 +80,42 @@ function estimateTokens(text) {
7380
return Math.ceil(otherChars / 4 + cjkChars / 2);
7481
}
7582

83+
const BACKGROUND_SAVE_PROMPT = `Extract key facts from this conversation for background storage. Do NOT compress — just list the important data points.
84+
85+
## Extract:
86+
- Customer BANT data (Budget, Authority, Need, Timeline)
87+
- All numbers (prices, quantities, dates, percentages)
88+
- Commitments from both sides
89+
- Decision signals and objections
90+
- Stage progression
91+
92+
## Output format:
93+
=== Key Facts Snapshot (${new Date().toISOString()}) ===
94+
[Facts]
95+
1. fact
96+
2. fact
97+
[Numbers]
98+
- exact figure or quote
99+
[Open Items]
100+
- pending action or decision
101+
=== End ===`;
102+
76103
function shouldTrigger(contextText, model) {
77104
const tokens = estimateTokens(contextText);
78105
const maxTokens = MODEL_CONTEXT_WINDOWS[model] || 128000;
79106
const usage = tokens / maxTokens;
80-
return { trigger: usage >= TOKEN_THRESHOLD, usage, tokens, maxTokens };
107+
let action = 'OK';
108+
if (usage >= COMPRESS_THRESHOLD) action = 'COMPRESS';
109+
else if (usage >= BACKGROUND_THRESHOLD) action = 'BACKGROUND_SAVE';
110+
return { trigger: action !== 'OK', action, usage, tokens, maxTokens };
81111
}
82112

83113
// Export for use as a module
84-
export { COMPRESSION_PROMPT, TOKEN_THRESHOLD, estimateTokens, shouldTrigger };
114+
export {
115+
COMPRESSION_PROMPT, BACKGROUND_SAVE_PROMPT,
116+
TOKEN_THRESHOLD, COMPRESS_THRESHOLD, BACKGROUND_THRESHOLD,
117+
estimateTokens, shouldTrigger,
118+
};
85119

86120
// ─── CLI ────────────────────────────────────────────────────
87121
if (process.argv[1]?.endsWith('proactive-summary.mjs')) {
@@ -90,7 +124,7 @@ if (process.argv[1]?.endsWith('proactive-summary.mjs')) {
90124
const contextFile = args.find((_, i) => args[i - 1] === '--context');
91125

92126
if (args.includes('--help') || !contextFile) {
93-
console.log(`proactive-summary — L2 Proactive Compaction
127+
console.log(`proactive-summary — Dual-Threshold Compaction
94128
95129
Usage:
96130
node proactive-summary.mjs --context <file> [--model <model>]
@@ -100,10 +134,13 @@ Options:
100134
--model AI model name for threshold calculation (default: gpt-4o)
101135
102136
Environment:
103-
TOKEN_THRESHOLD Trigger threshold (default: 0.65)
137+
BACKGROUND_THRESHOLD Background save threshold (default: 0.50)
138+
COMPRESS_THRESHOLD Compression threshold (default: 0.65)
104139
105-
This script checks if compaction should trigger and outputs the compression prompt.
106-
Actual compression is performed by the OpenClaw agent using its configured model.`);
140+
Actions:
141+
OK — Below 50%, no action needed
142+
BACKGROUND_SAVE — 50-65%, extract key facts to ChromaDB (non-blocking)
143+
COMPRESS — Above 65%, full L2 compression (blocking)`);
107144
process.exit(0);
108145
}
109146

@@ -112,12 +149,16 @@ Actual compression is performed by the OpenClaw agent using its configured model
112149
const context = readFileSync(contextFile, 'utf-8');
113150
const result = shouldTrigger(context, model);
114151

152+
const prompt = result.action === 'COMPRESS' ? COMPRESSION_PROMPT
153+
: result.action === 'BACKGROUND_SAVE' ? BACKGROUND_SAVE_PROMPT
154+
: null;
155+
115156
console.log(JSON.stringify({
116157
...result,
117158
model,
118-
threshold: TOKEN_THRESHOLD,
119-
action: result.trigger ? 'COMPRESS' : 'OK',
120-
compressionPrompt: result.trigger ? COMPRESSION_PROMPT : null,
159+
backgroundThreshold: BACKGROUND_THRESHOLD,
160+
compressThreshold: COMPRESS_THRESHOLD,
161+
prompt,
121162
}, null, 2));
122163
} catch (e) {
123164
console.error(`Error: ${e.message}`);

skills/chroma-memory/chroma.mjs

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* node chroma.mjs store --customer <id> --turn <n> --user <msg> --agent <msg> [--stage <s>] [--topic <t>]
1010
* node chroma.mjs search <query> [--customer <id>] [--limit <n>]
1111
* node chroma.mjs recall <customer_id> [--limit <n>]
12+
* node chroma.mjs expand <turn_id>
1213
* node chroma.mjs snapshot
1314
* node chroma.mjs stats
1415
*/
@@ -74,6 +75,28 @@ function storeTurn({ customer, turn, user, agent, stage, topic }) {
7475
return id;
7576
}
7677

78+
// ─── Ranking: lexical overlap + recency decay + tag boost ─────
79+
const TAG_WEIGHTS = { has_order: 0.12, has_quote: 0.10, has_commitment: 0.10, has_objection: 0.08, has_sample: 0.05 };
80+
81+
function rankResult(doc, queryWords) {
82+
// Factor 1: normalized lexical overlap (0-1)
83+
const textLower = doc.text.toLowerCase();
84+
const matchCount = queryWords.reduce((s, w) => s + (textLower.includes(w) ? 1 : 0), 0);
85+
const lexical = queryWords.length > 0 ? matchCount / queryWords.length : 0;
86+
87+
// Factor 2: recency decay — half-life 30 days, floor at 0.5
88+
const ageDays = doc.timestamp ? (Date.now() - new Date(doc.timestamp).getTime()) / 86400000 : 30;
89+
const recency = Math.max(0.5, 1 - ageDays / 60);
90+
91+
// Factor 3: tag boost
92+
let tagBoost = 0;
93+
for (const [tag, weight] of Object.entries(TAG_WEIGHTS)) {
94+
if (doc[tag]) tagBoost += weight;
95+
}
96+
97+
return lexical * 0.5 + recency * 0.3 + tagBoost;
98+
}
99+
77100
function search(query, customer, limit = 5) {
78101
const queryWords = query.toLowerCase().split(/\s+/);
79102
const results = [];
@@ -88,13 +111,9 @@ function search(query, customer, limit = 5) {
88111
for (const file of readdirSync(dir).filter(f => f.endsWith('.json'))) {
89112
try {
90113
const doc = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
91-
if (doc.type === 'crm_snapshot') continue; // skip snapshots in search
92-
const textLower = doc.text.toLowerCase();
93-
const score = queryWords.reduce((s, w) => s + (textLower.includes(w) ? 1 : 0), 0);
94-
// Boost tagged turns
95-
if (doc.has_quote) score + 0.5;
96-
if (doc.has_commitment) score + 0.5;
97-
if (score > 0) results.push({ ...doc, score });
114+
if (doc.type === 'crm_snapshot') continue;
115+
const score = rankResult(doc, queryWords);
116+
if (score > 0.1) results.push({ ...doc, score: Math.round(score * 1000) / 1000 });
98117
} catch { /* skip */ }
99118
}
100119
}
@@ -138,6 +157,38 @@ function snapshot() {
138157
console.log(`CRM snapshot marker created: ${id}`);
139158
}
140159

160+
function expand(id) {
161+
const dirs = readdirSync(CHROMA_DIR, { withFileTypes: true })
162+
.filter(d => d.isDirectory())
163+
.map(d => join(CHROMA_DIR, d.name));
164+
165+
for (const dir of dirs) {
166+
const file = join(dir, `${id}.json`);
167+
if (existsSync(file)) {
168+
const doc = JSON.parse(readFileSync(file, 'utf-8'));
169+
console.log(`=== Turn ${doc.turn_number || '?'}${doc.customer_id || 'unknown'} (${doc.timestamp || '?'}) ===`);
170+
console.log(`Stage: ${doc.stage || 'unknown'} | Topic: ${doc.topic || 'general'}`);
171+
const tags = Object.entries(TAG_WEIGHTS).filter(([t]) => doc[t]).map(([t]) => t);
172+
if (tags.length) console.log(`Tags: ${tags.join(', ')}`);
173+
console.log(`\n--- Customer ---\n${doc.user_message || '(empty)'}`);
174+
console.log(`\n--- Agent ---\n${doc.agent_response || '(empty)'}`);
175+
console.log(`\n=== End ===`);
176+
return doc;
177+
}
178+
}
179+
180+
// Also check top-level files (snapshots)
181+
const topFile = join(CHROMA_DIR, `${id}.json`);
182+
if (existsSync(topFile)) {
183+
const doc = JSON.parse(readFileSync(topFile, 'utf-8'));
184+
console.log(JSON.stringify(doc, null, 2));
185+
return doc;
186+
}
187+
188+
console.log(`Turn ${id} not found in any customer directory.`);
189+
return null;
190+
}
191+
141192
function stats() {
142193
let totalTurns = 0;
143194
let totalSnapshots = 0;
@@ -208,12 +259,15 @@ switch (command) {
208259
case 'recall':
209260
console.log(JSON.stringify(recall(opts._positional || args[0], parseInt(opts.limit) || 10), null, 2));
210261
break;
262+
case 'expand':
263+
expand(opts._positional || args[0]);
264+
break;
211265
case 'snapshot':
212266
snapshot();
213267
break;
214268
case 'stats':
215269
stats();
216270
break;
217271
default:
218-
console.log('Usage: chroma.mjs <store|search|recall|snapshot|stats> [args]');
272+
console.log('Usage: chroma.mjs <store|search|recall|expand|snapshot|stats> [args]');
219273
}

workspace/MEMORY.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@
55
```
66
Message In → L1 MemOS auto-recall
77
→ L3 chroma:store (every turn)
8-
→ L2 proactive-summary (at 65% token usage)
8+
→ L2 dual-threshold (50% background save → 65% compress)
99
→ L4 CRM snapshot (daily 12:00 fallback)
1010
```
1111

1212
| Layer | Engine | How It Works | Your Action |
1313
|-------|--------|-------------|-------------|
1414
| **L1: MemOS** | Structured memory | Auto-injects past memories at conversation start, auto-captures BANT/commitments/objections at end | Read what it gives you |
15-
| **L2: Proactive Summary** | Token monitoring | Compresses at 65% context usage via haiku-class model. Zero info loss on numbers/quotes/commitments | Embed key-data summary past 20 turns |
16-
| **L3: ChromaDB** | Per-turn vector store | Every turn stored with customer_id isolation + auto-tagging (quotes, commitments, objections) | Use `chroma:search` before outreach |
15+
| **L2: Proactive Summary** | Dual-threshold monitoring | **50%**: background save key facts to ChromaDB (non-blocking). **65%**: full compression via haiku-class model. Zero info loss on numbers/quotes/commitments | Embed key-data summary past 20 turns |
16+
| **L3: ChromaDB** | Per-turn store | Every turn stored with customer_id isolation + auto-tagging. Search uses recency-weighted ranking | Use `chroma:search` before outreach |
1717
| **L4: CRM Snapshot** | Daily backup | 12:00 daily pipeline snapshot to ChromaDB as disaster recovery | None — automatic |
1818

1919
## Operating Rules (Every Conversation)
@@ -43,6 +43,7 @@ memory:stats
4343
chroma:store --customer "+971501234567" --turn 5 --user "price?" --agent "let me quote..." --stage qualifying --topic pricing
4444
chroma:search "pricing discussion Dubai" --customer "+971501234567" --limit 5
4545
chroma:recall "+971501234567" --limit 10
46+
chroma:expand <turn_id> -- View full original text of a compressed/archived turn
4647
chroma:snapshot
4748
chroma:stats
4849
```
@@ -80,16 +81,23 @@ Every stored turn is automatically analyzed and tagged:
8081
| `has_order` | "place order", "confirm purchase", "deposit" |
8182
| `has_sample` | "sample", "trial", "prototype" |
8283

83-
## L2 Compression Rules
84+
## L2 Dual-Threshold Compression
8485

85-
When token usage hits 65%, the proactive summary engine:
86+
**At 50% token usage** (BACKGROUND_SAVE):
87+
- Non-blocking background extraction of key facts
88+
- Facts stored to ChromaDB — no conversation compression
89+
- Protects critical data early in case of unexpected context loss
90+
91+
**At 65% token usage** (COMPRESS):
8692
1. Updates MemOS first (safety net)
8793
2. Compresses with haiku-class model (fast, cheap)
8894
3. **Preserves verbatim**: all numbers, quotes, commitments, BANT data
8995
4. **Compresses**: small talk, repeated intros, multi-round confirmations
9096
5. Stores compressed summary in ChromaDB
9197
6. Keeps last 3 raw turns uncompressed
9298

99+
**Recover compressed turns**: Use `chroma:expand <turn_id>` to view full original text.
100+
93101
## CRM Column Mapping
94102
> See USER.md → CRM Configuration
95103

0 commit comments

Comments
 (0)