discoclaw 1.2.2 → 1.2.3
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/.env.example +9 -0
- package/.env.example.full +5 -0
- package/dist/config.js +1 -0
- package/dist/discord/capsule-invalidation.js +52 -0
- package/dist/discord/capsule-invalidation.test.js +108 -0
- package/dist/discord/message-coordinator.js +8 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -234,6 +234,15 @@ DISCORD_GUILD_ID=
|
|
|
234
234
|
# "anthropic-cache-telemetry" in journal output). Set LOG_LEVEL=debug for
|
|
235
235
|
# full prompt section breakdowns.
|
|
236
236
|
|
|
237
|
+
# ----------------------------------------------------------
|
|
238
|
+
# Continuation capsule staleness
|
|
239
|
+
# ----------------------------------------------------------
|
|
240
|
+
# Maximum age (ms) of a continuation capsule before it is skipped at injection time.
|
|
241
|
+
# Prevents stale context from prior conversations bleeding into unrelated sessions.
|
|
242
|
+
# Capsules with an idle/awaiting currentFocus are always skipped regardless of age.
|
|
243
|
+
# Default: 7200000 (2 hours). Set to 0 to disable TTL expiry (idle detection still applies).
|
|
244
|
+
#DISCOCLAW_CAPSULE_TTL_MS=7200000
|
|
245
|
+
|
|
237
246
|
# ----------------------------------------------------------
|
|
238
247
|
# For all ~90 options (subsystems, actions, memory, identity,
|
|
239
248
|
# observability, advanced/debug), see .env.example.full
|
package/.env.example.full
CHANGED
|
@@ -369,6 +369,11 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
|
|
|
369
369
|
#DISCOCLAW_MEMORY_CONSOLIDATION_MODEL=fast
|
|
370
370
|
# Character budget for recent conversation history in prompts (0 = disabled).
|
|
371
371
|
#DISCOCLAW_MESSAGE_HISTORY_BUDGET=3000
|
|
372
|
+
# Maximum age (ms) of a continuation capsule before it is skipped at injection time.
|
|
373
|
+
# Prevents stale context from prior conversations bleeding into unrelated sessions.
|
|
374
|
+
# Capsules with an idle/awaiting/none currentFocus are always skipped regardless of age.
|
|
375
|
+
# Default: 7200000 (2 hours). Set to 0 to disable TTL expiry (idle detection still applies).
|
|
376
|
+
#DISCOCLAW_CAPSULE_TTL_MS=7200000
|
|
372
377
|
|
|
373
378
|
# ----------------------------------------------------------
|
|
374
379
|
# Cold storage — vector-indexed conversation history (off by default)
|
package/dist/config.js
CHANGED
|
@@ -704,6 +704,7 @@ export function parseConfig(env) {
|
|
|
704
704
|
summaryTargetRatio: parseZeroToOneExclusive(env, 'DISCOCLAW_SUMMARY_TARGET_RATIO', 0.65),
|
|
705
705
|
summaryDataDirOverride: parseTrimmedString(env, 'DISCOCLAW_SUMMARY_DATA_DIR'),
|
|
706
706
|
summaryArchiveDirOverride: parseTrimmedString(env, 'DISCOCLAW_SUMMARY_ARCHIVE_DIR'),
|
|
707
|
+
capsuleTtlMs: parseNonNegativeInt(env, 'DISCOCLAW_CAPSULE_TTL_MS', 7_200_000),
|
|
707
708
|
durableMemoryEnabled: parseBoolean(env, 'DISCOCLAW_DURABLE_MEMORY_ENABLED', true),
|
|
708
709
|
durableDataDirOverride: parseTrimmedString(env, 'DISCOCLAW_DURABLE_DATA_DIR'),
|
|
709
710
|
durableInjectMaxChars: parsePositiveInt(env, 'DISCOCLAW_DURABLE_INJECT_MAX_CHARS', 2000),
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default TTL for continuation capsules: 2 hours.
|
|
3
|
+
* If the parent summary's updatedAt is older than this, the capsule is
|
|
4
|
+
* considered stale and should not be injected.
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_CAPSULE_TTL_MS = 2 * 60 * 60 * 1000;
|
|
7
|
+
/**
|
|
8
|
+
* Patterns in `currentFocus` that indicate the capsule carries no useful
|
|
9
|
+
* state — the assistant was idle, awaiting input, or had no active task.
|
|
10
|
+
*/
|
|
11
|
+
const IDLE_FOCUS_PATTERNS = [
|
|
12
|
+
/^idle$/i,
|
|
13
|
+
/^none$/i,
|
|
14
|
+
/^awaiting\b/i,
|
|
15
|
+
/^waiting\b/i,
|
|
16
|
+
/^no active task/i,
|
|
17
|
+
/^n\/a$/i,
|
|
18
|
+
/^\(none\)$/i,
|
|
19
|
+
/^\(idle\)$/i,
|
|
20
|
+
/^\(awaiting\b/i,
|
|
21
|
+
];
|
|
22
|
+
/**
|
|
23
|
+
* Returns true when the capsule's `currentFocus` matches an idle/no-state
|
|
24
|
+
* pattern, meaning the capsule should not be injected.
|
|
25
|
+
*/
|
|
26
|
+
export function isCapsuleIdle(capsule) {
|
|
27
|
+
const focus = capsule.currentFocus.trim();
|
|
28
|
+
return IDLE_FOCUS_PATTERNS.some(pattern => pattern.test(focus));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns true when the parent summary's `updatedAt` exceeds the staleness
|
|
32
|
+
* threshold relative to `now`.
|
|
33
|
+
*/
|
|
34
|
+
export function isCapsuleExpired(updatedAt, now = Date.now(), ttlMs = DEFAULT_CAPSULE_TTL_MS) {
|
|
35
|
+
if (ttlMs <= 0)
|
|
36
|
+
return false;
|
|
37
|
+
return now - updatedAt > ttlMs;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Combined check: returns whether a capsule should be injected into
|
|
41
|
+
* the conversation prompt. Both idle detection and TTL expiry are
|
|
42
|
+
* evaluated; idle is checked first (cheaper).
|
|
43
|
+
*/
|
|
44
|
+
export function validateCapsuleForInjection(capsule, updatedAt, opts) {
|
|
45
|
+
if (isCapsuleIdle(capsule)) {
|
|
46
|
+
return { valid: false, reason: 'idle' };
|
|
47
|
+
}
|
|
48
|
+
if (isCapsuleExpired(updatedAt, opts?.now, opts?.ttlMs)) {
|
|
49
|
+
return { valid: false, reason: 'expired' };
|
|
50
|
+
}
|
|
51
|
+
return { valid: true };
|
|
52
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DEFAULT_CAPSULE_TTL_MS, isCapsuleIdle, isCapsuleExpired, validateCapsuleForInjection, } from './capsule-invalidation.js';
|
|
3
|
+
function makeCapsule(currentFocus) {
|
|
4
|
+
return { currentFocus, nextStep: 'do something' };
|
|
5
|
+
}
|
|
6
|
+
describe('isCapsuleIdle', () => {
|
|
7
|
+
it.each([
|
|
8
|
+
'idle',
|
|
9
|
+
'Idle',
|
|
10
|
+
'IDLE',
|
|
11
|
+
'none',
|
|
12
|
+
'None',
|
|
13
|
+
'NONE',
|
|
14
|
+
'awaiting input',
|
|
15
|
+
'Awaiting user response',
|
|
16
|
+
'waiting for user',
|
|
17
|
+
'Waiting on feedback',
|
|
18
|
+
'no active task',
|
|
19
|
+
'No active task right now',
|
|
20
|
+
'n/a',
|
|
21
|
+
'N/A',
|
|
22
|
+
'(none)',
|
|
23
|
+
'(None)',
|
|
24
|
+
'(idle)',
|
|
25
|
+
'(Idle)',
|
|
26
|
+
'(awaiting input)',
|
|
27
|
+
'(Awaiting response)',
|
|
28
|
+
])('returns true for idle focus: %s', (focus) => {
|
|
29
|
+
expect(isCapsuleIdle(makeCapsule(focus))).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
it.each([
|
|
32
|
+
'Implement feature X',
|
|
33
|
+
'Debugging the parser',
|
|
34
|
+
'idling is not the same',
|
|
35
|
+
'nonetheless important',
|
|
36
|
+
])('returns false for active focus: %s', (focus) => {
|
|
37
|
+
expect(isCapsuleIdle(makeCapsule(focus))).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
it('trims whitespace before matching', () => {
|
|
40
|
+
expect(isCapsuleIdle(makeCapsule(' idle '))).toBe(true);
|
|
41
|
+
expect(isCapsuleIdle(makeCapsule(' none '))).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe('isCapsuleExpired', () => {
|
|
45
|
+
it('returns false when within TTL', () => {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const updatedAt = now - (DEFAULT_CAPSULE_TTL_MS - 1000);
|
|
48
|
+
expect(isCapsuleExpired(updatedAt, now)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
it('returns true when past default TTL', () => {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const updatedAt = now - DEFAULT_CAPSULE_TTL_MS - 1;
|
|
53
|
+
expect(isCapsuleExpired(updatedAt, now)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
it('returns false at exactly the TTL boundary', () => {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const updatedAt = now - DEFAULT_CAPSULE_TTL_MS;
|
|
58
|
+
expect(isCapsuleExpired(updatedAt, now)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
it('respects a custom ttlMs', () => {
|
|
61
|
+
const now = 100_000;
|
|
62
|
+
expect(isCapsuleExpired(90_000, now, 5_000)).toBe(true); // 10s > 5s TTL
|
|
63
|
+
expect(isCapsuleExpired(96_000, now, 5_000)).toBe(false); // 4s < 5s TTL
|
|
64
|
+
});
|
|
65
|
+
it('returns false when ttlMs is 0 (TTL disabled)', () => {
|
|
66
|
+
const now = 100_000;
|
|
67
|
+
expect(isCapsuleExpired(1, now, 0)).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
it('returns false when ttlMs is negative (TTL disabled)', () => {
|
|
70
|
+
const now = 100_000;
|
|
71
|
+
expect(isCapsuleExpired(1, now, -1)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
it('DEFAULT_CAPSULE_TTL_MS is 2 hours', () => {
|
|
74
|
+
expect(DEFAULT_CAPSULE_TTL_MS).toBe(2 * 60 * 60 * 1000);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe('validateCapsuleForInjection', () => {
|
|
78
|
+
const now = 1_000_000;
|
|
79
|
+
const freshUpdatedAt = now - 60_000; // 1 minute ago
|
|
80
|
+
it('returns valid for active, fresh capsule', () => {
|
|
81
|
+
const capsule = makeCapsule('Implementing feature X');
|
|
82
|
+
const result = validateCapsuleForInjection(capsule, freshUpdatedAt, { now });
|
|
83
|
+
expect(result).toEqual({ valid: true });
|
|
84
|
+
});
|
|
85
|
+
it('returns idle reason for idle capsule', () => {
|
|
86
|
+
const capsule = makeCapsule('idle');
|
|
87
|
+
const result = validateCapsuleForInjection(capsule, freshUpdatedAt, { now });
|
|
88
|
+
expect(result).toEqual({ valid: false, reason: 'idle' });
|
|
89
|
+
});
|
|
90
|
+
it('returns expired reason for stale capsule', () => {
|
|
91
|
+
const capsule = makeCapsule('Implementing feature X');
|
|
92
|
+
const staleUpdatedAt = now - DEFAULT_CAPSULE_TTL_MS - 1;
|
|
93
|
+
const result = validateCapsuleForInjection(capsule, staleUpdatedAt, { now });
|
|
94
|
+
expect(result).toEqual({ valid: false, reason: 'expired' });
|
|
95
|
+
});
|
|
96
|
+
it('idle takes precedence over expired', () => {
|
|
97
|
+
const capsule = makeCapsule('none');
|
|
98
|
+
const staleUpdatedAt = now - DEFAULT_CAPSULE_TTL_MS - 1;
|
|
99
|
+
const result = validateCapsuleForInjection(capsule, staleUpdatedAt, { now });
|
|
100
|
+
expect(result).toEqual({ valid: false, reason: 'idle' });
|
|
101
|
+
});
|
|
102
|
+
it('respects custom ttlMs via opts', () => {
|
|
103
|
+
const capsule = makeCapsule('Working on task');
|
|
104
|
+
const updatedAt = now - 10_000; // 10s ago
|
|
105
|
+
const result = validateCapsuleForInjection(capsule, updatedAt, { now, ttlMs: 5_000 });
|
|
106
|
+
expect(result).toEqual({ valid: false, reason: 'expired' });
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -19,6 +19,7 @@ import { resolveGroundedToolCapabilities } from '../runtime/tool-capabilities.js
|
|
|
19
19
|
import { fetchMessageHistory } from './message-history.js';
|
|
20
20
|
import { loadSummary, saveSummary, generateSummary, archiveSummary, recompressSummary, estimateSummaryTokens, buildConversationMemorySection, } from './summarizer.js';
|
|
21
21
|
import { parseCapsuleBlock } from './capsule.js';
|
|
22
|
+
import { validateCapsuleForInjection } from './capsule-invalidation.js';
|
|
22
23
|
import { parseMemoryCommand, handleMemoryCommand } from './memory-commands.js';
|
|
23
24
|
import { parseSecretCommand, handleSecretCommand } from './secret-commands.js';
|
|
24
25
|
import { parsePlanCommand, handlePlanCommand, preparePlanRun, handlePlanSkip, closePlanIfComplete, NO_PHASES_SENTINEL, findPlanFile, looksLikePlanId, PLAN_DISABLED_NUDGE } from './plan-commands.js';
|
|
@@ -2717,6 +2718,13 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
2717
2718
|
existingSummaryUpdatedAt = existing.updatedAt;
|
|
2718
2719
|
existingSummaryRegeneratedAt = existing.regeneratedAt;
|
|
2719
2720
|
existingContinuationCapsule = existing.continuationCapsule;
|
|
2721
|
+
if (existingContinuationCapsule) {
|
|
2722
|
+
const capsuleCheck = validateCapsuleForInjection(existingContinuationCapsule, existing.updatedAt, { ttlMs: params.capsuleTtlMs });
|
|
2723
|
+
if (!capsuleCheck.valid) {
|
|
2724
|
+
params.log?.info({ reason: capsuleCheck.reason, sessionKey }, 'discord:capsule skipped at injection');
|
|
2725
|
+
existingContinuationCapsule = undefined;
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2720
2728
|
summarySection = buildConversationMemorySection(existingSummaryText, {
|
|
2721
2729
|
turnsSinceUpdate: existing.turnsSinceUpdate,
|
|
2722
2730
|
regeneratedAt: existing.regeneratedAt,
|
package/dist/index.js
CHANGED
|
@@ -425,6 +425,7 @@ const summaryMaxChars = cfg.summaryMaxChars;
|
|
|
425
425
|
const summaryEveryNTurns = cfg.summaryEveryNTurns;
|
|
426
426
|
const summaryMaxTokens = cfg.summaryMaxTokens;
|
|
427
427
|
const summaryTargetRatio = cfg.summaryTargetRatio;
|
|
428
|
+
const capsuleTtlMs = cfg.capsuleTtlMs;
|
|
428
429
|
const summaryDataDir = cfg.summaryDataDirOverride
|
|
429
430
|
|| (dataDir ? path.join(dataDir, 'memory', 'rolling') : path.join(__dirname, '..', 'data', 'memory', 'rolling'));
|
|
430
431
|
const summaryArchiveDir = cfg.summaryArchiveDirOverride
|
|
@@ -1275,6 +1276,7 @@ const botParams = {
|
|
|
1275
1276
|
summaryTargetRatio,
|
|
1276
1277
|
summaryDataDir,
|
|
1277
1278
|
summaryArchiveDir,
|
|
1279
|
+
capsuleTtlMs,
|
|
1278
1280
|
durableMemoryEnabled,
|
|
1279
1281
|
durableDataDir,
|
|
1280
1282
|
durableInjectMaxChars,
|