clawmem 0.1.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/AGENTS.md +660 -0
- package/CLAUDE.md +660 -0
- package/LICENSE +21 -0
- package/README.md +993 -0
- package/SKILL.md +717 -0
- package/bin/clawmem +75 -0
- package/package.json +72 -0
- package/src/amem.ts +797 -0
- package/src/beads.ts +263 -0
- package/src/clawmem.ts +1849 -0
- package/src/collections.ts +405 -0
- package/src/config.ts +178 -0
- package/src/consolidation.ts +123 -0
- package/src/directory-context.ts +248 -0
- package/src/errors.ts +41 -0
- package/src/formatter.ts +427 -0
- package/src/graph-traversal.ts +247 -0
- package/src/hooks/context-surfacing.ts +317 -0
- package/src/hooks/curator-nudge.ts +89 -0
- package/src/hooks/decision-extractor.ts +639 -0
- package/src/hooks/feedback-loop.ts +214 -0
- package/src/hooks/handoff-generator.ts +345 -0
- package/src/hooks/postcompact-inject.ts +226 -0
- package/src/hooks/precompact-extract.ts +314 -0
- package/src/hooks/pretool-inject.ts +79 -0
- package/src/hooks/session-bootstrap.ts +324 -0
- package/src/hooks/staleness-check.ts +130 -0
- package/src/hooks.ts +367 -0
- package/src/indexer.ts +327 -0
- package/src/intent.ts +294 -0
- package/src/limits.ts +26 -0
- package/src/llm.ts +1175 -0
- package/src/mcp.ts +2138 -0
- package/src/memory.ts +336 -0
- package/src/mmr.ts +93 -0
- package/src/observer.ts +269 -0
- package/src/openclaw/engine.ts +283 -0
- package/src/openclaw/index.ts +221 -0
- package/src/openclaw/plugin.json +83 -0
- package/src/openclaw/shell.ts +207 -0
- package/src/openclaw/tools.ts +304 -0
- package/src/profile.ts +346 -0
- package/src/promptguard.ts +218 -0
- package/src/retrieval-gate.ts +106 -0
- package/src/search-utils.ts +127 -0
- package/src/server.ts +783 -0
- package/src/splitter.ts +325 -0
- package/src/store.ts +4062 -0
- package/src/validation.ts +67 -0
- package/src/watcher.ts +58 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Surfacing Hook - UserPromptSubmit
|
|
3
|
+
*
|
|
4
|
+
* Fires on every user message. Searches the vault for relevant context,
|
|
5
|
+
* applies SAME composite scoring, enforces a token budget, and injects
|
|
6
|
+
* the most relevant notes as additional context for Claude.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Store, SearchResult } from "../store.ts";
|
|
10
|
+
import { DEFAULT_EMBED_MODEL, extractSnippet } from "../store.ts";
|
|
11
|
+
import type { HookInput, HookOutput } from "../hooks.ts";
|
|
12
|
+
import {
|
|
13
|
+
makeContextOutput,
|
|
14
|
+
makeEmptyOutput,
|
|
15
|
+
smartTruncate,
|
|
16
|
+
estimateTokens,
|
|
17
|
+
logInjection,
|
|
18
|
+
isHeartbeatPrompt,
|
|
19
|
+
wasPromptSeenRecently,
|
|
20
|
+
} from "../hooks.ts";
|
|
21
|
+
import {
|
|
22
|
+
applyCompositeScoring,
|
|
23
|
+
hasRecencyIntent,
|
|
24
|
+
inferMemoryType,
|
|
25
|
+
type EnrichedResult,
|
|
26
|
+
type ScoredResult,
|
|
27
|
+
} from "../memory.ts";
|
|
28
|
+
import { enrichResults } from "../search-utils.ts";
|
|
29
|
+
import { sanitizeSnippet } from "../promptguard.ts";
|
|
30
|
+
import { shouldSkipRetrieval, isRetrievedNoise } from "../retrieval-gate.ts";
|
|
31
|
+
import { MAX_QUERY_LENGTH } from "../limits.ts";
|
|
32
|
+
import { getActiveProfile } from "../config.ts";
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// Config
|
|
36
|
+
// =============================================================================
|
|
37
|
+
|
|
38
|
+
// Profile-driven defaults (overridden by CLAWMEM_PROFILE env var)
|
|
39
|
+
const DEFAULT_TOKEN_BUDGET = 800;
|
|
40
|
+
const DEFAULT_MAX_RESULTS = 10;
|
|
41
|
+
const DEFAULT_MIN_SCORE = 0.45;
|
|
42
|
+
const MIN_COMPOSITE_SCORE_RECENCY = 0.35;
|
|
43
|
+
const MIN_PROMPT_LENGTH = 20;
|
|
44
|
+
|
|
45
|
+
// Tiered injection: HOT gets full snippets, WARM gets shorter, COLD gets title-only
|
|
46
|
+
function getTierConfig(score: number): { snippetLen: number; showMeta: boolean; tier: string } {
|
|
47
|
+
if (score > 0.8) return { snippetLen: 300, showMeta: true, tier: "HOT" };
|
|
48
|
+
if (score > 0.6) return { snippetLen: 150, showMeta: false, tier: "WARM" };
|
|
49
|
+
return { snippetLen: 0, showMeta: false, tier: "COLD" };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Directories to never surface
|
|
53
|
+
const FILTERED_PATHS = ["_PRIVATE/", "experiments/", "_clawmem/"];
|
|
54
|
+
|
|
55
|
+
// File path patterns to extract from prompts (E13: file-aware UserPromptSubmit)
|
|
56
|
+
const FILE_PATH_RE = /(?:^|\s)((?:\/[\w.@-]+)+(?:\.\w+)?|[\w.@-]+\.(?:ts|js|py|md|sh|yaml|yml|json|toml|rs|go|tsx|jsx|css|html))\b/g;
|
|
57
|
+
|
|
58
|
+
// =============================================================================
|
|
59
|
+
// Handler
|
|
60
|
+
// =============================================================================
|
|
61
|
+
|
|
62
|
+
export async function contextSurfacing(
|
|
63
|
+
store: Store,
|
|
64
|
+
input: HookInput
|
|
65
|
+
): Promise<HookOutput> {
|
|
66
|
+
let prompt = input.prompt?.trim();
|
|
67
|
+
if (!prompt || prompt.length < MIN_PROMPT_LENGTH) return makeEmptyOutput("context-surfacing");
|
|
68
|
+
|
|
69
|
+
// Bound query length to prevent DoS on search indices
|
|
70
|
+
if (prompt.length > MAX_QUERY_LENGTH) prompt = prompt.slice(0, MAX_QUERY_LENGTH);
|
|
71
|
+
|
|
72
|
+
// Skip slash commands
|
|
73
|
+
if (prompt.startsWith("/")) return makeEmptyOutput("context-surfacing");
|
|
74
|
+
|
|
75
|
+
// Adaptive retrieval gate: skip greetings, shell commands, affirmations, etc.
|
|
76
|
+
if (shouldSkipRetrieval(prompt)) return makeEmptyOutput("context-surfacing");
|
|
77
|
+
|
|
78
|
+
// Heartbeat / duplicate suppression (IO4)
|
|
79
|
+
if (isHeartbeatPrompt(prompt)) return makeEmptyOutput("context-surfacing");
|
|
80
|
+
if (wasPromptSeenRecently(store, "context-surfacing", prompt)) {
|
|
81
|
+
return makeEmptyOutput("context-surfacing");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Load active performance profile
|
|
85
|
+
const profile = getActiveProfile();
|
|
86
|
+
const maxResults = profile.maxResults;
|
|
87
|
+
const tokenBudget = profile.tokenBudget;
|
|
88
|
+
|
|
89
|
+
const isRecency = hasRecencyIntent(prompt);
|
|
90
|
+
const minScore = isRecency ? MIN_COMPOSITE_SCORE_RECENCY : profile.minScore;
|
|
91
|
+
|
|
92
|
+
// Search: try vector first (if profile allows), fall back to BM25
|
|
93
|
+
// When vector succeeds, also supplement with FTS for keyword-exact recall
|
|
94
|
+
let results: SearchResult[] = [];
|
|
95
|
+
if (profile.useVector) {
|
|
96
|
+
try {
|
|
97
|
+
const vectorPromise = store.searchVec(prompt, DEFAULT_EMBED_MODEL, maxResults);
|
|
98
|
+
const timeoutPromise = new Promise<SearchResult[]>((_, reject) =>
|
|
99
|
+
setTimeout(() => reject(new Error("vector timeout")), profile.vectorTimeout)
|
|
100
|
+
);
|
|
101
|
+
results = await Promise.race([vectorPromise, timeoutPromise]);
|
|
102
|
+
} catch {
|
|
103
|
+
// Vector search unavailable, timed out, or errored — fall back to BM25
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (results.length === 0) {
|
|
108
|
+
results = store.searchFTS(prompt, maxResults);
|
|
109
|
+
} else {
|
|
110
|
+
// Supplement vector results with FTS for keyword-exact matches (<10ms)
|
|
111
|
+
const seen = new Set(results.map(r => r.filepath));
|
|
112
|
+
const ftsSupplemental = store.searchFTS(prompt, 5);
|
|
113
|
+
for (const r of ftsSupplemental) {
|
|
114
|
+
if (!seen.has(r.filepath)) {
|
|
115
|
+
seen.add(r.filepath);
|
|
116
|
+
results.push(r);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// File-aware supplemental search (E13): extract file paths/names from prompt
|
|
122
|
+
// and run targeted FTS queries to surface file-specific vault context
|
|
123
|
+
const fileMatches = [...prompt.matchAll(FILE_PATH_RE)].map(m => m[1]!.trim()).filter(Boolean);
|
|
124
|
+
if (fileMatches.length > 0) {
|
|
125
|
+
const seen = new Set(results.map(r => r.filepath));
|
|
126
|
+
for (const fp of fileMatches.slice(0, 3)) {
|
|
127
|
+
try {
|
|
128
|
+
const fileResults = store.searchFTS(fp, 2);
|
|
129
|
+
for (const r of fileResults) {
|
|
130
|
+
if (!seen.has(r.filepath)) {
|
|
131
|
+
seen.add(r.filepath);
|
|
132
|
+
results.push(r);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch { /* non-fatal */ }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (results.length === 0) return makeEmptyOutput("context-surfacing");
|
|
140
|
+
|
|
141
|
+
// Filter out private/excluded paths
|
|
142
|
+
results = results.filter(r =>
|
|
143
|
+
!FILTERED_PATHS.some(p => r.displayPath.includes(p))
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
if (results.length === 0) return makeEmptyOutput("context-surfacing");
|
|
147
|
+
|
|
148
|
+
// Filter out snoozed documents
|
|
149
|
+
const now = new Date();
|
|
150
|
+
results = results.filter(r => {
|
|
151
|
+
const parsed = r.filepath.startsWith('clawmem://') ? r.filepath.replace(/^clawmem:\/\/[^/]+\/?/, '') : r.filepath;
|
|
152
|
+
const doc = store.findActiveDocument(r.collectionName, parsed);
|
|
153
|
+
if (!doc) return true;
|
|
154
|
+
if (doc.snoozed_until && new Date(doc.snoozed_until) > now) return false;
|
|
155
|
+
return true;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (results.length === 0) return makeEmptyOutput("context-surfacing");
|
|
159
|
+
|
|
160
|
+
// Deduplicate by filepath (keep best score per path)
|
|
161
|
+
const deduped = new Map<string, SearchResult>();
|
|
162
|
+
for (const r of results) {
|
|
163
|
+
const existing = deduped.get(r.filepath);
|
|
164
|
+
if (!existing || r.score > existing.score) {
|
|
165
|
+
deduped.set(r.filepath, r);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
results = [...deduped.values()];
|
|
169
|
+
|
|
170
|
+
// Filter out noise results (agent denials, too-short snippets) before enrichment
|
|
171
|
+
results = results.filter(r => !r.body || !isRetrievedNoise(r.body));
|
|
172
|
+
|
|
173
|
+
// Enrich with SAME metadata
|
|
174
|
+
const enriched = enrichResults(store, results, prompt);
|
|
175
|
+
|
|
176
|
+
// Apply composite scoring
|
|
177
|
+
const scored = applyCompositeScoring(enriched, prompt)
|
|
178
|
+
.filter(r => r.compositeScore >= minScore);
|
|
179
|
+
|
|
180
|
+
if (scored.length === 0) return makeEmptyOutput("context-surfacing");
|
|
181
|
+
|
|
182
|
+
// Spreading activation (E11): boost results co-activated with top HOT results
|
|
183
|
+
if (scored.length > 3) {
|
|
184
|
+
const hotPaths = scored.slice(0, 3)
|
|
185
|
+
.filter(r => r.compositeScore > 0.8)
|
|
186
|
+
.map(r => r.displayPath);
|
|
187
|
+
|
|
188
|
+
for (const hotPath of hotPaths) {
|
|
189
|
+
try {
|
|
190
|
+
const coActs = store.getCoActivated(hotPath, 3);
|
|
191
|
+
for (const ca of coActs) {
|
|
192
|
+
const existing = scored.find(r => r.displayPath === ca.path);
|
|
193
|
+
if (existing && existing.compositeScore <= 0.8) {
|
|
194
|
+
existing.compositeScore += Math.min(0.2, 0.1 * Math.min(ca.count, 2));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
// co_activations table may not exist yet
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
scored.sort((a, b) => b.compositeScore - a.compositeScore);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Memory type diversification (E10): ensure procedural results aren't crowded out
|
|
205
|
+
if (scored.length > 3) {
|
|
206
|
+
const top3Types = scored.slice(0, 3).map(r => inferMemoryType(r.displayPath, r.contentType, r.body));
|
|
207
|
+
const hasProc = top3Types.includes("procedural");
|
|
208
|
+
if (!hasProc) {
|
|
209
|
+
const procIdx = scored.findIndex(r => inferMemoryType(r.displayPath, r.contentType, r.body) === "procedural");
|
|
210
|
+
if (procIdx > 3) {
|
|
211
|
+
const [proc] = scored.splice(procIdx, 1);
|
|
212
|
+
scored.splice(3, 0, proc!);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Build context within token budget (profile-driven)
|
|
218
|
+
const { context, paths, tokens } = buildContext(scored, prompt, tokenBudget);
|
|
219
|
+
|
|
220
|
+
if (!context) return makeEmptyOutput("context-surfacing");
|
|
221
|
+
|
|
222
|
+
// Log the injection
|
|
223
|
+
if (input.sessionId) {
|
|
224
|
+
logInjection(store, input.sessionId, "context-surfacing", paths, tokens);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Routing hint: detect query intent signals and prepend a tool routing directive
|
|
228
|
+
const routingHint = detectRoutingHint(prompt);
|
|
229
|
+
|
|
230
|
+
return makeContextOutput(
|
|
231
|
+
"context-surfacing",
|
|
232
|
+
routingHint
|
|
233
|
+
? `<vault-routing>${routingHint}</vault-routing>\n<vault-context>\n${context}\n</vault-context>`
|
|
234
|
+
: `<vault-context>\n${context}\n</vault-context>`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// =============================================================================
|
|
239
|
+
// Helpers
|
|
240
|
+
// =============================================================================
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Detect causal/temporal/discovery signals in the prompt and return a
|
|
244
|
+
* routing hint that makes the correct tool choice salient at the moment
|
|
245
|
+
* of tool selection. Returns null for general queries (no hint needed).
|
|
246
|
+
*/
|
|
247
|
+
function detectRoutingHint(prompt: string): string | null {
|
|
248
|
+
const q = prompt.toLowerCase();
|
|
249
|
+
|
|
250
|
+
if (/\b(last session|yesterday|prior session|previous session|last time we|handoff|what happened last|what did we do|cross.session|earlier today|what we discussed|when we last)\b/i.test(q)) {
|
|
251
|
+
return "If searching memory for this: use session_log or memory_retrieve, NOT query.";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (/\b(why did|why was|why were|what caused|what led to|reason for|decided to|decision about|trade.?off|instead of|chose to)\b/i.test(q) || /^why\b/i.test(q)) {
|
|
255
|
+
return "If searching memory for this: use intent_search or memory_retrieve, NOT query.";
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (/\b(similar to|related to|what else|what other|reminds? me of|like this)\b/i.test(q)) {
|
|
259
|
+
return "If searching memory for this: use find_similar or memory_retrieve, NOT query.";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function buildContext(
|
|
266
|
+
scored: ScoredResult[],
|
|
267
|
+
query: string,
|
|
268
|
+
budget: number = DEFAULT_TOKEN_BUDGET
|
|
269
|
+
): { context: string; paths: string[]; tokens: number } {
|
|
270
|
+
const lines: string[] = [];
|
|
271
|
+
const paths: string[] = [];
|
|
272
|
+
let totalTokens = 0;
|
|
273
|
+
|
|
274
|
+
for (const r of scored) {
|
|
275
|
+
if (totalTokens >= budget) break;
|
|
276
|
+
|
|
277
|
+
// Tiered injection: allocate snippet length by composite score
|
|
278
|
+
const tier = getTierConfig(r.compositeScore);
|
|
279
|
+
|
|
280
|
+
// Sanitize title and displayPath to prevent injection via metadata fields
|
|
281
|
+
const safeTitle = sanitizeSnippet(r.title);
|
|
282
|
+
const safePath = sanitizeSnippet(r.displayPath);
|
|
283
|
+
if (safeTitle === "[content filtered for security]" || safePath === "[content filtered for security]") continue;
|
|
284
|
+
|
|
285
|
+
const typeTag = r.contentType !== "note" ? ` (${r.contentType})` : "";
|
|
286
|
+
let entry: string;
|
|
287
|
+
|
|
288
|
+
if (tier.snippetLen > 0) {
|
|
289
|
+
// HOT or WARM: include snippet
|
|
290
|
+
const bodyStr = r.body || "";
|
|
291
|
+
const sanitized = sanitizeSnippet(bodyStr);
|
|
292
|
+
if (sanitized === "[content filtered for security]") continue;
|
|
293
|
+
|
|
294
|
+
const snippet = smartTruncate(
|
|
295
|
+
extractSnippet(sanitized, query, tier.snippetLen, r.chunkPos).snippet,
|
|
296
|
+
tier.snippetLen
|
|
297
|
+
);
|
|
298
|
+
entry = `**${safeTitle}**${typeTag}\n${safePath}\n${snippet}`;
|
|
299
|
+
} else {
|
|
300
|
+
// COLD: title + path only, no snippet
|
|
301
|
+
entry = `**${safeTitle}**${typeTag}\n${safePath}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const entryTokens = estimateTokens(entry);
|
|
305
|
+
if (totalTokens + entryTokens > budget && lines.length > 0) break;
|
|
306
|
+
|
|
307
|
+
lines.push(entry);
|
|
308
|
+
paths.push(r.displayPath);
|
|
309
|
+
totalTokens += entryTokens;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
context: lines.join("\n\n---\n\n"),
|
|
314
|
+
paths,
|
|
315
|
+
tokens: totalTokens,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curator Nudge Hook - SessionStart
|
|
3
|
+
*
|
|
4
|
+
* Reads the curator report JSON and surfaces actionable items.
|
|
5
|
+
* If the report is stale (>7 days), nudges to run curator.
|
|
6
|
+
* Budget: ~200 tokens. Fail-open.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { resolve as pathResolve } from "path";
|
|
10
|
+
import { existsSync, readFileSync } from "fs";
|
|
11
|
+
import type { Store } from "../store.ts";
|
|
12
|
+
import type { HookInput, HookOutput } from "../hooks.ts";
|
|
13
|
+
import {
|
|
14
|
+
makeContextOutput,
|
|
15
|
+
makeEmptyOutput,
|
|
16
|
+
estimateTokens,
|
|
17
|
+
logInjection,
|
|
18
|
+
} from "../hooks.ts";
|
|
19
|
+
|
|
20
|
+
const MAX_TOKEN_BUDGET = 200;
|
|
21
|
+
const STALE_DAYS = 7;
|
|
22
|
+
const REPORT_PATH = pathResolve(process.env.HOME || "~", ".cache", "clawmem", "curator-report.json");
|
|
23
|
+
|
|
24
|
+
interface CuratorReport {
|
|
25
|
+
timestamp: string;
|
|
26
|
+
actions: string[];
|
|
27
|
+
health: { active: number; embeddingBacklog: number; infrastructure: string };
|
|
28
|
+
sweep: { candidates: number };
|
|
29
|
+
consolidation: { candidates: number };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function curatorNudge(
|
|
33
|
+
store: Store,
|
|
34
|
+
input: HookInput
|
|
35
|
+
): Promise<HookOutput> {
|
|
36
|
+
if (!existsSync(REPORT_PATH)) {
|
|
37
|
+
// No report yet — nudge to run curator
|
|
38
|
+
return makeContextOutput(
|
|
39
|
+
"curator-nudge",
|
|
40
|
+
`<vault-curator>\nCurator has never run. Consider: \`clawmem curate\` or "run curator" agent.\n</vault-curator>`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let report: CuratorReport;
|
|
45
|
+
try {
|
|
46
|
+
report = JSON.parse(readFileSync(REPORT_PATH, "utf-8"));
|
|
47
|
+
} catch {
|
|
48
|
+
return makeEmptyOutput("curator-nudge");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const reportAge = Date.now() - new Date(report.timestamp).getTime();
|
|
52
|
+
const reportDays = Math.floor(reportAge / (86400 * 1000));
|
|
53
|
+
|
|
54
|
+
// If report is stale, just nudge
|
|
55
|
+
if (reportDays > STALE_DAYS) {
|
|
56
|
+
return makeContextOutput(
|
|
57
|
+
"curator-nudge",
|
|
58
|
+
`<vault-curator>\nCurator report is ${reportDays}d old. Consider: \`clawmem curate\` or "run curator" agent.\n</vault-curator>`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// If no actions, stay silent
|
|
63
|
+
if (!report.actions || report.actions.length === 0) {
|
|
64
|
+
return makeEmptyOutput("curator-nudge");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Build compact action summary within budget
|
|
68
|
+
const lines = [`**Curator (${report.timestamp.slice(0, 10)}):**`];
|
|
69
|
+
let tokens = estimateTokens(lines[0]!);
|
|
70
|
+
|
|
71
|
+
for (const action of report.actions) {
|
|
72
|
+
const line = `- ${action}`;
|
|
73
|
+
const lineTokens = estimateTokens(line);
|
|
74
|
+
if (tokens + lineTokens > MAX_TOKEN_BUDGET && lines.length > 1) break;
|
|
75
|
+
lines.push(line);
|
|
76
|
+
tokens += lineTokens;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (lines.length <= 1) return makeEmptyOutput("curator-nudge");
|
|
80
|
+
|
|
81
|
+
if (input.sessionId) {
|
|
82
|
+
logInjection(store, input.sessionId, "curator-nudge", [], tokens);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return makeContextOutput(
|
|
86
|
+
"curator-nudge",
|
|
87
|
+
`<vault-curator>\n${lines.join("\n")}\n</vault-curator>`
|
|
88
|
+
);
|
|
89
|
+
}
|