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 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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discoclaw",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "Personal AI orchestrator that turns Discord into a persistent workspace",
5
5
  "license": "MIT",
6
6
  "keywords": [