Skip to content

Commit ec0d3e7

Browse files
authored
Merge pull request #139 from vvvvroot/feat/capture-exclude
feat(capture): add captureExclude and captureSkipMarker
2 parents ab44e41 + 920c06a commit ec0d3e7

5 files changed

Lines changed: 180 additions & 5 deletions

File tree

nowledge-mem-openclaw-plugin/CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ Credentials (apiUrl/apiKey): also reads `~/.nowledge-mem/config.json` (shared wi
151151
| `maxContextResults` | integer 1-20 | `5` | `NMEM_MAX_CONTEXT_RESULTS` | How many memories to inject at prompt time |
152152
| `recallMinScore` | integer 0-100 | `0` | `NMEM_RECALL_MIN_SCORE` | Min relevance score (%) to include in auto-recall |
153153
| `maxThreadMessageChars` | integer 200-20000 | `800` | `NMEM_MAX_THREAD_MESSAGE_CHARS` | Max chars per captured thread message before truncation |
154+
| `captureExclude` | string[] | `[]` || Session key glob patterns to skip during auto-capture. `*` matches within a colon-segment. Example: `["agent:*:cron:*"]` |
155+
| `captureSkipMarker` | string | `"#nmem-skip"` || In-band marker: any message containing this text skips capture for the session. Not sticky across compaction |
154156
| `apiUrl` | string | `""` | `NMEM_API_URL` | Remote server URL. Empty = local (127.0.0.1:14242) |
155157
| `apiKey` | string | `""` | `NMEM_API_KEY` | API key. Never logged. |
156158

nowledge-mem-openclaw-plugin/openclaw.plugin.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@
2828
"label": "Max thread message chars",
2929
"help": "Maximum characters preserved per captured OpenClaw thread message (200-20000). Higher values keep more of long messages in Nowledge thread history."
3030
},
31+
"captureExclude": {
32+
"label": "Exclude session patterns",
33+
"help": "Session key glob patterns to skip during auto-capture. Use * to match within a colon-segment. Example: agent:*:cron:*"
34+
},
35+
"captureSkipMarker": {
36+
"label": "Skip marker",
37+
"help": "Text marker in messages that prevents capture for the session. Default: #nmem-skip. Note: if compaction drops the marked message, capture resumes — use captureExclude patterns for persistent filtering."
38+
},
3139
"apiUrl": {
3240
"label": "Server URL (remote mode)",
3341
"help": "Leave empty for local mode (default: http://127.0.0.1:14242). Set to your remote server URL for cross-device or team access. See: https://docs.nowledge.co/docs/remote-access"
@@ -79,6 +87,17 @@
7987
"maximum": 20000,
8088
"description": "Maximum characters preserved per captured OpenClaw thread message before truncation."
8189
},
90+
"captureExclude": {
91+
"type": "array",
92+
"items": { "type": "string" },
93+
"default": [],
94+
"description": "Session key glob patterns to exclude from auto-capture. Glob * matches within a colon-delimited segment. Example: agent:*:cron:* excludes all cron job sessions."
95+
},
96+
"captureSkipMarker": {
97+
"type": "string",
98+
"default": "#nmem-skip",
99+
"description": "Text marker in messages that prevents capture for the entire session. When any message contains this marker, the session is skipped. Note: compaction may drop the marked message — use captureExclude for persistent filtering."
100+
},
82101
"apiUrl": {
83102
"type": "string",
84103
"default": "",

nowledge-mem-openclaw-plugin/src/config.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const ALLOWED_KEYS = new Set([
2121
"maxContextResults",
2222
"recallMinScore",
2323
"maxThreadMessageChars",
24+
"captureExclude",
25+
"captureSkipMarker",
2426
"apiUrl",
2527
"apiKey",
2628
// Legacy aliases — accepted but not advertised
@@ -343,13 +345,52 @@ export function parseConfig(raw, logger) {
343345
const apiKey = ak.value;
344346
_sources.apiKey = ak.source;
345347

348+
// --- captureExclude: file > pluginConfig > default ---
349+
const captureExclude = (() => {
350+
const fromFile = Array.isArray(resolvedFile.captureExclude)
351+
? resolvedFile.captureExclude
352+
: null;
353+
const fromPlugin = Array.isArray(resolvedPlugin.captureExclude)
354+
? resolvedPlugin.captureExclude
355+
: null;
356+
_sources.captureExclude = fromFile
357+
? "file"
358+
: fromPlugin
359+
? "pluginConfig"
360+
: "default";
361+
const entries = fromFile ?? fromPlugin ?? [];
362+
return entries
363+
.filter((v) => typeof v === "string" && v.trim())
364+
.map((v) => v.trim());
365+
})();
366+
367+
// --- captureSkipMarker: file > pluginConfig > default ---
368+
const captureSkipMarker = (() => {
369+
const fromFile =
370+
typeof resolvedFile.captureSkipMarker === "string"
371+
? resolvedFile.captureSkipMarker.trim()
372+
: undefined;
373+
const fromPlugin =
374+
typeof resolvedPlugin.captureSkipMarker === "string"
375+
? resolvedPlugin.captureSkipMarker.trim()
376+
: undefined;
377+
_sources.captureSkipMarker = fromFile
378+
? "file"
379+
: fromPlugin
380+
? "pluginConfig"
381+
: "default";
382+
return fromFile || fromPlugin || "#nmem-skip";
383+
})();
384+
346385
return {
347386
sessionContext,
348387
sessionDigest,
349388
digestMinInterval,
350389
maxContextResults,
351390
recallMinScore,
352391
maxThreadMessageChars,
392+
captureExclude,
393+
captureSkipMarker,
353394
apiUrl,
354395
apiKey,
355396
_sources,

nowledge-mem-openclaw-plugin/src/context-engine.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222

2323
import { ceState } from "./ce-state.js";
2424
import { BASE_GUIDANCE, SESSION_CONTEXT_GUIDANCE } from "./hooks/behavioral.js";
25-
import { appendOrCreateThread, triageAndDistill } from "./hooks/capture.js";
25+
import {
26+
appendOrCreateThread,
27+
hasSkipMarker,
28+
matchesExcludePattern,
29+
triageAndDistill,
30+
} from "./hooks/capture.js";
2631
import {
2732
MAX_QUERY_LENGTH,
2833
SHORT_QUERY_THRESHOLD,
@@ -342,8 +347,23 @@ export function createNowledgeMemContextEngineFactory(client, cfg, logger) {
342347
}) {
343348
if (isHeartbeat) return;
344349

350+
// Normalize sessionKey consistently with hook handlers
351+
const normalizedKey = String(sessionKey || sessionId || "");
352+
353+
// Capture exclusion: pattern-based and marker-based filters
354+
if (matchesExcludePattern(normalizedKey, cfg.captureExclude)) {
355+
logger.debug?.(`ce: skipped excluded session ${normalizedKey}`);
356+
return;
357+
}
358+
if (hasSkipMarker(messages, cfg.captureSkipMarker)) {
359+
logger.debug?.(
360+
`ce: skipped session with skip marker ${normalizedKey}`,
361+
);
362+
return;
363+
}
364+
345365
const event = { messages, sessionFile };
346-
const ctx = { sessionId, sessionKey };
366+
const ctx = { sessionId, sessionKey: normalizedKey };
347367

348368
// 1. Always capture thread (idempotent, deduped)
349369
const captureResult = await appendOrCreateThread({

nowledge-mem-openclaw-plugin/src/hooks/capture.js

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,63 @@ function _setSyncedMessageCount(threadId, count) {
4040
}
4141
}
4242

43+
// ---------------------------------------------------------------------------
44+
// Capture exclusion filters
45+
// ---------------------------------------------------------------------------
46+
47+
/**
48+
* Compile a glob pattern (where `*` matches within a colon-delimited segment)
49+
* into a RegExp. Results are cached since patterns are immutable after parse.
50+
*/
51+
const _compiledPatterns = new Map();
52+
function _compileGlob(pattern) {
53+
const key = String(pattern).toLowerCase();
54+
let re = _compiledPatterns.get(key);
55+
if (!re) {
56+
re = new RegExp(
57+
"^" +
58+
key
59+
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
60+
.replace(/\*/g, "[^:]*") +
61+
"$",
62+
);
63+
_compiledPatterns.set(key, re);
64+
}
65+
return re;
66+
}
67+
68+
/**
69+
* Test whether a session key matches any exclusion glob pattern.
70+
* Glob `*` matches within a colon-delimited segment (not across colons).
71+
* Example: "agent:*:cron:*" matches "agent:main:cron:abc123"
72+
*
73+
* Exported for reuse by Context Engine and other capture paths.
74+
*/
75+
export function matchesExcludePattern(sessionKey, patterns) {
76+
if (!Array.isArray(patterns) || patterns.length === 0) return false;
77+
const key = String(sessionKey || "").toLowerCase();
78+
return patterns.some((pattern) => _compileGlob(pattern).test(key));
79+
}
80+
81+
/**
82+
* Check if any message contains the skip marker text.
83+
* Scans both raw message content and nested message objects.
84+
*
85+
* Exported for reuse by Context Engine and other capture paths.
86+
*/
87+
export function hasSkipMarker(messages, marker) {
88+
if (!marker || typeof marker !== "string" || !Array.isArray(messages)) return false;
89+
const markerLc = marker.toLowerCase();
90+
return messages.some((msg) => {
91+
const text = extractText(msg?.content ?? msg?.message?.content);
92+
return text.toLowerCase().includes(markerLc);
93+
});
94+
}
95+
96+
// ---------------------------------------------------------------------------
97+
// Message normalization utilities
98+
// ---------------------------------------------------------------------------
99+
43100
export function truncate(text, max = DEFAULT_MAX_MESSAGE_CHARS) {
44101
const str = String(text || "").trim();
45102
if (!str) return "";
@@ -197,8 +254,9 @@ export async function appendOrCreateThread({
197254
ctx,
198255
reason,
199256
maxMessageChars = DEFAULT_MAX_MESSAGE_CHARS,
257+
resolvedMessages,
200258
}) {
201-
const rawMessages = await resolveHookMessages(event);
259+
const rawMessages = resolvedMessages ?? (await resolveHookMessages(event));
202260
if (!Array.isArray(rawMessages) || rawMessages.length === 0) return;
203261

204262
const threadId = buildStableThreadId(event, ctx);
@@ -413,13 +471,30 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
413471
if (!event?.success) return;
414472
if (ctx?.trigger === "heartbeat") return;
415473

474+
// Layer 1: pattern-based exclusion (e.g. cron jobs, subagent sessions)
475+
const sessionKey = String(ctx?.sessionKey || ctx?.sessionId || "");
476+
if (matchesExcludePattern(sessionKey, cfg.captureExclude)) {
477+
logger.debug?.(`capture: skipped excluded session ${sessionKey}`);
478+
return;
479+
}
480+
481+
// Layer 2: marker-based exclusion (user typed #nmem-skip in conversation)
482+
const resolvedMessages = await resolveHookMessages(event);
483+
if (hasSkipMarker(resolvedMessages, cfg.captureSkipMarker)) {
484+
logger.debug?.(
485+
`capture: skipped session with skip marker ${sessionKey}`,
486+
);
487+
return;
488+
}
489+
416490
const captureResult = await appendOrCreateThread({
417491
client,
418492
logger,
419493
event,
420494
ctx,
421495
reason: "agent_end",
422496
maxMessageChars: cfg.maxThreadMessageChars,
497+
resolvedMessages,
423498
});
424499

425500
await triageAndDistill({ client, cfg, logger, captureResult, ctx });
@@ -435,18 +510,36 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
435510
*
436511
* Heartbeat sessions are skipped (same rationale as agent_end).
437512
*/
438-
export function buildBeforeResetCaptureHandler(client, _cfg, logger) {
513+
export function buildBeforeResetCaptureHandler(client, cfg, logger) {
439514
return async (event, ctx) => {
440515
if (ceState.active) return;
441516
if (ctx?.trigger === "heartbeat") return;
517+
518+
// Layer 1: pattern-based exclusion
519+
const sessionKey = String(ctx?.sessionKey || ctx?.sessionId || "");
520+
if (matchesExcludePattern(sessionKey, cfg.captureExclude)) {
521+
logger.debug?.(`capture: skipped excluded session ${sessionKey}`);
522+
return;
523+
}
524+
525+
// Layer 2: marker-based exclusion
526+
const resolvedMessages = await resolveHookMessages(event);
527+
if (hasSkipMarker(resolvedMessages, cfg.captureSkipMarker)) {
528+
logger.debug?.(
529+
`capture: skipped session with skip marker ${sessionKey}`,
530+
);
531+
return;
532+
}
533+
442534
const reason = typeof event?.reason === "string" ? event.reason : undefined;
443535
await appendOrCreateThread({
444536
client,
445537
logger,
446538
event,
447539
ctx,
448540
reason,
449-
maxMessageChars: _cfg?.maxThreadMessageChars,
541+
maxMessageChars: cfg.maxThreadMessageChars,
542+
resolvedMessages,
450543
});
451544
};
452545
}

0 commit comments

Comments
 (0)