clementine-agent 1.18.20 → 1.18.22

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 +12 -0
  5. package/dist/agent/assistant.js +165 -31
  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 +1071 -70
  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 +7 -0
  25. package/dist/gateway/router.js +303 -9
  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
package/README.md CHANGED
@@ -216,10 +216,27 @@ clementine login | auth Authenticate Claude Code / OAuth providers
216
216
  clementine chat Interactive REPL
217
217
  clementine memory status Index size, recent activity
218
218
  clementine memory search <q> FTS5 search
219
+ clementine memory model status Local dense embedding model cache
220
+ clementine memory model install Pre-cache the local embedding model
219
221
  clementine memory dedup | reembed Maintenance
220
222
  clementine brain digest Run the brain digest pipeline
221
223
  ```
222
224
 
225
+ Dense neural recall uses a local Transformers.js embedding model. Model
226
+ weights are not bundled into the npm tarball; the first install caches them
227
+ under `~/.clementine/models/`. To make repo or npm updates prefetch the model
228
+ automatically, set this once in `~/.clementine/.env`:
229
+
230
+ ```
231
+ CLEMENTINE_PREFETCH_EMBEDDINGS=1
232
+ ```
233
+
234
+ You can also opt in for a single install/update command:
235
+
236
+ ```
237
+ CLEMENTINE_INSTALL_EMBEDDINGS=1 npm install -g clementine-agent
238
+ ```
239
+
223
240
  **Projects & agents**
224
241
  ```
225
242
  clementine projects list | add <p> | remove <p>
@@ -0,0 +1,29 @@
1
+ export type ActionExpectationSource = 'approval_followup' | 'user_request' | 'diagnostic_request' | 'none';
2
+ export interface ActionExpectation {
3
+ expected: boolean;
4
+ source: ActionExpectationSource;
5
+ reason: string;
6
+ }
7
+ export interface ActionResponseAssessment {
8
+ violation: boolean;
9
+ reason: string;
10
+ }
11
+ export declare function detectActionExpectation(userText: string, opts?: {
12
+ approvalFollowup?: boolean;
13
+ }): ActionExpectation;
14
+ export declare function buildApprovalFollowupPrompt(reply: string): string;
15
+ export declare function assessActionResponse(input: {
16
+ actionExpectation: ActionExpectation;
17
+ userText: string;
18
+ response: string;
19
+ toolActivityCount: number;
20
+ backgroundStarted?: boolean;
21
+ delegated?: boolean;
22
+ }): ActionResponseAssessment;
23
+ export declare function buildActionEnforcementPrompt(input: {
24
+ userText: string;
25
+ previousResponse: string;
26
+ reason: string;
27
+ }): string;
28
+ export declare function fallbackUnverifiedActionResponse(reason: string): string;
29
+ //# sourceMappingURL=action-enforcer.d.ts.map
@@ -0,0 +1,120 @@
1
+ import { looksLikeApprovalPrompt } from './local-turn.js';
2
+ function normalize(text) {
3
+ return text.trim().toLowerCase().replace(/\s+/g, ' ');
4
+ }
5
+ const ACTION_REQUEST_RE = /\b(can you|could you|would you|please|pls|i need you to|i want you to|let'?s|go ahead and|do it|handle this|take care of)\b[\s\S]{0,160}\b(send|email|message|post|publish|delete|change|update|run|execute|check|look(?:\s+into)?|diagnose|investigate|figure(?:\s+it)?\s*out|find|search|read|write|create|fix|schedule|reschedule|pull|fetch|review|tag|save|upload|download)\b/i;
6
+ const DIRECT_ACTION_RE = /^(send|email|message|post|publish|delete|change|update|run|execute|check|look(?:\s+into)?|diagnose|investigate|find|search|read|write|create|fix|schedule|reschedule|pull|fetch|review|tag|save|upload|download)\b/i;
7
+ const DIAGNOSTIC_RE = /\b(log|logs|crash|crashing|error|failing|failure|broken|diagnose|debug|investigate|look into|figure it out|what'?s causing|why is|why did)\b/i;
8
+ const DONE_CLAIM_RE = /\b(done|sent|emailed|queued|accepted|completed|finished|fixed|updated|changed|deleted|posted|published|scheduled|rescheduled|created|saved|uploaded|downloaded|tagged|checked|reviewed|found|read|ran|executed)\b/i;
9
+ const PROMISE_RE = /\b(i'?ll|i will|i am going to|i'?m going to|let me|i'?m checking|i'?m sending|i'?m running|i'?m looking|working on it|on it)\b[\s\S]{0,120}\b(send|email|message|post|publish|delete|change|update|run|execute|check|look|diagnose|investigate|find|search|read|write|create|fix|schedule|reschedule|pull|fetch|review|tag|save|upload|download|now)\b/i;
10
+ const VACUOUS_ACK_RE = /^(got it|okay|ok|sure|perfect|sounds good|on it|will do|yep|yeah)[.! ]*$/i;
11
+ const BLOCKED_OR_ASKING_RE = /\b(i can'?t|i cannot|unable to|blocked|need you to|need a|need the|please provide|please send|can you send|can you share|which|what should|who should|before i|confirm|approve|good to go|okay to)\b/i;
12
+ const DIAGNOSTIC_DEFLECTION_RE = /\b(what are you seeing|what do you see|send (me )?the logs|share (the )?logs|provide (the )?logs|can you paste|can you send me)\b/i;
13
+ export function detectActionExpectation(userText, opts = {}) {
14
+ if (opts.approvalFollowup) {
15
+ return {
16
+ expected: true,
17
+ source: 'approval_followup',
18
+ reason: 'user approved the previous action prompt',
19
+ };
20
+ }
21
+ const text = userText.trim();
22
+ if (!text)
23
+ return { expected: false, source: 'none', reason: 'empty message' };
24
+ if (ACTION_REQUEST_RE.test(text) || DIRECT_ACTION_RE.test(text)) {
25
+ const diagnostic = DIAGNOSTIC_RE.test(text);
26
+ return {
27
+ expected: true,
28
+ source: diagnostic ? 'diagnostic_request' : 'user_request',
29
+ reason: diagnostic ? 'user asked for local/tool-backed diagnosis' : 'user asked Clementine to take an action',
30
+ };
31
+ }
32
+ if (DIAGNOSTIC_RE.test(text) && /\b(can you|could you|please|figure|diagnose|debug|look)\b/i.test(text)) {
33
+ return {
34
+ expected: true,
35
+ source: 'diagnostic_request',
36
+ reason: 'user asked for local/tool-backed diagnosis',
37
+ };
38
+ }
39
+ return { expected: false, source: 'none', reason: 'no concrete action requested' };
40
+ }
41
+ export function buildApprovalFollowupPrompt(reply) {
42
+ return [
43
+ `[Approval reply: "${reply.trim().slice(0, 120)}"]`,
44
+ 'The user approved the action you proposed in your previous message.',
45
+ 'Continue from that previous approval prompt and perform the approved action now using the appropriate tool.',
46
+ 'Do not treat this as casual feedback. Do not say it is done unless a tool call verifies it. If you are blocked, say exactly what is blocking you.',
47
+ ].join('\n');
48
+ }
49
+ export function assessActionResponse(input) {
50
+ const { actionExpectation, userText, response, toolActivityCount } = input;
51
+ if (!actionExpectation.expected)
52
+ return { violation: false, reason: 'no action expected' };
53
+ if (input.backgroundStarted)
54
+ return { violation: false, reason: 'action was queued in background' };
55
+ if (input.delegated)
56
+ return { violation: false, reason: 'action was delegated' };
57
+ if (toolActivityCount > 0)
58
+ return { violation: false, reason: 'tool activity observed' };
59
+ const trimmed = response.trim();
60
+ if (!trimmed)
61
+ return { violation: true, reason: 'empty response to action request' };
62
+ if (looksLikeApprovalPrompt(trimmed)) {
63
+ if (actionExpectation.source === 'approval_followup') {
64
+ return { violation: true, reason: 'asked for approval again after the user already approved' };
65
+ }
66
+ return { violation: false, reason: 'assistant requested approval before acting' };
67
+ }
68
+ const lower = normalize(trimmed);
69
+ if (actionExpectation.source === 'diagnostic_request' && DIAGNOSTIC_DEFLECTION_RE.test(lower)) {
70
+ return { violation: true, reason: 'asked user for logs instead of using available local tools' };
71
+ }
72
+ if (VACUOUS_ACK_RE.test(trimmed)) {
73
+ return { violation: true, reason: 'acknowledged action request without acting' };
74
+ }
75
+ if (DONE_CLAIM_RE.test(trimmed)) {
76
+ return { violation: true, reason: 'claimed completion without tool activity' };
77
+ }
78
+ if (PROMISE_RE.test(trimmed)) {
79
+ return { violation: true, reason: 'promised future action without same-turn tool activity' };
80
+ }
81
+ if (BLOCKED_OR_ASKING_RE.test(lower) || trimmed.endsWith('?')) {
82
+ return { violation: false, reason: 'assistant asked for needed input or reported a block' };
83
+ }
84
+ // Diagnostic requests should usually use local tools. A generic answer with
85
+ // no tool activity is allowed only if it clearly does not claim inspection.
86
+ if (actionExpectation.source === 'diagnostic_request' && /\b(i think|likely|probably|sounds like)\b/i.test(trimmed)) {
87
+ return { violation: false, reason: 'assistant gave hypothesis without claiming inspection' };
88
+ }
89
+ // For action-shaped requests, a generic short answer is usually a stall.
90
+ if (trimmed.length < 80 && /\b(send|email|message|run|fix|check|diagnose|figure|look)\b/i.test(userText)) {
91
+ return { violation: true, reason: 'short action response without tool activity' };
92
+ }
93
+ return { violation: false, reason: 'no unsupported action claim detected' };
94
+ }
95
+ export function buildActionEnforcementPrompt(input) {
96
+ return [
97
+ '[SYSTEM ACTION ENFORCEMENT]',
98
+ 'Your previous response was not allowed because it implied action without verified tool activity.',
99
+ `Reason: ${input.reason}`,
100
+ '',
101
+ 'Original user request:',
102
+ input.userText.slice(0, 1200),
103
+ '',
104
+ 'Previous response:',
105
+ input.previousResponse.slice(0, 1200),
106
+ '',
107
+ 'Now correct this in the same turn:',
108
+ '- If the action is possible, use the appropriate tool now.',
109
+ '- If the action is blocked, say exactly what is blocking it.',
110
+ '- Do not say "done", "sent", "queued", "checked", or similar unless a tool call actually verifies it.',
111
+ ].join('\n');
112
+ }
113
+ export function fallbackUnverifiedActionResponse(reason) {
114
+ return [
115
+ "I didn't complete that yet.",
116
+ `I caught an action-verification issue: ${reason}.`,
117
+ "I won't call it done without a tool confirmation. Please resend the request and I'll retry from a clean turn.",
118
+ ].join(' ');
119
+ }
120
+ //# sourceMappingURL=action-enforcer.js.map
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import type { AgentProfile, OnTextCallback, OnToolActivityCallback, VerboseLevel } from '../types.js';
13
13
  import { AgentManager } from './agent-manager.js';
14
+ import { type ToolsetName } from './toolsets.js';
14
15
  /**
15
16
  * Estimate token count for Claude.
16
17
  *
@@ -30,6 +31,8 @@ export declare function estimateTokens(text: string): number;
30
31
  export declare function looksLikeContextThrashText(value: unknown): boolean;
31
32
  export declare function contextThrashRecoveryNotice(): string;
32
33
  export declare function buildContextThrashRecoveryPrompt(userRequest: string, priorFailureText?: string): string;
34
+ /** Format a millisecond duration as a human-friendly "X ago" string. */
35
+ export declare function formatTimeAgo(ms: number): string;
33
36
  export declare function looksLikeOneMillionContextError(value: unknown): boolean;
34
37
  export declare function oneMillionContextRecoveryMessage(): string;
35
38
  export declare function looksLikeProviderApiErrorResponse(value: unknown): boolean;
@@ -147,6 +150,7 @@ export declare class PersonalAssistant {
147
150
  flushSessions(): void;
148
151
  private saveSessionsNow;
149
152
  getExchangeCount(sessionKey: string): number;
153
+ hasRecentApprovalPrompt(sessionKey: string): boolean;
150
154
  getMemoryChunkCount(): number;
151
155
  private buildSystemPrompt;
152
156
  private buildOptions;
@@ -178,6 +182,7 @@ export declare class PersonalAssistant {
178
182
  projectOverride?: ProjectMeta;
179
183
  verboseLevel?: VerboseLevel;
180
184
  abortController?: AbortController;
185
+ toolset?: ToolsetName;
181
186
  }): Promise<[string, string]>;
182
187
  /**
183
188
  * Compare retrieved chunks against the response text and record which
@@ -198,6 +203,12 @@ export declare class PersonalAssistant {
198
203
  * No LLM call — uses buildLocalSummary for instant summarization.
199
204
  */
200
205
  private compactContext;
206
+ compactSessionForGateway(sessionKey: string, reason?: string): {
207
+ compacted: boolean;
208
+ exchangeCount: number;
209
+ summary?: string;
210
+ reason: string;
211
+ };
201
212
  /**
202
213
  * Expire sessions inactive for more than 24 hours.
203
214
  * Called periodically from chat() to prevent unbounded map growth.
@@ -209,6 +220,7 @@ export declare class PersonalAssistant {
209
220
  * to avoid blocking the user's query.
210
221
  */
211
222
  private buildLocalSummary;
223
+ private buildStructuredCompactionSummary;
212
224
  private buildLocalSummaryFromTurns;
213
225
  /**
214
226
  * Walk a chronological list of transcript turns and pair adjacent
@@ -21,7 +21,7 @@ import { detectFrustrationSignals, detectRepeatedTopics } from './insight-engine
21
21
  import { DEFAULT_CHANNEL_CAPABILITIES } from '../types.js';
22
22
  import { enforceToolPermissions, getSecurityPrompt, getHeartbeatSecurityPrompt, getCronSecurityPrompt, getHeartbeatDisallowedTools, logToolUse, setProfileTier, setProfileAllowedTools, setAgentDir, setSendPolicy, setInteractionSource, logAuditJsonl, } from './hooks.js';
23
23
  import { scanner } from '../security/scanner.js';
24
- import { agentWorkingMemoryFile, listAllGoals } from '../tools/shared.js';
24
+ import { agentWorkingMemoryFile, capOutput, listAllGoals } from '../tools/shared.js';
25
25
  import { AgentManager } from './agent-manager.js';
26
26
  import { extractLinks } from './link-extractor.js';
27
27
  import { StallGuard } from './stall-guard.js';
@@ -33,6 +33,8 @@ import { searchSkills as searchSkillsSync } from './skill-extractor.js';
33
33
  import { classifyIntent, getStrategyGuidance } from './intent-classifier.js';
34
34
  import { getEventLog } from './session-event-log.js';
35
35
  import { routeToolSurface, TOOL_SURFACE_HARD_LIMIT, TOOL_SURFACE_WARN_THRESHOLD } from './tool-router.js';
36
+ import { isRestrictedToolset, toolsetAllowsLocalWrites } from './toolsets.js';
37
+ import { looksLikeApprovalPrompt } from './local-turn.js';
36
38
  import { decideTurn } from './turn-policy.js';
37
39
  import { loadClementineJson } from '../config/clementine-json.js';
38
40
  import { isCreditBalanceError, markBackgroundCreditBlocked } from '../gateway/credit-guard.js';
@@ -294,9 +296,23 @@ const query = ((args) => {
294
296
  }
295
297
  return rawQuery(args);
296
298
  });
299
+ function parseMemoryTimestampMs(value) {
300
+ const text = String(value ?? '').trim();
301
+ if (!text)
302
+ return NaN;
303
+ // SQLite datetime('now') returns UTC as "YYYY-MM-DD HH:mm:ss" with no zone.
304
+ // Parse it explicitly as UTC so summaries don't appear hours in the future.
305
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text)) {
306
+ return Date.parse(`${text.replace(' ', 'T')}Z`);
307
+ }
308
+ return Date.parse(text);
309
+ }
297
310
  /** Format a millisecond duration as a human-friendly "X ago" string. */
298
- function formatTimeAgo(ms) {
299
- const minutes = Math.floor(ms / 60_000);
311
+ export function formatTimeAgo(ms) {
312
+ const safeMs = Number.isFinite(ms) ? Math.max(0, ms) : 0;
313
+ if (safeMs < 60_000)
314
+ return 'just now';
315
+ const minutes = Math.floor(safeMs / 60_000);
300
316
  if (minutes < 60)
301
317
  return `${minutes}m ago`;
302
318
  const hours = Math.floor(minutes / 60);
@@ -311,6 +327,11 @@ function formatTimeAgo(ms) {
311
327
  const CONTEXT_GUARD_MIN_TOKENS = 16_000;
312
328
  /** Warn threshold — context is getting tight. */
313
329
  const CONTEXT_GUARD_WARN_TOKENS = 32_000;
330
+ const PENDING_CONTEXT_USER_MAX_CHARS = 1000;
331
+ const PENDING_CONTEXT_ASSISTANT_MAX_CHARS = 3000;
332
+ const CRON_PROGRESS_NOTES_MAX_CHARS = 2000;
333
+ const CRON_PROGRESS_PENDING_MAX_ITEMS = 20;
334
+ const CRON_PROGRESS_ITEM_MAX_CHARS = 300;
314
335
  /** Rotate SDK sessions before hidden resume history approaches the 200K cap. */
315
336
  const SESSION_ROTATE_INPUT_TOKENS = 140_000;
316
337
  /** Approximate context window sizes by model family. */
@@ -328,6 +349,12 @@ function getContextWindow(model) {
328
349
  }
329
350
  return 200_000; // safe default
330
351
  }
352
+ function capContextBlock(text, maxChars) {
353
+ return capOutput(String(text ?? ''), maxChars);
354
+ }
355
+ function capContextItem(text) {
356
+ return capContextBlock(text, CRON_PROGRESS_ITEM_MAX_CHARS).replace(/\s+/g, ' ').trim();
357
+ }
331
358
  function resultInputTokens(result) {
332
359
  let total = 0;
333
360
  const modelUsage = result.modelUsage;
@@ -1319,6 +1346,10 @@ export class PersonalAssistant {
1319
1346
  getExchangeCount(sessionKey) {
1320
1347
  return this.exchangeCounts.get(sessionKey) ?? 0;
1321
1348
  }
1349
+ hasRecentApprovalPrompt(sessionKey) {
1350
+ const lastAssistant = this.lastExchanges.get(sessionKey)?.at(-1)?.assistant ?? '';
1351
+ return looksLikeApprovalPrompt(lastAssistant);
1352
+ }
1322
1353
  getMemoryChunkCount() {
1323
1354
  if (!this.memoryStore)
1324
1355
  return 0;
@@ -1950,7 +1981,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1950
1981
  }
1951
1982
  // ── Build SDK Options ─────────────────────────────────────────────
1952
1983
  async buildOptions(opts = {}) {
1953
- const { isHeartbeat = false, cronTier = null, maxTurns = null, model = null, enableTeams = true, retrievalContext = '', profile = null, sessionKey = null, streaming = false, isPlanStep = false, isUnleashed = false, sourceOverride, disableAllTools = false, verboseLevel, abortController, effort, maxBudgetUsd, toolScopeText, thinking, outputFormat, stallGuard, intentClassification, turnPolicy, contextRoutingText, } = opts;
1984
+ const { isHeartbeat = false, cronTier = null, maxTurns = null, model = null, enableTeams = true, retrievalContext = '', profile = null, sessionKey = null, streaming = false, isPlanStep = false, isUnleashed = false, sourceOverride, disableAllTools = false, verboseLevel, abortController, effort, maxBudgetUsd, toolScopeText, thinking, outputFormat, stallGuard, intentClassification, turnPolicy, contextRoutingText, toolset = 'auto', } = opts;
1954
1985
  const isCron = cronTier !== null;
1955
1986
  const toolsDisabledForCall = disableAllTools || (isHeartbeat && !isCron);
1956
1987
  const promptScopeText = toolScopeText ?? '';
@@ -2001,7 +2032,27 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2001
2032
  const safeContextToolRoute = allowContextToolRoute && !contextToolRoute.fullSurface
2002
2033
  ? contextToolRoute
2003
2034
  : emptyToolRoute();
2004
- const toolRoute = mergeToolRoutes(promptToolRoute, mergeToolRoutes(safeProfileToolRoute, safeContextToolRoute));
2035
+ let toolRoute = mergeToolRoutes(promptToolRoute, mergeToolRoutes(safeProfileToolRoute, safeContextToolRoute));
2036
+ if (toolset === 'full') {
2037
+ toolRoute = {
2038
+ bundles: [],
2039
+ externalMcpServers: undefined,
2040
+ composioToolkits: undefined,
2041
+ inheritFullClaudeEnv: true,
2042
+ fullSurface: true,
2043
+ reason: 'full_surface',
2044
+ };
2045
+ }
2046
+ else if (isRestrictedToolset(toolset)) {
2047
+ toolRoute = {
2048
+ ...toolRoute,
2049
+ bundles: [],
2050
+ externalMcpServers: [],
2051
+ composioToolkits: [],
2052
+ inheritFullClaudeEnv: false,
2053
+ fullSurface: false,
2054
+ };
2055
+ }
2005
2056
  let allowedTools = [];
2006
2057
  const addAllowed = (...tools) => {
2007
2058
  for (const tool of tools) {
@@ -2021,9 +2072,13 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2021
2072
  const memoryNeeded = autonomousToolRun
2022
2073
  || retrievalContext.trim().length > 0
2023
2074
  || (turnPolicy?.retrievalTier !== undefined && turnPolicy.retrievalTier !== 'none');
2024
- const localReadNeeded = taskIntent || /\b(repo|repository|code|file|files|folder|directory|path|log|logs|config|read|show|grep|diff|search)\b/i.test(promptScopeLower);
2025
- const localWriteNeeded = taskIntent || /\b(write|edit|fix|implement|refactor|build|test|run|npm|git|commit|push|pull|deploy|install|configure)\b/i.test(promptScopeLower);
2026
- const adminNeeded = toolRoute.fullSurface || /\b(self[- ]?update|restart|daemon|doctor|env|credential|integration|setup|set up|configure|npm publish|publish to npm)\b/i.test(promptScopeLower);
2075
+ const localReadNeeded = taskIntent || toolset === 'diagnostic' || /\b(repo|repository|code|file|files|folder|directory|path|log|logs|config|read|show|grep|diff|search)\b/i.test(promptScopeLower);
2076
+ const diagnosticCommandNeeded = toolset === 'diagnostic'
2077
+ && /\b(run|test|npm|pnpm|yarn|node|git|logs?|tail|ps|status|diagnos(?:e|tic)|check)\b/i.test(promptScopeLower);
2078
+ const localWriteNeeded = diagnosticCommandNeeded
2079
+ || (toolsetAllowsLocalWrites(toolset) && (taskIntent || /\b(write|edit|fix|implement|refactor|build|test|run|npm|git|commit|push|pull|deploy|install|configure)\b/i.test(promptScopeLower)));
2080
+ const adminNeeded = toolRoute.fullSurface
2081
+ || (toolsetAllowsLocalWrites(toolset) && /\b(self[- ]?update|restart|daemon|doctor|env|credential|integration|setup|set up|configure|npm publish|publish to npm)\b/i.test(promptScopeLower));
2027
2082
  if (!toolsDisabledForCall) {
2028
2083
  if (toolRoute.fullSurface) {
2029
2084
  addAllowed('Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'WebSearch', 'WebFetch');
@@ -2032,8 +2087,12 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2032
2087
  else {
2033
2088
  if (localReadNeeded)
2034
2089
  addAllowed('Read', 'Glob', 'Grep');
2035
- if (localWriteNeeded)
2036
- addAllowed('Write', 'Edit', 'Bash');
2090
+ if (localWriteNeeded) {
2091
+ if (toolset === 'diagnostic')
2092
+ addAllowed('Bash');
2093
+ else
2094
+ addAllowed('Write', 'Edit', 'Bash');
2095
+ }
2037
2096
  if (toolRoute.bundles.includes('web_research') || toolRoute.bundles.includes('docs_lookup')) {
2038
2097
  addAllowed('WebSearch', 'WebFetch');
2039
2098
  }
@@ -2041,7 +2100,12 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2041
2100
  addClementineTools(CLEMENTINE_CORE_TOOL_NAMES);
2042
2101
  addClementineTools(CLEMENTINE_RELATIONSHIP_TOOL_NAMES);
2043
2102
  }
2044
- if (taskIntent || intentClassification?.type === 'correction') {
2103
+ const clementineMemoryWritesAllowed = toolset === 'auto'
2104
+ || toolset === 'full'
2105
+ || toolset === 'communications'
2106
+ || intentClassification?.type === 'feedback'
2107
+ || intentClassification?.type === 'correction';
2108
+ if ((taskIntent || intentClassification?.type === 'correction') && clementineMemoryWritesAllowed) {
2045
2109
  addClementineTools(CLEMENTINE_MEMORY_WRITE_TOOL_NAMES);
2046
2110
  addClementineTools(CLEMENTINE_WORKSPACE_TOOL_NAMES);
2047
2111
  }
@@ -2058,20 +2122,22 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2058
2122
  addClementineTools(CLEMENTINE_INTEGRATION_TOOL_NAMES);
2059
2123
  addClementineTools(CLEMENTINE_ADMIN_TOOL_NAMES);
2060
2124
  }
2061
- if (toolRoute.bundles.includes('email_outlook') || /\b(outlook|email|mailbox|inbox|calendar|follow-?up)\b/i.test(scopeText)) {
2125
+ if ((toolset === 'auto' || toolset === 'full' || toolset === 'communications')
2126
+ && (toolRoute.bundles.includes('email_outlook') || /\b(outlook|email|mailbox|inbox|calendar|follow-?up)\b/i.test(scopeText))) {
2062
2127
  addClementineTools(CLEMENTINE_COMM_TOOL_NAMES);
2063
2128
  }
2064
- if (toolRoute.bundles.includes('github') || toolRoute.bundles.includes('browser') || toolRoute.bundles.includes('web_research')) {
2129
+ if ((toolset === 'auto' || toolset === 'full')
2130
+ && (toolRoute.bundles.includes('github') || toolRoute.bundles.includes('browser') || toolRoute.bundles.includes('web_research'))) {
2065
2131
  addClementineTools(CLEMENTINE_RESEARCH_TOOL_NAMES);
2066
2132
  }
2067
- if (enableTeams) {
2133
+ if (enableTeams && (toolset === 'auto' || toolset === 'full')) {
2068
2134
  addAllowed('Task', 'Agent');
2069
2135
  addClementineTools(CLEMENTINE_TEAM_TOOL_NAMES);
2070
2136
  addClementineTools(CLEMENTINE_JOB_TOOL_NAMES);
2071
2137
  }
2072
2138
  }
2073
2139
  // Include local user scripts/plugins for task-like or explicit full-surface turns.
2074
- if (taskIntent || toolRoute.fullSurface || adminNeeded) {
2140
+ if (toolsetAllowsLocalWrites(toolset) && (taskIntent || toolRoute.fullSurface || adminNeeded)) {
2075
2141
  try {
2076
2142
  const toolsDir = path.join(BASE_DIR, 'tools');
2077
2143
  const pluginsDir = path.join(BASE_DIR, 'plugins');
@@ -2414,6 +2480,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2414
2480
  isolateClaudeConfig,
2415
2481
  inheritFullClaudeEnv: shouldInheritClaudeEnv,
2416
2482
  maxBudgetUsd: enforcedBudget,
2483
+ toolset,
2417
2484
  isCron,
2418
2485
  cronTier,
2419
2486
  isPlanStep,
@@ -2806,6 +2873,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2806
2873
  const projectOverride = options?.projectOverride;
2807
2874
  const verboseLevel = options?.verboseLevel;
2808
2875
  const abortController = options?.abortController;
2876
+ const toolset = options?.toolset ?? 'auto';
2809
2877
  const key = sessionKey ?? undefined;
2810
2878
  this._lastUserMessage = text;
2811
2879
  let sessionRotated = false;
@@ -2906,11 +2974,14 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2906
2974
  const exchanges = this.lastExchanges.get(key) ?? [];
2907
2975
  if (exchanges.length === 0 && this.memoryStore) {
2908
2976
  try {
2909
- const recentSummaries = this.memoryStore.getRecentSummaries(1);
2977
+ const recentSummaries = typeof this.memoryStore.getRecentSummariesForSession === 'function'
2978
+ ? this.memoryStore.getRecentSummariesForSession(key, 1)
2979
+ : this.memoryStore.getRecentSummaries(5).filter((s) => s.sessionKey === key).slice(0, 1);
2910
2980
  if (recentSummaries.length > 0) {
2911
2981
  const last = recentSummaries[0];
2912
- const ageMs = Date.now() - new Date(last.createdAt).getTime();
2913
- if (ageMs < 7 * 24 * 60 * 60 * 1000) { // within 7 days
2982
+ const createdAtMs = parseMemoryTimestampMs(last.createdAt);
2983
+ const ageMs = Date.now() - createdAtMs;
2984
+ if (Number.isFinite(ageMs) && ageMs >= -5 * 60_000 && ageMs < 7 * 24 * 60 * 60 * 1000) { // within 7 days
2914
2985
  const ago = formatTimeAgo(ageMs);
2915
2986
  effectivePrompt =
2916
2987
  `[Last conversation (${ago}):\n${last.summary.slice(0, 600)}]\n\n` +
@@ -2946,7 +3017,9 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2946
3017
  if (allPending.length > 0) {
2947
3018
  const contextLines = [];
2948
3019
  for (const ctx of allPending) {
2949
- contextLines.push(`[${ctx.user}]\n${ctx.assistant}`);
3020
+ const user = capContextBlock(ctx.user, PENDING_CONTEXT_USER_MAX_CHARS);
3021
+ const assistant = capContextBlock(ctx.assistant, PENDING_CONTEXT_ASSISTANT_MAX_CHARS);
3022
+ contextLines.push(`[${user}]\n${assistant}`);
2950
3023
  }
2951
3024
  effectivePrompt =
2952
3025
  `[Since we last talked, you did some background work. Naturally mention what happened — lead with anything that needs attention, briefly note routine completions. Don't dump raw tool calls or list job names. Be conversational.\nBackground:\n${contextLines.join('\n\n')}]\n\n${effectivePrompt}`;
@@ -2975,7 +3048,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2975
3048
  const effectiveMaxTurns = maxTurns ?? turnPolicy.maxTurns;
2976
3049
  const CHAT_TIMEOUT_MS = 30 * 60 * 1000;
2977
3050
  const guard = new StallGuard();
2978
- let [responseText, sessionId] = await this.runQuery(effectivePrompt, key, onText, model, profile, securityAnnotation, effectiveMaxTurns, projectOverride, onToolActivity, verboseLevel, abortController, guard, CHAT_TIMEOUT_MS, intent, turnPolicy);
3051
+ let [responseText, sessionId] = await this.runQuery(effectivePrompt, key, onText, model, profile, securityAnnotation, effectiveMaxTurns, projectOverride, onToolActivity, verboseLevel, abortController, guard, CHAT_TIMEOUT_MS, intent, turnPolicy, toolset);
2979
3052
  // If we got a context-length / prompt-too-long error, retry with a fresh session
2980
3053
  const errLower = responseText.toLowerCase();
2981
3054
  const isContextOverflow = errLower.includes('prompt is too long') ||
@@ -2996,7 +3069,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2996
3069
  `If this task involves pulling data for multiple entities, delegate each to a sub-agent using the Agent tool ` +
2997
3070
  `instead of calling data-heavy tools directly.\n\n${text}`;
2998
3071
  }
2999
- [responseText, sessionId] = await this.runQuery(retryPrompt, key, onText, model, profile, securityAnnotation, maxTurns, undefined, onToolActivity, verboseLevel, abortController, undefined, CHAT_TIMEOUT_MS, intent, turnPolicy);
3072
+ [responseText, sessionId] = await this.runQuery(retryPrompt, key, onText, model, profile, securityAnnotation, maxTurns, undefined, onToolActivity, verboseLevel, abortController, undefined, CHAT_TIMEOUT_MS, intent, turnPolicy, toolset);
3000
3073
  }
3001
3074
  // Track exchange count, timestamp, and last exchange.
3002
3075
  // Never store API error responses — they poison session history and create
@@ -3090,7 +3163,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3090
3163
  // ── Run Query ─────────────────────────────────────────────────────
3091
3164
  static RATE_LIMIT_MAX_RETRIES = 3;
3092
3165
  static RATE_LIMIT_BACKOFF = [5000, 15000, 30000];
3093
- async runQuery(prompt, sessionKey, onText, model, profile, securityAnnotation, maxTurnsOverride, projectOverride, onToolActivity, verboseLevel, abortController, stallGuard, timeoutMs, intentClassification, turnPolicy) {
3166
+ async runQuery(prompt, sessionKey, onText, model, profile, securityAnnotation, maxTurnsOverride, projectOverride, onToolActivity, verboseLevel, abortController, stallGuard, timeoutMs, intentClassification, turnPolicy, toolset = 'auto') {
3094
3167
  // Parallelize context retrieval and project matching — they're independent
3095
3168
  // If a project override is set, skip auto-matching entirely
3096
3169
  const hasActiveSession = !!(sessionKey && this.sessions.has(sessionKey));
@@ -3197,6 +3270,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3197
3270
  intentClassification,
3198
3271
  turnPolicy: effectiveTurnPolicy,
3199
3272
  effort: effectiveTurnPolicy?.effort ?? intentClassification?.suggestedEffort,
3273
+ toolset,
3200
3274
  // Route destructive/admin/local write decisions from the direct user
3201
3275
  // request only. Retrieved memory may still contribute integration
3202
3276
  // continuity via contextRoutingText, but stale memories should not
@@ -3981,18 +4055,20 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3981
4055
  *
3982
4056
  * No LLM call — uses buildLocalSummary for instant summarization.
3983
4057
  */
3984
- compactContext(sessionKey) {
3985
- const summary = this.buildLocalSummary(sessionKey);
4058
+ compactContext(sessionKey, reason = 'context_guard') {
4059
+ const summary = this.buildStructuredCompactionSummary(sessionKey);
3986
4060
  if (!summary)
3987
- return;
4061
+ return null;
3988
4062
  // Build compaction block for working memory
3989
4063
  const exchangeCount = this.exchangeCounts.get(sessionKey) ?? 0;
4064
+ const parentSessionId = this.sessions.get(sessionKey) ?? null;
3990
4065
  const COMPACTION_START = '<!-- COMPACTION_START -->';
3991
4066
  const COMPACTION_END = '<!-- COMPACTION_END -->';
3992
4067
  const compactionBlock = [
3993
4068
  COMPACTION_START,
3994
4069
  `## Session Compaction (auto-generated)`,
3995
4070
  `Session ${sessionKey} compacted at ${exchangeCount} exchanges.`,
4071
+ `Reason: ${reason}.`,
3996
4072
  ``,
3997
4073
  summary,
3998
4074
  ``,
@@ -4030,6 +4106,20 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4030
4106
  catch {
4031
4107
  // If working memory write fails, still rotate — better than hitting the hard limit
4032
4108
  }
4109
+ try {
4110
+ this.memoryStore?.saveSessionSummary?.(sessionKey, summary, exchangeCount);
4111
+ this.memoryStore?.recordSessionLineage?.({
4112
+ sessionKey,
4113
+ parentSessionId,
4114
+ childSessionId: null,
4115
+ reason,
4116
+ summary,
4117
+ exchangeCount,
4118
+ });
4119
+ }
4120
+ catch {
4121
+ // Durable lineage is helpful, not required for compaction safety.
4122
+ }
4033
4123
  // Rotate session — clear the session ID so next query starts fresh
4034
4124
  // The working memory summary will provide continuity
4035
4125
  this.sessions.delete(sessionKey);
@@ -4038,6 +4128,14 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4038
4128
  this.sessionTimestamps.delete(sessionKey);
4039
4129
  this.stallNudges.delete(sessionKey);
4040
4130
  this.saveSessions();
4131
+ return summary;
4132
+ }
4133
+ compactSessionForGateway(sessionKey, reason = 'gateway_preflight') {
4134
+ const exchangeCount = this.exchangeCounts.get(sessionKey) ?? 0;
4135
+ const summary = this.compactContext(sessionKey, reason);
4136
+ return summary
4137
+ ? { compacted: true, exchangeCount, summary, reason }
4138
+ : { compacted: false, exchangeCount, reason };
4041
4139
  }
4042
4140
  /**
4043
4141
  * Expire sessions inactive for more than 24 hours.
@@ -4059,7 +4157,39 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4059
4157
  * to avoid blocking the user's query.
4060
4158
  */
4061
4159
  buildLocalSummary(sessionKey) {
4062
- return this.buildLocalSummaryFromTurns(this.lastExchanges.get(sessionKey) ?? []);
4160
+ let exchanges = this.lastExchanges.get(sessionKey) ?? [];
4161
+ if (exchanges.length === 0 && this.memoryStore && typeof this.memoryStore.getTranscriptTail === 'function') {
4162
+ try {
4163
+ const recent = this.memoryStore.getTranscriptTail(sessionKey, 0, SESSION_EXCHANGE_HISTORY_SIZE * 2);
4164
+ exchanges = this.pairTranscriptTurns(recent ?? []);
4165
+ }
4166
+ catch {
4167
+ exchanges = [];
4168
+ }
4169
+ }
4170
+ return this.buildLocalSummaryFromTurns(exchanges);
4171
+ }
4172
+ buildStructuredCompactionSummary(sessionKey) {
4173
+ const exchanges = this.lastExchanges.get(sessionKey) ?? [];
4174
+ const summary = this.buildLocalSummary(sessionKey);
4175
+ if (!summary)
4176
+ return '';
4177
+ const latest = exchanges.at(-1);
4178
+ const lastUser = latest?.user
4179
+ ? latest.user.slice(0, 400).replace(/\s+/g, ' ')
4180
+ : '';
4181
+ const continuity = [
4182
+ '- Exact details remain in transcripts; use transcript_search before relying on this handoff for names, dates, IDs, files, or sent-message status.',
4183
+ '- Keep tool outputs bounded and prefer targeted reads over full log dumps.',
4184
+ lastUser ? `- Last visible user request: ${lastUser}` : '',
4185
+ ].filter(Boolean);
4186
+ return [
4187
+ '### Recent Conversation',
4188
+ summary,
4189
+ '',
4190
+ '### Continuity Notes',
4191
+ continuity.join('\n'),
4192
+ ].join('\n');
4063
4193
  }
4064
4194
  buildLocalSummaryFromTurns(turns, opts) {
4065
4195
  if (turns.length === 0)
@@ -4971,13 +5101,17 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4971
5101
  const progress = JSON.parse(fs.readFileSync(progressFile, 'utf-8'));
4972
5102
  const parts = [`## Previous Progress (run #${progress.runCount}, ${progress.lastRunAt})`];
4973
5103
  if (progress.completedItems?.length > 0) {
4974
- parts.push(`Completed: ${progress.completedItems.slice(-10).join(', ')}`);
5104
+ parts.push(`Completed: ${progress.completedItems.slice(-10).map(capContextItem).join(', ')}`);
4975
5105
  }
4976
5106
  if (progress.pendingItems?.length > 0) {
4977
- parts.push(`Pending: ${progress.pendingItems.join(', ')}`);
5107
+ const pendingItems = progress.pendingItems.slice(0, CRON_PROGRESS_PENDING_MAX_ITEMS).map(capContextItem);
5108
+ const suffix = progress.pendingItems.length > CRON_PROGRESS_PENDING_MAX_ITEMS
5109
+ ? ` (${progress.pendingItems.length - CRON_PROGRESS_PENDING_MAX_ITEMS} more omitted)`
5110
+ : '';
5111
+ parts.push(`Pending: ${pendingItems.join(', ')}${suffix}`);
4978
5112
  }
4979
5113
  if (progress.notes) {
4980
- parts.push(`Notes: ${progress.notes}`);
5114
+ parts.push(`Notes: ${capContextBlock(progress.notes, CRON_PROGRESS_NOTES_MAX_CHARS)}`);
4981
5115
  }
4982
5116
  progressContext = parts.join('\n') + '\n\n' +
4983
5117
  'Continue from where you left off. Use `cron_progress_write` at the end to save what you completed and what\'s pending.\n\n';
@@ -5999,8 +6133,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
5999
6133
  * so follow-up conversation has context.
6000
6134
  */
6001
6135
  injectContext(sessionKey, userText, assistantText) {
6002
- const trimmedUser = userText.slice(0, INJECTED_CONTEXT_MAX_CHARS);
6003
- const trimmedAssistant = assistantText.slice(0, INJECTED_CONTEXT_MAX_CHARS);
6136
+ const trimmedUser = capContextBlock(userText, INJECTED_CONTEXT_MAX_CHARS);
6137
+ const trimmedAssistant = capContextBlock(assistantText, INJECTED_CONTEXT_MAX_CHARS);
6004
6138
  // Add to in-memory exchange history
6005
6139
  const history = this.lastExchanges.get(sessionKey) ?? [];
6006
6140
  history.push({ user: trimmedUser, assistant: trimmedAssistant });