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.
- package/README.md +17 -0
- package/dist/agent/action-enforcer.d.ts +29 -0
- package/dist/agent/action-enforcer.js +120 -0
- package/dist/agent/assistant.d.ts +14 -0
- package/dist/agent/assistant.js +190 -35
- package/dist/agent/auto-update.js +46 -2
- package/dist/agent/local-turn.d.ts +16 -0
- package/dist/agent/local-turn.js +54 -1
- package/dist/agent/route-classifier.d.ts +1 -0
- package/dist/agent/route-classifier.js +30 -3
- package/dist/agent/toolsets.d.ts +14 -0
- package/dist/agent/toolsets.js +68 -0
- package/dist/brain/ingestion-pipeline.d.ts +7 -0
- package/dist/brain/ingestion-pipeline.js +107 -21
- package/dist/channels/discord.js +38 -7
- package/dist/channels/telegram.js +5 -6
- package/dist/cli/dashboard.js +112 -6
- package/dist/cli/index.js +174 -0
- package/dist/cli/ingest.js +8 -2
- package/dist/gateway/context-hygiene.d.ts +17 -0
- package/dist/gateway/context-hygiene.js +31 -0
- package/dist/gateway/heartbeat-scheduler.d.ts +20 -0
- package/dist/gateway/heartbeat-scheduler.js +27 -10
- package/dist/gateway/router.d.ts +8 -1
- package/dist/gateway/router.js +326 -12
- package/dist/gateway/turn-ledger.d.ts +32 -0
- package/dist/gateway/turn-ledger.js +55 -0
- package/dist/memory/embeddings.d.ts +2 -0
- package/dist/memory/embeddings.js +8 -1
- package/dist/memory/store.d.ts +88 -1
- package/dist/memory/store.js +349 -18
- package/dist/memory/write-queue.d.ts +16 -0
- package/dist/memory/write-queue.js +5 -0
- package/dist/tools/shared.d.ts +89 -0
- package/dist/types.d.ts +11 -0
- package/package.json +1 -1
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
270
|
-
|
|
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
|
-
//
|
|
367
|
+
// 4) Re-index via existing vault pipeline (chunks, FTS, wikilinks)
|
|
291
368
|
store.updateFile(record.targetRelPath, source.agentSlug ?? undefined);
|
|
292
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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();
|
package/dist/channels/discord.js
CHANGED
|
@@ -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
|
|
1078
|
-
if (
|
|
1087
|
+
const approvalReply = detectApprovalReply(text);
|
|
1088
|
+
if (approvalReply !== null) {
|
|
1079
1089
|
const approvals = gateway.getPendingApprovals();
|
|
1080
1090
|
if (approvals.length > 0) {
|
|
1081
|
-
|
|
1082
|
-
|
|
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
|
|
143
|
-
if (
|
|
143
|
+
const approvalReply = detectApprovalReply(text);
|
|
144
|
+
if (approvalReply !== null) {
|
|
144
145
|
const approvals = gateway.getPendingApprovals();
|
|
145
146
|
if (approvals.length > 0) {
|
|
146
|
-
|
|
147
|
-
|
|
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
|
}
|