clementine-agent 1.0.68 → 1.0.69

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 (53) hide show
  1. package/dist/agent/assistant.d.ts +42 -0
  2. package/dist/agent/assistant.js +241 -29
  3. package/dist/agent/session-store-adapter.d.ts +14 -0
  4. package/dist/agent/session-store-adapter.js +69 -0
  5. package/dist/brain/adapters/common.d.ts +12 -0
  6. package/dist/brain/adapters/common.js +29 -0
  7. package/dist/brain/adapters/csv.d.ts +10 -0
  8. package/dist/brain/adapters/csv.js +55 -0
  9. package/dist/brain/adapters/docx.d.ts +10 -0
  10. package/dist/brain/adapters/docx.js +35 -0
  11. package/dist/brain/adapters/email.d.ts +9 -0
  12. package/dist/brain/adapters/email.js +84 -0
  13. package/dist/brain/adapters/index.d.ts +9 -0
  14. package/dist/brain/adapters/index.js +24 -0
  15. package/dist/brain/adapters/json.d.ts +10 -0
  16. package/dist/brain/adapters/json.js +100 -0
  17. package/dist/brain/adapters/markdown.d.ts +10 -0
  18. package/dist/brain/adapters/markdown.js +49 -0
  19. package/dist/brain/adapters/pdf.d.ts +9 -0
  20. package/dist/brain/adapters/pdf.js +53 -0
  21. package/dist/brain/adapters/rest.d.ts +29 -0
  22. package/dist/brain/adapters/rest.js +139 -0
  23. package/dist/brain/batch-summary.d.ts +30 -0
  24. package/dist/brain/batch-summary.js +129 -0
  25. package/dist/brain/format-detector.d.ts +16 -0
  26. package/dist/brain/format-detector.js +153 -0
  27. package/dist/brain/graph-extractor.d.ts +15 -0
  28. package/dist/brain/graph-extractor.js +61 -0
  29. package/dist/brain/ingest-scheduler.d.ts +32 -0
  30. package/dist/brain/ingest-scheduler.js +123 -0
  31. package/dist/brain/ingestion-pipeline.d.ts +47 -0
  32. package/dist/brain/ingestion-pipeline.js +337 -0
  33. package/dist/brain/intelligence.d.ts +67 -0
  34. package/dist/brain/intelligence.js +291 -0
  35. package/dist/brain/llm-client.d.ts +38 -0
  36. package/dist/brain/llm-client.js +92 -0
  37. package/dist/brain/source-registry.d.ts +38 -0
  38. package/dist/brain/source-registry.js +121 -0
  39. package/dist/cli/dashboard.js +1204 -10
  40. package/dist/cli/index.js +23 -0
  41. package/dist/cli/ingest.d.ts +19 -0
  42. package/dist/cli/ingest.js +151 -0
  43. package/dist/config.d.ts +14 -0
  44. package/dist/config.js +80 -0
  45. package/dist/index.js +8 -0
  46. package/dist/memory/store.d.ts +190 -0
  47. package/dist/memory/store.js +674 -6
  48. package/dist/tools/artifact-tools.d.ts +11 -0
  49. package/dist/tools/artifact-tools.js +83 -0
  50. package/dist/tools/mcp-server.js +2 -0
  51. package/dist/tools/shared.d.ts +135 -0
  52. package/dist/types.d.ts +103 -0
  53. package/package.json +11 -3
@@ -40,6 +40,21 @@ export declare function getLinkedProjects(): ProjectMeta[];
40
40
  export declare function addProject(projectPath: string, description?: string, keywords?: string[]): void;
41
41
  /** Remove a project from the linked projects list. Returns true if removed. */
42
42
  export declare function removeProject(projectPath: string): boolean;
43
+ export interface ProactiveGoalInput {
44
+ goal: {
45
+ title: string;
46
+ priority?: string;
47
+ owner?: string;
48
+ nextActions?: string[];
49
+ };
50
+ }
51
+ /**
52
+ * Build the compact "active goals" block that gets injected when no goal
53
+ * keyword matches the user's prompt. Pure so it can be tested without the
54
+ * full Assistant/vault setup.
55
+ */
56
+ export declare function buildActiveGoalsBlock(goals: ProactiveGoalInput[], agentSlug?: string | null, maxEntries?: number): string;
57
+ export declare function chunkReferencedInResponse(chunkContent: string, responseLower: string): boolean;
43
58
  export declare class PersonalAssistant {
44
59
  static readonly MAX_SESSION_EXCHANGES = 40;
45
60
  private sessions;
@@ -69,6 +84,14 @@ export declare class PersonalAssistant {
69
84
  private _compactedSessions;
70
85
  /** Last auto-matched project per session — exposed for CLI display. */
71
86
  private _lastMatchedProject;
87
+ /**
88
+ * Chunks retrieved on the most recent turn per session, kept so the
89
+ * post-response outcome scorer can check which actually got referenced.
90
+ * Cleared after each scoring pass.
91
+ */
92
+ private _lastRetrievedChunks;
93
+ /** Lazy-built SessionStore adapter that mirrors SDK transcripts to SQLite. */
94
+ private _sessionStore;
72
95
  /** Hot correction buffer — explicit behavioral corrections applied before nightly SI. */
73
96
  private hotCorrections;
74
97
  constructor();
@@ -91,6 +114,12 @@ export declare class PersonalAssistant {
91
114
  /** Inject a background work result into the session so the next chat naturally references it. */
92
115
  injectPendingContext(sessionKey: string, userPrompt: string, result: string): void;
93
116
  private initMemoryStore;
117
+ /**
118
+ * Return the cached SessionStore adapter. Null until initMemoryStore
119
+ * completes, in which case the SDK falls back to local-only sessions —
120
+ * no crash on cold boot.
121
+ */
122
+ private getSessionStore;
94
123
  /**
95
124
  * Seed the in-memory hotCorrections ring buffer from persisted behavioral
96
125
  * patterns (corrections that recurred across ≥2 sessions in the last 30d).
@@ -123,6 +152,13 @@ export declare class PersonalAssistant {
123
152
  * or empty string if no goals match.
124
153
  */
125
154
  private matchGoals;
155
+ /**
156
+ * Compact always-on block of active goals. Used when no keyword match
157
+ * fires so the agent still sees what it's supposed to be working on.
158
+ * Scoped: for agent sessions, includes that agent's goals plus any
159
+ * clementine-owned goals it might contribute to.
160
+ */
161
+ private formatActiveGoalsBlock;
126
162
  chat(text: string, sessionKey?: string | null, options?: {
127
163
  onText?: OnTextCallback;
128
164
  onToolActivity?: OnToolActivityCallback;
@@ -134,6 +170,12 @@ export declare class PersonalAssistant {
134
170
  verboseLevel?: VerboseLevel;
135
171
  abortController?: AbortController;
136
172
  }): Promise<[string, string]>;
173
+ /**
174
+ * Compare retrieved chunks against the response text and record which
175
+ * were referenced. Uses a distinctive-token overlap heuristic — cheap,
176
+ * deterministic, no extra LLM calls. Called right after a turn completes.
177
+ */
178
+ private scoreRetrievalOutcomes;
137
179
  private static readonly RATE_LIMIT_MAX_RETRIES;
138
180
  private static readonly RATE_LIMIT_BACKOFF;
139
181
  private runQuery;
@@ -11,9 +11,9 @@
11
11
  */
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
- import { query as rawQuery, listSubagents, getSubagentMessages, } from '@anthropic-ai/claude-agent-sdk';
14
+ import { query as rawQuery, listSubagents, getSubagentMessages, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, } from '@anthropic-ai/claude-agent-sdk';
15
15
  import pino from 'pino';
16
- import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE, MEMORY_FILE, PROFILES_DIR, AGENTS_DIR, ASSISTANT_NAME, OWNER_NAME, MODEL, MODELS, HEARTBEAT_MAX_TURNS, SEARCH_CONTEXT_LIMIT, SEARCH_RECENCY_LIMIT, SYSTEM_PROMPT_MAX_CONTEXT_CHARS, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, UNLEASHED_PHASE_TURNS, UNLEASHED_DEFAULT_MAX_HOURS, UNLEASHED_MAX_PHASES, PROJECTS_META_FILE, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, HANDOFFS_DIR, BUDGET, ENABLE_1M_CONTEXT, IDENTITY_FILE, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, } from '../config.js';
16
+ import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE, MEMORY_FILE, PROFILES_DIR, AGENTS_DIR, ASSISTANT_NAME, OWNER_NAME, MODEL, MODELS, HEARTBEAT_MAX_TURNS, SEARCH_CONTEXT_LIMIT, SEARCH_RECENCY_LIMIT, SYSTEM_PROMPT_MAX_CONTEXT_CHARS, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, UNLEASHED_PHASE_TURNS, UNLEASHED_DEFAULT_MAX_HOURS, UNLEASHED_MAX_PHASES, PROJECTS_META_FILE, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, HANDOFFS_DIR, BUDGET, TASK_BUDGET_TOKENS, ENABLE_1M_CONTEXT, IDENTITY_FILE, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, } from '../config.js';
17
17
  import { DEFAULT_CHANNEL_CAPABILITIES } from '../types.js';
18
18
  import { enforceToolPermissions, getSecurityPrompt, getHeartbeatSecurityPrompt, getCronSecurityPrompt, getHeartbeatDisallowedTools, logToolUse, setProfileTier, setProfileAllowedTools, setAgentDir, setSendPolicy, setInteractionSource, logAuditJsonl, } from './hooks.js';
19
19
  import { scanner } from '../security/scanner.js';
@@ -242,6 +242,9 @@ const query = ((args) => {
242
242
  if (typeof opts.systemPrompt === 'string') {
243
243
  newOpts.systemPrompt = stripLoneSurrogates(opts.systemPrompt);
244
244
  }
245
+ else if (Array.isArray(opts.systemPrompt)) {
246
+ newOpts.systemPrompt = opts.systemPrompt.map((s) => typeof s === 'string' ? stripLoneSurrogates(s) : s);
247
+ }
245
248
  if (typeof opts.appendSystemPrompt === 'string') {
246
249
  newOpts.appendSystemPrompt = stripLoneSurrogates(opts.appendSystemPrompt);
247
250
  }
@@ -636,6 +639,72 @@ export function removeProject(projectPath) {
636
639
  _projectsMetaCacheTime = 0; // invalidate cache
637
640
  return true;
638
641
  }
642
+ // ── Retrieval Outcome Heuristic ─────────────────────────────────────
643
+ /**
644
+ * Decide whether a retrieved memory chunk shows up in the assistant's
645
+ * response. We key on distinctive tokens (multi-letter capitalized words,
646
+ * numbers of 2+ digits) that are unlikely to appear in the response unless
647
+ * the chunk's content actually influenced what was said.
648
+ *
649
+ * Intentionally a cheap local heuristic — no LLM call. False positives are
650
+ * tolerable since the outcome score is bounded and averaged over many
651
+ * observations.
652
+ */
653
+ const OUTCOME_STOPWORDS = new Set([
654
+ 'there', 'these', 'those', 'their', 'where', 'which', 'while',
655
+ 'would', 'could', 'should', 'about', 'being', 'after', 'before',
656
+ 'again', 'against', 'because',
657
+ ]);
658
+ /**
659
+ * Build the compact "active goals" block that gets injected when no goal
660
+ * keyword matches the user's prompt. Pure so it can be tested without the
661
+ * full Assistant/vault setup.
662
+ */
663
+ export function buildActiveGoalsBlock(goals, agentSlug, maxEntries = 6) {
664
+ if (goals.length === 0)
665
+ return '';
666
+ const filtered = goals.filter(({ goal }) => {
667
+ if (!agentSlug)
668
+ return true;
669
+ return goal.owner === agentSlug || goal.owner === 'clementine';
670
+ });
671
+ if (filtered.length === 0)
672
+ return '';
673
+ const rank = { high: 0, medium: 1, low: 2 };
674
+ const sorted = [...filtered].sort((a, b) => {
675
+ const ra = rank[a.goal.priority ?? 'medium'] ?? 1;
676
+ const rb = rank[b.goal.priority ?? 'medium'] ?? 1;
677
+ return ra - rb;
678
+ });
679
+ const top = sorted.slice(0, maxEntries);
680
+ const lines = top.map(({ goal }) => {
681
+ const next = goal.nextActions?.[0];
682
+ const nextBit = next ? ` → ${String(next).slice(0, 80)}` : '';
683
+ return `- [${goal.priority ?? 'medium'}] ${goal.title}${nextBit}`;
684
+ });
685
+ return `\n\n## Active Goals (background context)\n${lines.join('\n')}\n`;
686
+ }
687
+ export function chunkReferencedInResponse(chunkContent, responseLower) {
688
+ if (!chunkContent || !responseLower)
689
+ return false;
690
+ const distinctive = new Set();
691
+ const capMatches = chunkContent.match(/\b[A-Z][a-zA-Z]{3,}\b/g) ?? [];
692
+ for (const m of capMatches) {
693
+ const lower = m.toLowerCase();
694
+ if (!OUTCOME_STOPWORDS.has(lower))
695
+ distinctive.add(lower);
696
+ }
697
+ const numMatches = chunkContent.match(/\b\d{2,}\b/g) ?? [];
698
+ for (const m of numMatches)
699
+ distinctive.add(m);
700
+ if (distinctive.size === 0)
701
+ return false;
702
+ for (const tok of distinctive) {
703
+ if (responseLower.includes(tok))
704
+ return true;
705
+ }
706
+ return false;
707
+ }
639
708
  // ── PersonalAssistant ───────────────────────────────────────────────
640
709
  export class PersonalAssistant {
641
710
  static MAX_SESSION_EXCHANGES = MAX_SESSION_EXCHANGES;
@@ -666,6 +735,14 @@ export class PersonalAssistant {
666
735
  _compactedSessions = new Set();
667
736
  /** Last auto-matched project per session — exposed for CLI display. */
668
737
  _lastMatchedProject = new Map();
738
+ /**
739
+ * Chunks retrieved on the most recent turn per session, kept so the
740
+ * post-response outcome scorer can check which actually got referenced.
741
+ * Cleared after each scoring pass.
742
+ */
743
+ _lastRetrievedChunks = new Map();
744
+ /** Lazy-built SessionStore adapter that mirrors SDK transcripts to SQLite. */
745
+ _sessionStore = null;
669
746
  /** Hot correction buffer — explicit behavioral corrections applied before nightly SI. */
670
747
  hotCorrections = [];
671
748
  constructor() {
@@ -816,11 +893,27 @@ export class PersonalAssistant {
816
893
  this.memoryStore = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
817
894
  this.memoryStore.initialize();
818
895
  this.primeHotCorrections();
896
+ // Build the SDK SessionStore adapter now that the store is live.
897
+ try {
898
+ const { createMemorySessionStore } = await import('./session-store-adapter.js');
899
+ this._sessionStore = createMemorySessionStore(this.memoryStore);
900
+ }
901
+ catch (err) {
902
+ logger.warn({ err }, 'SessionStore adapter init failed — SDK will use local-only sessions');
903
+ }
819
904
  }
820
905
  catch (err) {
821
906
  logger.warn({ err }, 'Memory store init failed — falling back to static prompts');
822
907
  }
823
908
  }
909
+ /**
910
+ * Return the cached SessionStore adapter. Null until initMemoryStore
911
+ * completes, in which case the SDK falls back to local-only sessions —
912
+ * no crash on cold boot.
913
+ */
914
+ getSessionStore() {
915
+ return this._sessionStore;
916
+ }
824
917
  /**
825
918
  * Seed the in-memory hotCorrections ring buffer from persisted behavioral
826
919
  * patterns (corrections that recurred across ≥2 sessions in the last 30d).
@@ -1637,22 +1730,29 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1637
1730
  // Capture source at build time so concurrent queries don't race on the global
1638
1731
  const capturedSource = sourceOverride;
1639
1732
  // Build combined system prompt (custom + security rules).
1640
- // Split is kept intentional: the stable prefix (SOUL/AGENTS/personality/
1641
- // skills) is deterministic per-session; the volatile suffix (integration
1642
- // status, current date/time) changes per-turn. Putting volatile content
1643
- // STRICTLY at the end gives Claude Code's internal prompt cache the best
1644
- // chance at reusing the stable prefix across turns. The SDK's public
1645
- // systemPrompt option only accepts a string, not the Messages-API content
1646
- // array with explicit cache_control, so we rely on the SDK to do the
1647
- // right thing with the layout it receives.
1733
+ // Stable prefix (SOUL/AGENTS/personality/skills + security rules) is
1734
+ // deterministic per-session and cacheable across turns; the volatile
1735
+ // suffix (retrieved memory, active goals, current date/time, integration
1736
+ // status) changes per-turn and must NOT be in the cached prefix.
1737
+ //
1738
+ // The SDK's string[] systemPrompt with SYSTEM_PROMPT_DYNAMIC_BOUNDARY
1739
+ // (added in @anthropic-ai/claude-agent-sdk 0.2.119) tells the prompt
1740
+ // cache exactly where the boundary is, so cross-turn cache hits work
1741
+ // even when our per-turn goals/memory block changes.
1648
1742
  const { stable, volatile: volatilePromptPart } = this.buildSystemPrompt({
1649
1743
  isHeartbeat, cronTier: isPlanStep ? null : cronTier, retrievalContext, profile, sessionKey, model, verboseLevel, intentClassification,
1650
1744
  });
1651
- const fullSystemPrompt = [
1652
- stable,
1653
- securityPrompt,
1654
- volatilePromptPart,
1655
- ].filter(s => s && s.trim().length > 0).join('\n\n');
1745
+ const stablePrefixParts = [stable, securityPrompt]
1746
+ .filter(s => s && s.trim().length > 0);
1747
+ const volatileSuffix = volatilePromptPart && volatilePromptPart.trim().length > 0
1748
+ ? volatilePromptPart
1749
+ : '';
1750
+ // If there is no volatile content, a plain string keeps the call simple
1751
+ // and behaves identically for the cache. Only use the array form when
1752
+ // we actually have dynamic content to split off.
1753
+ const fullSystemPrompt = volatileSuffix
1754
+ ? [...stablePrefixParts, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, volatileSuffix]
1755
+ : stablePrefixParts.join('\n\n');
1656
1756
  // ── Compute effort level ──────────────────────────────────────
1657
1757
  const computedEffort = effort ?? (isHeartbeat && !isCron ? 'low'
1658
1758
  : isCron && (cronTier ?? 0) < 2 ? 'low'
@@ -1669,10 +1769,31 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1669
1769
  : isCron ? BUDGET.cronT2
1670
1770
  : BUDGET.chat);
1671
1771
  void computedBudget; // reserved for future cost telemetry — not enforced
1772
+ // ── Task budget (tokens) ──────────────────────────────────────
1773
+ // Soft brake — the SDK tells the model its remaining token budget so it
1774
+ // paces tool use. Prevents runaway loops in autonomous contexts without
1775
+ // killing long, legitimate work. Interactive chat stays uncapped.
1776
+ const computedTaskBudget = isPlanStep
1777
+ ? TASK_BUDGET_TOKENS.planStep
1778
+ : isUnleashed
1779
+ ? TASK_BUDGET_TOKENS.unleashedPhase
1780
+ : isCron && (cronTier ?? 0) < 2
1781
+ ? TASK_BUDGET_TOKENS.cronT1
1782
+ : isCron
1783
+ ? TASK_BUDGET_TOKENS.cronT2
1784
+ : isHeartbeat
1785
+ ? TASK_BUDGET_TOKENS.heartbeat
1786
+ : TASK_BUDGET_TOKENS.chat;
1672
1787
  // ── Compute adaptive thinking ─────────────────────────────────
1673
1788
  const supportsThinking = !resolvedModel.includes('haiku');
1674
1789
  const needsThinking = !isHeartbeat && (isPlanStep || isUnleashed || !isCron);
1675
1790
  const computedThinking = thinking ?? (supportsThinking && needsThinking ? { type: 'adaptive' } : undefined);
1791
+ // Haiku rejects user-configurable task budgets with a 400 ("This model
1792
+ // does not support user-configurable task budgets"). Only pass
1793
+ // taskBudget to models that accept it — otherwise every Haiku cron
1794
+ // run dies on arrival and (historically) got mis-classified as a
1795
+ // permanent "budget exceeded" failure.
1796
+ const supportsTaskBudget = !resolvedModel.includes('haiku');
1676
1797
  // 1M context beta: enable for Sonnet when toggled and context-heavy work benefits
1677
1798
  const isSonnet = resolvedModel.includes('sonnet');
1678
1799
  const computedBetas = ENABLE_1M_CONTEXT && isSonnet
@@ -1691,12 +1812,17 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1691
1812
  // Permission mode: always 'bypassPermissions' — this is a daemon/harness with no interactive
1692
1813
  // terminal, so 'auto' mode (which requires plan support + human approval) doesn't apply.
1693
1814
  const effectivePermissionMode = 'bypassPermissions';
1815
+ // SessionStore adapter: mirror SDK transcripts into our SQLite store.
1816
+ // Resume then works from the durable store, not just local JSONL.
1817
+ const sessionStore = this.getSessionStore();
1694
1818
  return {
1695
1819
  systemPrompt: fullSystemPrompt,
1696
1820
  model: resolvedModel,
1697
1821
  ...(fallback ? { fallbackModel: fallback } : {}),
1698
1822
  permissionMode: effectivePermissionMode,
1699
1823
  allowDangerouslySkipPermissions: true,
1824
+ ...(sessionStore ? { sessionStore } : {}),
1825
+ ...(computedTaskBudget && supportsTaskBudget ? { taskBudget: { total: computedTaskBudget } } : {}),
1700
1826
  // SDK field semantics (per node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts):
1701
1827
  // - `tools` → which built-in tools the model can see (Read, Bash, Task, …)
1702
1828
  // - `mcpServers` → MCP servers to spawn; all their declared tools are exposed automatically
@@ -1802,6 +1928,17 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1802
1928
  // Non-fatal
1803
1929
  }
1804
1930
  }
1931
+ // Stash chunks for post-response outcome scoring. Only populate if
1932
+ // we have a sessionKey to key against — chunks with no session can't
1933
+ // be attributed to a response.
1934
+ if (sessionKey) {
1935
+ const stash = results
1936
+ .filter((r) => typeof r.chunkId === 'number' && r.chunkId !== 0 && typeof r.content === 'string')
1937
+ .map((r) => ({ id: r.chunkId, content: r.content }));
1938
+ if (stash.length > 0) {
1939
+ this._lastRetrievedChunks.set(sessionKey, stash);
1940
+ }
1941
+ }
1805
1942
  }
1806
1943
  // Resolve skill + graph context in parallel (independent of each other)
1807
1944
  const [skillContext, graphContext] = await Promise.all([
@@ -1949,6 +2086,20 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1949
2086
  return '';
1950
2087
  }
1951
2088
  }
2089
+ /**
2090
+ * Compact always-on block of active goals. Used when no keyword match
2091
+ * fires so the agent still sees what it's supposed to be working on.
2092
+ * Scoped: for agent sessions, includes that agent's goals plus any
2093
+ * clementine-owned goals it might contribute to.
2094
+ */
2095
+ formatActiveGoalsBlock(agentSlug) {
2096
+ try {
2097
+ return buildActiveGoalsBlock(this.loadGoalsFromCache(), agentSlug);
2098
+ }
2099
+ catch {
2100
+ return '';
2101
+ }
2102
+ }
1952
2103
  // ── Chat ──────────────────────────────────────────────────────────
1953
2104
  async chat(text, sessionKey, options) {
1954
2105
  const onText = options?.onText;
@@ -2182,8 +2333,38 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2182
2333
  this.worthExtracting(text, responseText)) {
2183
2334
  this.spawnMemoryExtraction(text, responseText, key, profile).catch(err => logger.debug({ err }, 'Memory extraction failed'));
2184
2335
  }
2336
+ // Score outcome-driven salience: for the chunks we retrieved this turn,
2337
+ // check which actually showed up in the response and adjust their
2338
+ // `last_outcome_score`. Fire-and-forget; failure is non-fatal.
2339
+ if (key && responseText && !isApiError) {
2340
+ this.scoreRetrievalOutcomes(key, responseText);
2341
+ }
2185
2342
  return [responseText, sessionId];
2186
2343
  }
2344
+ /**
2345
+ * Compare retrieved chunks against the response text and record which
2346
+ * were referenced. Uses a distinctive-token overlap heuristic — cheap,
2347
+ * deterministic, no extra LLM calls. Called right after a turn completes.
2348
+ */
2349
+ scoreRetrievalOutcomes(sessionKey, responseText) {
2350
+ const stash = this._lastRetrievedChunks.get(sessionKey);
2351
+ if (!stash || stash.length === 0)
2352
+ return;
2353
+ this._lastRetrievedChunks.delete(sessionKey);
2354
+ if (!this.memoryStore || typeof this.memoryStore.recordOutcome !== 'function')
2355
+ return;
2356
+ try {
2357
+ const responseLower = responseText.toLowerCase();
2358
+ const outcomes = stash.map(({ id, content }) => {
2359
+ const referenced = chunkReferencedInResponse(content, responseLower);
2360
+ return { chunkId: id, referenced };
2361
+ });
2362
+ this.memoryStore.recordOutcome(outcomes, sessionKey);
2363
+ }
2364
+ catch (err) {
2365
+ logger.debug({ err, sessionKey }, 'Outcome scoring failed');
2366
+ }
2367
+ }
2187
2368
  // ── Run Query ─────────────────────────────────────────────────────
2188
2369
  static RATE_LIMIT_MAX_RETRIES = 3;
2189
2370
  static RATE_LIMIT_BACKOFF = [5000, 15000, 30000];
@@ -2237,11 +2418,19 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2237
2418
  const projDesc = matchedProject.description ? ` — ${matchedProject.description}` : '';
2238
2419
  retrievalContext = `## Active Project: ${projName}${projDesc}\n\nYou are operating in the context of the **${projName}** project at \`${matchedProject.path}\`. You have access to this project's tools, MCP servers, and configuration.\n\n${retrievalContext}`;
2239
2420
  }
2240
- // Inject matching goal context so the agent is goal-aware without tool calls
2421
+ // Inject matching goal context so the agent is goal-aware without tool calls.
2422
+ // If no keyword match, fall back to a compact always-on block so active
2423
+ // goals stay in context even when the user message doesn't mention them —
2424
+ // this is what keeps multi-session work coherent across tangential turns.
2241
2425
  const goalContext = this.matchGoals(prompt);
2242
2426
  if (goalContext) {
2243
2427
  retrievalContext += goalContext;
2244
2428
  }
2429
+ else {
2430
+ const proactive = this.formatActiveGoalsBlock(profile?.slug);
2431
+ if (proactive)
2432
+ retrievalContext += proactive;
2433
+ }
2245
2434
  // Timeout: abort the query after timeoutMs to prevent hour-long stalls.
2246
2435
  // Works with or without an existing abortController from the gateway.
2247
2436
  let timeoutHandle;
@@ -2269,8 +2458,15 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2269
2458
  if (sessionKey && this.sessions.has(sessionKey)) {
2270
2459
  sdkOptions.resume = this.sessions.get(sessionKey);
2271
2460
  }
2272
- // Context window guard: estimate token usage and bail if too tight
2273
- const systemPromptText = typeof sdkOptions.systemPrompt === 'string' ? sdkOptions.systemPrompt : '';
2461
+ // Context window guard: estimate token usage and bail if too tight.
2462
+ // systemPrompt may be a plain string or a string[] with a boundary
2463
+ // sentinel — sum across the array elements so the estimate is honest.
2464
+ const sp = sdkOptions.systemPrompt;
2465
+ const systemPromptText = typeof sp === 'string'
2466
+ ? sp
2467
+ : Array.isArray(sp)
2468
+ ? sp.filter((s) => typeof s === 'string' && s !== SYSTEM_PROMPT_DYNAMIC_BOUNDARY).join('\n\n')
2469
+ : '';
2274
2470
  const systemPromptTokens = estimateTokens(systemPromptText);
2275
2471
  const promptTokens = estimateTokens(prompt);
2276
2472
  const totalEstimate = systemPromptTokens + promptTokens;
@@ -2404,7 +2600,11 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2404
2600
  const errorText = 'errors' in result ? result.errors.join('; ') : ('result' in result ? result.result : '');
2405
2601
  if (errorText) {
2406
2602
  const lower = errorText.toLowerCase();
2407
- if (lower.includes('max_budget_usd') || lower.includes('budget')) {
2603
+ // Strict match — only fire on the actual dollar-budget
2604
+ // marker. The bare word "budget" was matching Anthropic's
2605
+ // unrelated "does not support user-configurable task
2606
+ // budgets" 400, which killed Haiku chats.
2607
+ if (lower.includes('max_budget_usd')) {
2408
2608
  logger.warn({ sessionKey }, 'Chat query hit budget cap');
2409
2609
  responseText = responseText || (`I hit the $${BUDGET.chat.toFixed(2)} cost cap for this query. Options:\n` +
2410
2610
  `• Break it into smaller requests\n` +
@@ -3050,7 +3250,10 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3050
3250
  cwd: BASE_DIR,
3051
3251
  env: SAFE_ENV,
3052
3252
  effort: 'low',
3053
- maxBudgetUsd: BUDGET.summarization,
3253
+ // Budgets are opt-in. If BUDGET.summarization is undefined we
3254
+ // must NOT include the key — some SDK codepaths treat a present
3255
+ // undefined as a budget=0 cap.
3256
+ ...(BUDGET.summarization ? { maxBudgetUsd: BUDGET.summarization } : {}),
3054
3257
  },
3055
3258
  });
3056
3259
  for await (const message of stream) {
@@ -3368,7 +3571,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3368
3571
  cwd: BASE_DIR,
3369
3572
  env: SAFE_ENV,
3370
3573
  effort: 'low',
3371
- maxBudgetUsd: BUDGET.memoryExtraction,
3574
+ ...(BUDGET.memoryExtraction ? { maxBudgetUsd: BUDGET.memoryExtraction } : {}),
3372
3575
  },
3373
3576
  });
3374
3577
  const collectedText = [];
@@ -3812,11 +4015,16 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3812
4015
  const result = message;
3813
4016
  // Capture terminal reason for execution advisor
3814
4017
  this._lastTerminalReason = result.terminal_reason ?? undefined;
3815
- // Detect budget exceeded — treat as permanent error so cron doesn't retry
4018
+ // Detect ACTUAL dollar-budget cap — treat as permanent so cron
4019
+ // doesn't retry when we've intentionally capped spend. Use a
4020
+ // strict marker ("max_budget_usd") because the bare word
4021
+ // "budget" was catching Anthropic's unrelated "does not support
4022
+ // user-configurable task budgets" error and pinning perfectly
4023
+ // healthy Haiku jobs as permanent failures.
3816
4024
  if (result.is_error && 'result' in result) {
3817
4025
  const exitText = String(result.result ?? '');
3818
- if (exitText.includes('max_budget_usd') || exitText.includes('budget')) {
3819
- logger.warn({ job: jobName }, 'Cron job hit budget cap — treating as permanent error');
4026
+ if (exitText.includes('max_budget_usd')) {
4027
+ logger.warn({ job: jobName }, 'Cron job hit dollar budget cap — treating as permanent error');
3820
4028
  throw new Error(`Budget exceeded for cron job '${jobName}'`);
3821
4029
  }
3822
4030
  }
@@ -3919,7 +4127,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3919
4127
  cwd: BASE_DIR,
3920
4128
  env: SAFE_ENV,
3921
4129
  effort: 'low',
3922
- maxBudgetUsd: BUDGET.reflection,
4130
+ ...(BUDGET.reflection ? { maxBudgetUsd: BUDGET.reflection } : {}),
3923
4131
  outputFormat: {
3924
4132
  type: 'json_schema',
3925
4133
  schema: {
@@ -4065,7 +4273,10 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4065
4273
  model: model ?? null,
4066
4274
  enableTeams: true,
4067
4275
  isUnleashed: true,
4068
- maxBudgetUsd: BUDGET.unleashedPhase,
4276
+ // buildOptions intentionally drops this before reaching the SDK
4277
+ // (line ~2100 comment). Passing it here only matters if someone
4278
+ // later re-enables the SDK knob.
4279
+ ...(BUDGET.unleashedPhase ? { maxBudgetUsd: BUDGET.unleashedPhase } : {}),
4069
4280
  stallGuard: phaseGuard,
4070
4281
  profile: unleashedProfile,
4071
4282
  });
@@ -4255,11 +4466,12 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4255
4466
  // Capture terminal reason for execution advisor
4256
4467
  this._lastTerminalReason = result.terminal_reason ?? undefined;
4257
4468
  this.logQueryResult(result, 'unleashed', `unleashed:${jobName}`, jobName);
4258
- // Detect budget exceeded
4469
+ // Detect dollar-budget exceeded (strict marker — see cron
4470
+ // handler above for the reasoning).
4259
4471
  if (result.is_error && 'result' in result) {
4260
4472
  const exitText = String(result.result ?? '');
4261
- if (exitText.includes('max_budget_usd') || exitText.includes('budget')) {
4262
- logger.warn({ job: jobName, phase }, 'Unleashed phase hit budget cap');
4473
+ if (exitText.includes('max_budget_usd')) {
4474
+ logger.warn({ job: jobName, phase }, 'Unleashed phase hit dollar budget cap');
4263
4475
  appendProgress({ event: 'budget_exceeded', phase });
4264
4476
  }
4265
4477
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * SessionStore adapter: mirrors the Claude Agent SDK's JSONL session
3
+ * transcript into Clementine's SQLite memory store so resume works from
4
+ * the durable store instead of local files.
5
+ *
6
+ * Introduced after upgrading to @anthropic-ai/claude-agent-sdk 0.2.119.
7
+ * The SDK still writes to local disk first (durability is guaranteed
8
+ * before our adapter sees the batch); this adapter is the secondary
9
+ * copy and is the source of truth for long-term resume.
10
+ */
11
+ import { type SessionStore } from '@anthropic-ai/claude-agent-sdk';
12
+ import type { MemoryStoreType } from '../tools/shared.js';
13
+ export declare function createMemorySessionStore(store: MemoryStoreType): SessionStore;
14
+ //# sourceMappingURL=session-store-adapter.d.ts.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * SessionStore adapter: mirrors the Claude Agent SDK's JSONL session
3
+ * transcript into Clementine's SQLite memory store so resume works from
4
+ * the durable store instead of local files.
5
+ *
6
+ * Introduced after upgrading to @anthropic-ai/claude-agent-sdk 0.2.119.
7
+ * The SDK still writes to local disk first (durability is guaranteed
8
+ * before our adapter sees the batch); this adapter is the secondary
9
+ * copy and is the source of truth for long-term resume.
10
+ */
11
+ import { foldSessionSummary, } from '@anthropic-ai/claude-agent-sdk';
12
+ function subkey(key) {
13
+ return key.subpath ?? '';
14
+ }
15
+ export function createMemorySessionStore(store) {
16
+ const s = store;
17
+ return {
18
+ async append(key, entries) {
19
+ if (entries.length === 0)
20
+ return;
21
+ const sub = subkey(key);
22
+ // Persist the raw entries first so load() is coherent even if the
23
+ // summary sidecar fold throws.
24
+ s.appendSessionEntries(key.sessionId, key.projectKey, sub, entries);
25
+ // Maintain the incrementally-folded summary for cheap listing.
26
+ try {
27
+ const existing = s
28
+ .listSdkSessionSummaries(key.projectKey)
29
+ .find(row => row.sessionId === key.sessionId && row.subpath === sub);
30
+ const prev = existing
31
+ ? {
32
+ sessionId: existing.sessionId,
33
+ mtime: existing.mtime,
34
+ data: existing.data,
35
+ }
36
+ : undefined;
37
+ const next = foldSessionSummary(prev, key, entries);
38
+ s.upsertSessionSummary(key.sessionId, sub, key.projectKey, Date.now(), next.data);
39
+ }
40
+ catch {
41
+ // Non-fatal — summary is a convenience, not a correctness concern.
42
+ }
43
+ },
44
+ async load(key) {
45
+ const rows = s.loadSessionEntries(key.sessionId, subkey(key));
46
+ if (rows === null)
47
+ return null;
48
+ return rows;
49
+ },
50
+ async listSessions(projectKey) {
51
+ return s.listSdkSessions(projectKey);
52
+ },
53
+ async listSessionSummaries(projectKey) {
54
+ return s
55
+ .listSdkSessionSummaries(projectKey)
56
+ .filter(r => r.subpath === '')
57
+ .map(r => ({ sessionId: r.sessionId, mtime: r.mtime, data: r.data }));
58
+ },
59
+ async delete(key) {
60
+ // SDK passes per-key deletes; we scope the delete to all subpaths
61
+ // under the session so a top-level delete wipes subagent trails too.
62
+ s.deleteSdkSession(key.sessionId);
63
+ },
64
+ async listSubkeys(key) {
65
+ return s.listSdkSessionSubkeys(key.sessionId);
66
+ },
67
+ };
68
+ }
69
+ //# sourceMappingURL=session-store-adapter.js.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Clementine — Adapter common helpers.
3
+ */
4
+ /** Truncated SHA-256 content hash, hex, first 16 chars. */
5
+ export declare function contentHash(text: string): string;
6
+ /** Build a stable externalId fallback from (source-hint, index, content). */
7
+ export declare function fallbackExternalId(hint: string, index: number, content: string): string;
8
+ /** Detect whether a value looks like a stable identifier column. */
9
+ export declare function looksLikeIdKey(key: string): boolean;
10
+ /** Pick a likely id column from a record's keys (for structured adapters). */
11
+ export declare function pickIdField(keys: string[]): string | null;
12
+ //# sourceMappingURL=common.d.ts.map
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Clementine — Adapter common helpers.
3
+ */
4
+ import { createHash } from 'node:crypto';
5
+ /** Truncated SHA-256 content hash, hex, first 16 chars. */
6
+ export function contentHash(text) {
7
+ return createHash('sha256').update(text).digest('hex').slice(0, 16);
8
+ }
9
+ /** Build a stable externalId fallback from (source-hint, index, content). */
10
+ export function fallbackExternalId(hint, index, content) {
11
+ return `${hint}-${index}-${contentHash(content)}`;
12
+ }
13
+ /** Detect whether a value looks like a stable identifier column. */
14
+ export function looksLikeIdKey(key) {
15
+ const lower = key.toLowerCase();
16
+ return (lower === 'id' ||
17
+ lower.endsWith('_id') ||
18
+ lower.endsWith('id') && lower.length <= 6 ||
19
+ lower === 'uuid' || lower === 'guid' || lower === 'uid' ||
20
+ lower === 'email' || lower === 'message_id' || lower === 'sfid');
21
+ }
22
+ /** Pick a likely id column from a record's keys (for structured adapters). */
23
+ export function pickIdField(keys) {
24
+ for (const k of keys)
25
+ if (looksLikeIdKey(k))
26
+ return k;
27
+ return null;
28
+ }
29
+ //# sourceMappingURL=common.js.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Clementine — CSV adapter.
3
+ *
4
+ * Streams rows from a CSV file (comma- or tab-separated). Each row is a
5
+ * RawRecord with stringified JSON content so the downstream pipeline can
6
+ * template/distill it the same way as any other structured source.
7
+ */
8
+ import type { RawRecord } from '../../types.js';
9
+ export declare function parseCsv(filePath: string): AsyncIterable<RawRecord>;
10
+ //# sourceMappingURL=csv.d.ts.map