clementine-agent 1.18.19 → 1.18.21

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.
Files changed (37) hide show
  1. package/README.md +17 -0
  2. package/dist/agent/action-enforcer.d.ts +29 -0
  3. package/dist/agent/action-enforcer.js +120 -0
  4. package/dist/agent/assistant.d.ts +14 -0
  5. package/dist/agent/assistant.js +190 -35
  6. package/dist/agent/auto-update.js +46 -2
  7. package/dist/agent/local-turn.d.ts +16 -0
  8. package/dist/agent/local-turn.js +54 -1
  9. package/dist/agent/route-classifier.d.ts +1 -0
  10. package/dist/agent/route-classifier.js +30 -3
  11. package/dist/agent/toolsets.d.ts +14 -0
  12. package/dist/agent/toolsets.js +68 -0
  13. package/dist/brain/ingestion-pipeline.d.ts +7 -0
  14. package/dist/brain/ingestion-pipeline.js +107 -21
  15. package/dist/channels/discord.js +38 -7
  16. package/dist/channels/telegram.js +5 -6
  17. package/dist/cli/dashboard.js +112 -6
  18. package/dist/cli/index.js +174 -0
  19. package/dist/cli/ingest.js +8 -2
  20. package/dist/gateway/context-hygiene.d.ts +17 -0
  21. package/dist/gateway/context-hygiene.js +31 -0
  22. package/dist/gateway/heartbeat-scheduler.d.ts +20 -0
  23. package/dist/gateway/heartbeat-scheduler.js +27 -10
  24. package/dist/gateway/router.d.ts +8 -1
  25. package/dist/gateway/router.js +326 -12
  26. package/dist/gateway/turn-ledger.d.ts +32 -0
  27. package/dist/gateway/turn-ledger.js +55 -0
  28. package/dist/memory/embeddings.d.ts +2 -0
  29. package/dist/memory/embeddings.js +8 -1
  30. package/dist/memory/store.d.ts +88 -1
  31. package/dist/memory/store.js +349 -18
  32. package/dist/memory/write-queue.d.ts +16 -0
  33. package/dist/memory/write-queue.js +5 -0
  34. package/dist/tools/shared.d.ts +89 -0
  35. package/dist/types.d.ts +11 -0
  36. package/package.json +1 -1
  37. package/scripts/postinstall.js +56 -6
@@ -29,6 +29,7 @@ export declare function isDirectImperative(userMessage: string): {
29
29
  match: boolean;
30
30
  pattern?: string;
31
31
  };
32
+ export declare function hasNoDelegationInstruction(userMessage: string): boolean;
32
33
  /**
33
34
  * Decide whether the user is talking ABOUT an agent rather than to them.
34
35
  * The explicit-mention fast path otherwise routes a message like
@@ -91,6 +91,22 @@ export function isDirectImperative(userMessage) {
91
91
  }
92
92
  return { match: false };
93
93
  }
94
+ export function hasNoDelegationInstruction(userMessage) {
95
+ return /\b(don't|dont|do not|don't you|please don't|please dont)\s+(delegate|route|send|pass|hand ?off|handoff)\b/i.test(userMessage)
96
+ || /\b(no|without)\s+(delegating|routing|sending|passing|handing ?off|handoff)\b/i.test(userMessage)
97
+ || /\bkeep (this|that|it)?\s*(with )?(clementine|you|yourself)\b/i.test(userMessage);
98
+ }
99
+ function isExplicitDelegationToAgent(text, firstName, slug) {
100
+ const ident = `${firstName}|${slug}`;
101
+ const re = new RegExp(`\\b(send|route|delegate|pass|hand\\s*off|handoff)\\b[\\s\\w']{0,40}?\\b(to\\s+)?(${ident})\\b`, 'i');
102
+ return re.test(text);
103
+ }
104
+ function isVocativeAgentAddress(text, firstName, slug) {
105
+ const ident = `${firstName}|${slug}`;
106
+ const normalized = text.trim();
107
+ const openerRe = new RegExp(`^(hey\\s+|hi\\s+|yo\\s+)?(${ident})(\\b|\\s*[,—-])`, 'i');
108
+ return openerRe.test(normalized);
109
+ }
94
110
  /**
95
111
  * Decide whether the user is talking ABOUT an agent rather than to them.
96
112
  * The explicit-mention fast path otherwise routes a message like
@@ -111,8 +127,8 @@ export function isAskingAboutAgent(text, firstName, slug) {
111
127
  const possessiveRe = new RegExp(`\\b(${ident})('s|s')\\b`, 'i');
112
128
  if (possessiveRe.test(text))
113
129
  return true;
114
- const askingRe = new RegExp(`\\b(how|what|where|who|when|why|is|are|was|were|did|does|do|will|can|could|would|should|has|have|had|tell\\s+me|show\\s+me|let\\s+me\\s+know|any\\s+update|update\\s+on|status\\s+of|about)\\b[\\s\\w']{0,40}?\\b(${ident})\\b`, 'i');
115
- return askingRe.test(text);
130
+ const askingRe = new RegExp(`\\b(how|what|where|who|when|why|is|are|was|were|did|does|do|will|can|could|would|should|has|have|had|tell\\s+me|show\\s+me|let\\s+me\\s+know|any\\s+update|update\\s+on|status\\s+of|about|fix|check|review)\\b[\\s\\w']{0,80}?\\b(${ident})\\b`, 'i');
131
+ return askingRe.test(text) || new RegExp(`\\b(for|about|on)\\s+(${ident})\\b`, 'i').test(text);
116
132
  }
117
133
  /**
118
134
  * Session keys eligible for routing. Any key NOT in this set is
@@ -252,6 +268,13 @@ export async function classifyRoute(userMessage, agents, gateway) {
252
268
  const specialists = agents.filter(a => a.slug !== 'clementine');
253
269
  if (specialists.length === 0)
254
270
  return null;
271
+ if (hasNoDelegationInstruction(userMessage)) {
272
+ return {
273
+ targetAgent: 'clementine',
274
+ confidence: 0.95,
275
+ reasoning: 'User explicitly asked not to delegate or route this away from Clementine.',
276
+ };
277
+ }
255
278
  // Direct-imperative guardrail: user is instructing Clementine to act —
256
279
  // do not delegate, even if an agent is named.
257
280
  const imperative = isDirectImperative(userMessage);
@@ -272,7 +295,9 @@ export async function classifyRoute(userMessage, agents, gateway) {
272
295
  const wordRe = new RegExp(`\\b(${firstName}|${a.slug})\\b`, 'i');
273
296
  if (!wordRe.test(trimmed))
274
297
  continue;
275
- if (isAskingAboutAgent(trimmed, firstName, a.slug)) {
298
+ const explicitDelegation = isExplicitDelegationToAgent(trimmed, firstName, a.slug);
299
+ const vocativeAddress = isVocativeAgentAddress(trimmed, firstName, a.slug);
300
+ if (!explicitDelegation && !vocativeAddress && isAskingAboutAgent(trimmed, firstName, a.slug)) {
276
301
  // The user is asking ABOUT the agent ("how is <agent> doing", "<agent>'s
277
302
  // tasks", "did <agent> handle that?") rather than addressing them. Fall
278
303
  // through to the LLM classifier, which has a system-prompt rule for
@@ -280,6 +305,8 @@ export async function classifyRoute(userMessage, agents, gateway) {
280
305
  logger.debug({ slug: a.slug, trigger: 'meta-mention-bypass' }, 'Routing skipped — name appears as topic, not vocative');
281
306
  continue;
282
307
  }
308
+ if (!explicitDelegation && !vocativeAddress)
309
+ continue;
283
310
  logger.debug({ slug: a.slug, trigger: 'explicit-mention' }, 'Fast-path routing decision');
284
311
  return {
285
312
  targetAgent: a.slug,
@@ -0,0 +1,14 @@
1
+ export type ToolsetName = 'auto' | 'safe' | 'diagnostic' | 'communications' | 'memory' | 'full';
2
+ export interface ToolsetPreset {
3
+ name: ToolsetName;
4
+ label: string;
5
+ description: string;
6
+ directive: string;
7
+ }
8
+ export declare const TOOLSET_PRESETS: readonly ToolsetPreset[];
9
+ export declare function normalizeToolsetName(input: string | undefined | null): ToolsetName | null;
10
+ export declare function getToolsetPreset(name: ToolsetName): ToolsetPreset;
11
+ export declare function formatToolsetChoices(): string;
12
+ export declare function isRestrictedToolset(name: ToolsetName): boolean;
13
+ export declare function toolsetAllowsLocalWrites(name: ToolsetName): boolean;
14
+ //# sourceMappingURL=toolsets.d.ts.map
@@ -0,0 +1,68 @@
1
+ export const TOOLSET_PRESETS = [
2
+ {
3
+ name: 'auto',
4
+ label: 'Auto',
5
+ description: 'Route to the smallest inferred tool surface for each turn.',
6
+ directive: '',
7
+ },
8
+ {
9
+ name: 'safe',
10
+ label: 'Safe',
11
+ description: 'Memory and read-only local context; no external sends or local writes.',
12
+ directive: 'Toolset safe: use memory and read-only local context. Do not send messages, email, delete data, deploy, or modify files unless the user switches toolsets.',
13
+ },
14
+ {
15
+ name: 'diagnostic',
16
+ label: 'Diagnostic',
17
+ description: 'Bounded logs, local reads, memory, and diagnostics; no external sends.',
18
+ directive: 'Toolset diagnostic: diagnose with bounded reads and capped command output. Prefer targeted log slices, summaries, and transcript_search. Do not send external messages or make product changes.',
19
+ },
20
+ {
21
+ name: 'communications',
22
+ label: 'Communications',
23
+ description: 'Email/message workflows plus memory; avoid code and deployment tools.',
24
+ directive: 'Toolset communications: focus on email, calendar, messaging, approvals, and memory continuity. Do not edit code, deploy, or run unrelated local commands.',
25
+ },
26
+ {
27
+ name: 'memory',
28
+ label: 'Memory',
29
+ description: 'Memory, transcript, and relationship tools only unless explicitly changed.',
30
+ directive: 'Toolset memory: use memory_read, memory_search, memory_recall, transcript_search, working_memory, and user_model. Avoid external integrations and local shell/file writes.',
31
+ },
32
+ {
33
+ name: 'full',
34
+ label: 'Full',
35
+ description: 'Explicit operator mode for broad integrations and admin work.',
36
+ directive: 'Toolset full: the user explicitly enabled the broad operator surface for this chat. Still keep tool output bounded and ask before destructive or irreversible actions.',
37
+ },
38
+ ];
39
+ const TOOLSET_BY_NAME = new Map(TOOLSET_PRESETS.map((preset) => [preset.name, preset]));
40
+ export function normalizeToolsetName(input) {
41
+ const value = String(input ?? '').trim().toLowerCase().replace(/[\s_-]+/g, '-');
42
+ if (!value)
43
+ return null;
44
+ if (value === 'diagnostics' || value === 'debug')
45
+ return 'diagnostic';
46
+ if (value === 'comm' || value === 'comms' || value === 'communication')
47
+ return 'communications';
48
+ if (value === 'mem')
49
+ return 'memory';
50
+ if (value === 'all' || value === 'operator')
51
+ return 'full';
52
+ return TOOLSET_BY_NAME.has(value) ? value : null;
53
+ }
54
+ export function getToolsetPreset(name) {
55
+ return TOOLSET_BY_NAME.get(name) ?? TOOLSET_BY_NAME.get('auto');
56
+ }
57
+ export function formatToolsetChoices() {
58
+ return TOOLSET_PRESETS
59
+ .map((preset) => `- ${preset.name}: ${preset.description}`)
60
+ .join('\n');
61
+ }
62
+ export function isRestrictedToolset(name) {
63
+ return name === 'safe' || name === 'diagnostic' || name === 'memory';
64
+ }
65
+ export function toolsetAllowsLocalWrites(name) {
66
+ return name === 'auto' || name === 'full';
67
+ }
68
+ //# sourceMappingURL=toolsets.js.map
@@ -32,6 +32,13 @@ export interface IngestResult {
32
32
  recordsWritten: number;
33
33
  recordsSkipped: number;
34
34
  recordsFailed: number;
35
+ recordsUnchanged: number;
36
+ recallCheckStatus?: 'ok' | 'partial' | 'skipped';
37
+ recallCheck?: {
38
+ checked: number;
39
+ hits: number;
40
+ missing: string[];
41
+ };
35
42
  errors: Array<{
36
43
  externalId?: string;
37
44
  error: string;
@@ -10,7 +10,7 @@
10
10
  * → tag provenance → ingested_rows (structured overlay)
11
11
  * → graph extractor → ingestion_runs audit
12
12
  */
13
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { VAULT_DIR } from '../config.js';
16
16
  import { getStore } from '../tools/shared.js';
@@ -33,6 +33,8 @@ export async function runIngestion(opts) {
33
33
  let recordsWritten = 0;
34
34
  let recordsSkipped = 0;
35
35
  let recordsFailed = 0;
36
+ let recordsUnchanged = 0;
37
+ const touchedExternalIds = new Set();
36
38
  const report = (stage, message) => {
37
39
  opts.onProgress?.({
38
40
  runId: runId ?? -1,
@@ -52,6 +54,14 @@ export async function runIngestion(opts) {
52
54
  recordsWritten += 1;
53
55
  if (sum)
54
56
  writtenSummaries.push(sum);
57
+ if (sum?.externalId)
58
+ touchedExternalIds.add(sum.externalId);
59
+ },
60
+ onUnchanged: (sum) => {
61
+ recordsSkipped += 1;
62
+ recordsUnchanged += 1;
63
+ if (sum?.externalId)
64
+ touchedExternalIds.add(sum.externalId);
55
65
  },
56
66
  onSkip: () => { recordsSkipped += 1; },
57
67
  onFail: (err) => { recordsFailed += 1; errors.push(err); },
@@ -145,12 +155,28 @@ export async function runIngestion(opts) {
145
155
  });
146
156
  }
147
157
  }
158
+ let recallCheckStatus = 'skipped';
159
+ let recallCheck;
160
+ if (!opts.dryRun && touchedExternalIds.size > 0) {
161
+ const missing = [];
162
+ for (const externalId of touchedExternalIds) {
163
+ if (!store.findChunkByExternalId(source.slug, externalId))
164
+ missing.push(externalId);
165
+ }
166
+ recallCheck = {
167
+ checked: touchedExternalIds.size,
168
+ hits: touchedExternalIds.size - missing.length,
169
+ missing,
170
+ };
171
+ recallCheckStatus = missing.length === 0 ? 'ok' : 'partial';
172
+ }
148
173
  // Finalize
149
174
  const status = recordsFailed > 0 && recordsWritten === 0 ? 'error' :
150
175
  recordsFailed > 0 ? 'partial' : 'ok';
151
176
  if (runId !== null) {
152
177
  store.updateIngestionRun(runId, {
153
- recordsIn, recordsWritten, recordsSkipped, recordsFailed,
178
+ recordsIn, recordsWritten, recordsSkipped, recordsFailed, recordsUnchanged,
179
+ recallCheckStatus,
154
180
  errorsJson: errors.length ? JSON.stringify(errors.slice(0, 50)) : null,
155
181
  overviewNotePath,
156
182
  status,
@@ -165,6 +191,9 @@ export async function runIngestion(opts) {
165
191
  recordsWritten,
166
192
  recordsSkipped,
167
193
  recordsFailed,
194
+ recordsUnchanged,
195
+ recallCheckStatus,
196
+ recallCheck,
168
197
  errors,
169
198
  plannedRecords: opts.dryRun ? plannedRecords : undefined,
170
199
  overviewNotePath,
@@ -176,6 +205,7 @@ export async function runIngestion(opts) {
176
205
  if (runId !== null) {
177
206
  store.updateIngestionRun(runId, {
178
207
  recordsIn, recordsWritten, recordsSkipped, recordsFailed: recordsFailed + 1,
208
+ recordsUnchanged,
179
209
  errorsJson: JSON.stringify(errors),
180
210
  status: 'error',
181
211
  finished: true,
@@ -189,6 +219,8 @@ export async function runIngestion(opts) {
189
219
  recordsWritten,
190
220
  recordsSkipped,
191
221
  recordsFailed: recordsFailed + 1,
222
+ recordsUnchanged,
223
+ recallCheckStatus: 'skipped',
192
224
  errors,
193
225
  plannedRecords: opts.dryRun ? plannedRecords : undefined,
194
226
  };
@@ -207,8 +239,11 @@ async function processStructured(record, mapping, source, opts, store, _report,
207
239
  counters.onWrite(summaryBundle);
208
240
  return;
209
241
  }
210
- await writeRecord(ingested, source, store);
211
- counters.onWrite(summaryBundle);
242
+ const outcome = await writeRecord(ingested, source, store);
243
+ if (outcome === 'unchanged')
244
+ counters.onUnchanged(summaryBundle);
245
+ else
246
+ counters.onWrite(summaryBundle);
212
247
  }
213
248
  catch (err) {
214
249
  counters.onFail({ externalId: record.externalId, error: err instanceof Error ? err.message : String(err) });
@@ -242,8 +277,11 @@ async function processFreeForm(record, source, opts, store, report, planned, _er
242
277
  counters.onWrite(summaryBundle);
243
278
  return;
244
279
  }
245
- await writeRecord(ingested, source, store);
246
- counters.onWrite(summaryBundle);
280
+ const outcome = await writeRecord(ingested, source, store);
281
+ if (outcome === 'unchanged')
282
+ counters.onUnchanged(summaryBundle);
283
+ else
284
+ counters.onWrite(summaryBundle);
247
285
  }
248
286
  catch (err) {
249
287
  counters.onFail({ externalId: record.externalId, error: err instanceof Error ? err.message : String(err) });
@@ -266,37 +304,84 @@ async function writeRecord(record, source, store) {
266
304
  record.tags = [...record.tags, `project:${projSlug}`];
267
305
  }
268
306
  }
269
- // 1) Raw payload artifact store
270
- const artifactId = store.storeArtifact({
271
- toolName: `ingest:${source.slug}`,
272
- summary: record.title || record.externalId,
273
- content: record.rawPayload,
274
- tags: [source.slug, sourceType, ...record.tags].join(','),
275
- sessionKey: null,
276
- agentSlug: source.agentSlug ?? null,
277
- });
278
- record.artifactId = artifactId;
279
- // 2) Vault note: write markdown file with frontmatter + body
307
+ // 1) Vault note content. Build this before artifact writes so exact re-runs
308
+ // can be classified as unchanged without duplicating audit blobs.
280
309
  const abs = path.join(VAULT_DIR, record.targetRelPath);
281
310
  const dir = path.dirname(abs);
282
311
  if (!existsSync(dir))
283
312
  mkdirSync(dir, { recursive: true });
313
+ if (existsSync(abs) && record.frontmatter && 'ingested_at' in record.frontmatter) {
314
+ try {
315
+ const current = readFileSync(abs, 'utf-8');
316
+ const match = current.match(/^ingested_at:\s*(.+)$/m);
317
+ if (match?.[1]) {
318
+ const raw = match[1].trim();
319
+ try {
320
+ record.frontmatter.ingested_at = JSON.parse(raw);
321
+ }
322
+ catch {
323
+ record.frontmatter.ingested_at = raw.replace(/^['"]|['"]$/g, '');
324
+ }
325
+ }
326
+ }
327
+ catch {
328
+ // Fall through with a fresh timestamp if the old note can't be read.
329
+ }
330
+ }
284
331
  const fm = { title: record.title, ...record.frontmatter };
285
332
  const frontmatterBlock = Object.entries(fm)
286
333
  .map(([k, v]) => `${k}: ${serializeYaml(v)}`)
287
334
  .join('\n');
288
335
  const fileContent = `---\n${frontmatterBlock}\n---\n\n# ${record.title}\n\n${record.body}\n`;
336
+ if (existsSync(abs)) {
337
+ try {
338
+ const current = readFileSync(abs, 'utf-8');
339
+ if (current === fileContent && store.findChunkByExternalId(record.sourceSlug, record.externalId)) {
340
+ store.recordMemoryEvent?.({
341
+ sourceType: 'ingestion',
342
+ sourceId: null,
343
+ sessionKey: null,
344
+ agentSlug: source.agentSlug ?? null,
345
+ content: `${record.sourceSlug}:${record.externalId}\nunchanged\n${record.summary}`,
346
+ indexed: true,
347
+ });
348
+ return 'unchanged';
349
+ }
350
+ }
351
+ catch {
352
+ // If the existing file can't be read, fall through and rewrite.
353
+ }
354
+ }
355
+ // 2) Raw payload → artifact store
356
+ const artifactId = store.storeArtifact({
357
+ toolName: `ingest:${source.slug}`,
358
+ summary: record.title || record.externalId,
359
+ content: record.rawPayload,
360
+ tags: [source.slug, sourceType, ...record.tags].join(','),
361
+ sessionKey: null,
362
+ agentSlug: source.agentSlug ?? null,
363
+ });
364
+ record.artifactId = artifactId;
365
+ // 3) Vault note: write markdown file with frontmatter + body
289
366
  writeFileSync(abs, fileContent, 'utf-8');
290
- // 3) Re-index via existing vault pipeline (chunks, FTS, wikilinks)
367
+ // 4) Re-index via existing vault pipeline (chunks, FTS, wikilinks)
291
368
  store.updateFile(record.targetRelPath, source.agentSlug ?? undefined);
292
- // 4) Tag provenance on the chunks we just wrote
369
+ // 5) Tag provenance on the chunks we just wrote
293
370
  store.tagChunksForSource(record.targetRelPath, {
294
371
  sourceSlug: record.sourceSlug,
295
372
  externalId: record.externalId,
296
373
  sourceType,
297
374
  lastSyncedAt: nowIso,
298
375
  });
299
- // 5) Structured overlay for SQL aggregates
376
+ store.recordMemoryEvent?.({
377
+ sourceType: 'ingestion',
378
+ sourceId: artifactId,
379
+ sessionKey: null,
380
+ agentSlug: source.agentSlug ?? null,
381
+ content: `${record.sourceSlug}:${record.externalId}\n${record.title}\n${record.summary}`,
382
+ indexed: true,
383
+ });
384
+ // 6) Structured overlay for SQL aggregates
300
385
  if (record.structuredRow) {
301
386
  const chunkRef = store.findChunkByExternalId(record.sourceSlug, record.externalId);
302
387
  store.insertIngestedRow({
@@ -308,8 +393,9 @@ async function writeRecord(record, source, store) {
308
393
  structuredColumns: Object.fromEntries(Object.entries(record.structuredRow).map(([k, v]) => [k, coerceCol(v)])),
309
394
  });
310
395
  }
311
- // 6) Graph (best-effort, no-op if graph unavailable)
396
+ // 7) Graph (best-effort, no-op if graph unavailable)
312
397
  await writeGraphForRecord(record);
398
+ return 'written';
313
399
  }
314
400
  async function applyStructuredColumns(mapping) {
315
401
  const store = await getStore();
@@ -13,6 +13,8 @@ import path from 'node:path';
13
13
  import { chunkText, sendChunked, DiscordStreamingMessage, friendlyToolName, formatCronEmbed, rehydrateStatusEmbed, setSavedStatusEmbed, } from './discord-utils.js';
14
14
  import { DISCORD_TOKEN, DISCORD_OWNER_ID, DISCORD_WATCHED_CHANNELS, MODELS, ASSISTANT_NAME, OWNER_NAME, PKG_DIR, VAULT_DIR, BASE_DIR, DEFAULT_MODEL_TIER, } from '../config.js';
15
15
  import { findProjectByName, getLinkedProjects } from '../agent/assistant.js';
16
+ import { detectApprovalReply } from '../agent/local-turn.js';
17
+ import { normalizeToolsetName } from '../agent/toolsets.js';
16
18
  import * as cronParser from 'cron-parser';
17
19
  const logger = pino({ name: 'clementine.discord' });
18
20
  const BOT_MESSAGE_TRACKING_LIMIT = 100;
@@ -35,6 +37,12 @@ const slashCommands = [
35
37
  .addStringOption(o => o.setName('job').setDescription('Job name (for run/enable/disable)').setAutocomplete(true)),
36
38
  new SlashCommandBuilder().setName('heartbeat').setDescription('Run heartbeat check manually'),
37
39
  new SlashCommandBuilder().setName('tools').setDescription('List available MCP tools'),
40
+ new SlashCommandBuilder().setName('toolset').setDescription('Set this chat tool mode')
41
+ .addStringOption(o => o.setName('mode').setDescription('Tool mode').setRequired(true)
42
+ .addChoices({ name: 'Auto', value: 'auto' }, { name: 'Safe', value: 'safe' }, { name: 'Diagnostic', value: 'diagnostic' }, { name: 'Communications', value: 'communications' }, { name: 'Memory', value: 'memory' }, { name: 'Full', value: 'full' })),
43
+ new SlashCommandBuilder().setName('compress').setDescription('Compact this conversation context into memory'),
44
+ new SlashCommandBuilder().setName('usage').setDescription('Show recent turn/tool usage for this chat'),
45
+ new SlashCommandBuilder().setName('debug').setDescription('Show session diagnostics for this chat'),
38
46
  new SlashCommandBuilder().setName('project').setDescription('Set active project context')
39
47
  .addStringOption(o => o.setName('action').setDescription('Action').setRequired(true)
40
48
  .addChoices({ name: 'List projects', value: 'list' }, { name: 'Set active project', value: 'set' }, { name: 'Clear active project', value: 'clear' }, { name: 'Show current', value: 'status' }))
@@ -269,6 +277,8 @@ function handleHelp() {
269
277
  '`!self-improve run|status|history|pending|apply|deny` \u2014 Self-improvement',
270
278
  '`!team setup|list|status|messages|topology` \u2014 Manage agent team',
271
279
  '`!status [job]` \u2014 Check unleashed task progress',
280
+ '`/toolset` \u2014 Set tool mode \u00b7 `/compress` \u2014 Compact context \u00b7 `/usage` \u2014 Usage snapshot',
281
+ '`/debug` \u2014 Session diagnostics',
272
282
  '`!dashboard` \u2014 Send a fresh system status embed',
273
283
  '`!heartbeat` \u2014 Run heartbeat \u00b7 `!tools` \u2014 List tools \u00b7 `!clear` \u2014 Reset',
274
284
  '`!stop` \u2014 Interrupt current response',
@@ -1074,15 +1084,12 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
1074
1084
  }
1075
1085
  // ── Approval responses (DM only) ────────────────────────────────
1076
1086
  if (isDm) {
1077
- const lower = text.toLowerCase();
1078
- if (['yes', 'no', 'approve', 'deny', 'go', 'skip', 'always'].includes(lower)) {
1087
+ const approvalReply = detectApprovalReply(text);
1088
+ if (approvalReply !== null) {
1079
1089
  const approvals = gateway.getPendingApprovals();
1080
1090
  if (approvals.length > 0) {
1081
- // Pass 'always' as a string so the check-in gate can persist the channel
1082
- const result = lower === 'always' ? 'always' :
1083
- (lower === 'yes' || lower === 'approve' || lower === 'go');
1084
- gateway.resolveApproval(approvals[approvals.length - 1], result);
1085
- await message.react(lower === 'no' || lower === 'deny' || lower === 'skip' ? '\u274c' : '\u2705');
1091
+ gateway.resolveApproval(approvals[approvals.length - 1], approvalReply);
1092
+ await message.react(approvalReply === false ? '\u274c' : '\u2705');
1086
1093
  return;
1087
1094
  }
1088
1095
  }
@@ -1227,6 +1234,30 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
1227
1234
  await cmd.reply(formatToolsList());
1228
1235
  return;
1229
1236
  }
1237
+ if (name === 'toolset') {
1238
+ const mode = normalizeToolsetName(cmd.options.getString('mode', true));
1239
+ if (!mode) {
1240
+ await cmd.reply({ content: 'Unknown toolset.', ephemeral: true });
1241
+ return;
1242
+ }
1243
+ gateway.setSessionToolset(sessionKey, mode);
1244
+ await cmd.reply({ content: `Toolset set to **${mode}**.`, ephemeral: true });
1245
+ updatePresence(sessionKey);
1246
+ return;
1247
+ }
1248
+ if (name === 'compress') {
1249
+ await cmd.reply({ content: gateway.compactSessionForUser(sessionKey), ephemeral: true });
1250
+ updatePresence(sessionKey);
1251
+ return;
1252
+ }
1253
+ if (name === 'usage') {
1254
+ await cmd.reply({ content: gateway.describeSessionUsage(sessionKey), ephemeral: true });
1255
+ return;
1256
+ }
1257
+ if (name === 'debug') {
1258
+ await cmd.reply({ content: gateway.describeSessionDebug(sessionKey).slice(0, 1900), ephemeral: true });
1259
+ return;
1260
+ }
1230
1261
  if (name === 'status') {
1231
1262
  const jobArg = cmd.options.getString('job') ?? undefined;
1232
1263
  await cmd.reply(handleUnleashedStatus(jobArg));
@@ -7,6 +7,7 @@
7
7
  import { Bot } from 'grammy';
8
8
  import pino from 'pino';
9
9
  import { TELEGRAM_BOT_TOKEN, TELEGRAM_OWNER_ID, } from '../config.js';
10
+ import { detectApprovalReply } from '../agent/local-turn.js';
10
11
  const logger = pino({ name: 'clementine.telegram' });
11
12
  const STREAM_UPDATE_INTERVAL = 1500; // ms
12
13
  const TELEGRAM_MSG_LIMIT = 4096;
@@ -139,14 +140,12 @@ export async function startTelegram(gateway, dispatcher) {
139
140
  const chatId = ctx.chat.id;
140
141
  const sessionKey = `telegram:user:${userId}`;
141
142
  // ── Approval responses ──────────────────────────────────────────
142
- const lower = text.toLowerCase().trim();
143
- if (['yes', 'no', 'approve', 'deny', 'go', 'skip', 'always'].includes(lower)) {
143
+ const approvalReply = detectApprovalReply(text);
144
+ if (approvalReply !== null) {
144
145
  const approvals = gateway.getPendingApprovals();
145
146
  if (approvals.length > 0) {
146
- const result = lower === 'always' ? 'always' :
147
- (lower === 'yes' || lower === 'approve' || lower === 'go');
148
- gateway.resolveApproval(approvals[approvals.length - 1], result);
149
- const approved = result !== false;
147
+ gateway.resolveApproval(approvals[approvals.length - 1], approvalReply);
148
+ const approved = approvalReply !== false;
150
149
  await ctx.reply(approved ? '✅ Approved.' : '❌ Denied.');
151
150
  return;
152
151
  }