clementine-agent 1.0.33 → 1.0.35

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.
@@ -198,6 +198,11 @@ export class SelfImproveLoop {
198
198
  const loopStart = Date.now();
199
199
  const history = this.loadExperimentLog();
200
200
  let consecutiveLow = 0;
201
+ // Cap accepted proposals per run so the owner's approval queue stays
202
+ // scannable. The nightly loop should surface 1-3 solid ideas — not a
203
+ // flood — even if the hypothesizer is inspired.
204
+ const maxAcceptancesPerRun = 3;
205
+ let acceptedThisRun = 0;
201
206
  try {
202
207
  // Step 1: Gather baseline metrics
203
208
  const metrics = await this.gatherMetrics();
@@ -379,6 +384,7 @@ export class SelfImproveLoop {
379
384
  }
380
385
  }
381
386
  consecutiveLow = 0;
387
+ acceptedThisRun++;
382
388
  }
383
389
  else {
384
390
  consecutiveLow++;
@@ -389,7 +395,13 @@ export class SelfImproveLoop {
389
395
  area: proposal.area,
390
396
  score,
391
397
  accepted,
398
+ acceptedThisRun,
392
399
  }, `Iteration ${i} complete`);
400
+ // Stop once we've landed enough good ideas for the owner to review.
401
+ if (acceptedThisRun >= maxAcceptancesPerRun) {
402
+ logger.info({ acceptedThisRun }, 'Reached max-acceptances per run — stopping');
403
+ break;
404
+ }
393
405
  }
394
406
  catch (err) {
395
407
  const classified = classifyError(err);
@@ -666,6 +678,17 @@ export class SelfImproveLoop {
666
678
  const recentTargets = new Map();
667
679
  const recentAreas = new Map();
668
680
  for (const e of history.slice(-50)) {
681
+ // Skip error-fallback experiments. They default to `area: 'soul', target:
682
+ // 'unknown'` (see the error-catch block below) and historically have
683
+ // poisoned diversity accounting — e.g. a ~2-week stretch of API errors
684
+ // artificially blacklisted the whole 'soul' area even though no real
685
+ // attempt was made. A crashed iteration isn't evidence we explored the
686
+ // space, just that the SDK call failed.
687
+ if (e.reason?.startsWith('Error:'))
688
+ continue;
689
+ // Plateau markers also shouldn't count as attempts.
690
+ if (e.hypothesis?.startsWith('No new hypothesis'))
691
+ continue;
669
692
  const key = `${e.area}:${e.target}`;
670
693
  const ts = Date.parse(e.startedAt);
671
694
  const tsMs = Number.isFinite(ts) ? ts : 0;
@@ -717,7 +740,7 @@ export class SelfImproveLoop {
717
740
  (overTargeted.length > 0
718
741
  ? `These specific targets MUST NOT be re-targeted:\n${overTargeted.map(t => `- ${t}`).join('\n')}\n`
719
742
  : '') +
720
- `Choose a DIFFERENT area/target. If no other improvement is needed, output { "area": null }.\n`
743
+ `Choose a DIFFERENT area/target. If no other improvement is genuinely needed today, return an empty results array: { "results": [] }.\n`
721
744
  : '');
722
745
  const patternAnalysis = this.analyzeExperimentPatterns(history);
723
746
  // Format negative feedback
@@ -802,18 +825,17 @@ export class SelfImproveLoop {
802
825
  agentFocusText +
803
826
  soulCandidatesText +
804
827
  `\n## Instructions\n` +
805
- `Rank these by expected impact. For each opportunity, specify:\n` +
828
+ `Propose **1-3 concrete, high-impact improvements** the owner should review today — no fewer (aim for at least one actionable suggestion when data warrants it), no more (the owner reads each proposal manually and you'll overwhelm them). Rank by expected impact; drop anything below "solid idea".\n\n` +
829
+ `For each opportunity, specify:\n` +
806
830
  `- area: ${areas}\n` +
807
- `- target: the file/agent slug that should change\n` +
831
+ `- target: the exact file path / agent slug / cron job name that should change (not "unknown", not "n/a")\n` +
808
832
  `- what: a 1-sentence description of what specifically should change\n` +
809
- `- why: which metric this should improve\n\n` +
833
+ `- why: which metric or signal from the data above this should improve\n\n` +
810
834
  `Area notes:\n` +
811
835
  `- For "goal": target = "{owner}/{goal-slug}" (e.g. "clementine/improve-reply-rates" or "ross-the-sdr/book-demos"). ` +
812
836
  `Propose when you observe a pattern in completed tasks or cron runs that suggests a missing or stale goal. ` +
813
837
  `The proposedChange must be a JSON goal object with at minimum: title, description, priority, reviewFrequency.\n\n` +
814
- `Output ONLY a JSON array of 1-3 objects (no markdown, no explanation):\n` +
815
- `[{ "area": "...", "target": "...", "what": "...", "why": "..." }]\n` +
816
- `If no improvement is needed, output: []`;
838
+ `Return your answer as a JSON object matching the schema: { "results": [ ... ] }. Up to 3 items. If absolutely nothing actionable today, return { "results": [] }.`;
817
839
  const analysisResult = await this.assistant.runPlanStep('si-analyze', analysisPrompt, {
818
840
  tier: 2,
819
841
  maxTurns: 3,
@@ -33,14 +33,15 @@ export async function assembleContext(options) {
33
33
  priority: 0,
34
34
  maxChars: 500,
35
35
  minRemainingBudget: 0,
36
- resolve: () => {
36
+ resolve: (budget) => {
37
37
  if (!fs.existsSync(idPath))
38
38
  return '';
39
39
  try {
40
40
  const content = fs.readFileSync(idPath, 'utf-8').trim();
41
41
  if (!content)
42
42
  return '';
43
- return `## Identity\n\n${content}`;
43
+ const block = `## Identity\n\n${content}`;
44
+ return block.length > budget ? block.slice(0, budget) : block;
44
45
  }
45
46
  catch {
46
47
  return '';
@@ -56,14 +57,15 @@ export async function assembleContext(options) {
56
57
  priority: 1,
57
58
  maxChars: isAutonomous ? 1000 : 2000,
58
59
  minRemainingBudget: 0,
59
- resolve: () => {
60
+ resolve: (budget) => {
60
61
  if (!fs.existsSync(wmPath))
61
62
  return '';
62
63
  try {
63
64
  const content = fs.readFileSync(wmPath, 'utf-8').trim();
64
65
  if (!content)
65
66
  return '';
66
- return `## Working Memory (scratchpad)\n\n${content}`;
67
+ const block = `## Working Memory (scratchpad)\n\n${content}`;
68
+ return block.length > budget ? block.slice(0, budget) : block;
67
69
  }
68
70
  catch {
69
71
  return '';
@@ -79,10 +81,15 @@ export async function assembleContext(options) {
79
81
  priority: 2,
80
82
  maxChars: isAutonomous ? 1000 : 2000,
81
83
  minRemainingBudget: 500,
82
- resolve: () => skillCtx,
84
+ resolve: (budget) => skillCtx.length > budget ? skillCtx.slice(0, budget) : skillCtx,
83
85
  });
84
86
  }
85
87
  // Slot 3: Memory search results (core recall)
88
+ // formatResultsForPrompt respects the effective budget and breaks on
89
+ // entry boundaries (not mid-string), so we don't need the outer
90
+ // slice-truncation to kick in here. Previously this slot was double-
91
+ // truncated: formatter used its own 8000 cap, then the outer loop cut
92
+ // further by Math.min(maxChars, remaining), chopping entries in half.
86
93
  if (options.memoryResults && options.memoryResults.length > 0) {
87
94
  const results = options.memoryResults;
88
95
  slots.push({
@@ -90,10 +97,7 @@ export async function assembleContext(options) {
90
97
  priority: 3,
91
98
  maxChars: isAutonomous ? 2000 : 8000,
92
99
  minRemainingBudget: 200,
93
- resolve: () => {
94
- // formatResultsForPrompt already handles truncation within its own budget
95
- return formatResultsForPrompt(results, isAutonomous ? 2000 : 8000);
96
- },
100
+ resolve: (budget) => formatResultsForPrompt(results, budget),
97
101
  });
98
102
  }
99
103
  // Slot 4: Graph relationships (supplementary)
@@ -104,7 +108,7 @@ export async function assembleContext(options) {
104
108
  priority: 4,
105
109
  maxChars: 2000,
106
110
  minRemainingBudget: 500,
107
- resolve: () => graphCtx,
111
+ resolve: (budget) => graphCtx.length > budget ? graphCtx.slice(0, budget) : graphCtx,
108
112
  });
109
113
  }
110
114
  // Sort by priority (lower number = higher priority)
@@ -121,18 +125,27 @@ export async function assembleContext(options) {
121
125
  continue;
122
126
  }
123
127
  try {
124
- let content = await slot.resolve();
128
+ // The slot's effective budget is the smaller of its own maxChars and
129
+ // what's actually remaining across all slots. Passed into resolve so
130
+ // the slot produces right-sized content up front, not a mid-entry
131
+ // truncation after the fact.
132
+ const effectiveBudget = Math.min(slot.maxChars, remaining);
133
+ const content = await slot.resolve(effectiveBudget);
125
134
  if (!content) {
126
135
  skipped.push(slot.name);
127
136
  continue;
128
137
  }
129
- // Truncate to the smaller of slot max and remaining budget
130
- const limit = Math.min(slot.maxChars, remaining);
131
- if (content.length > limit) {
132
- content = content.slice(0, limit) + '\n...(truncated)';
138
+ // Safety net: if resolve() ignored the budget and returned too much,
139
+ // clip at a line boundary rather than a character boundary so we don't
140
+ // leave a malformed half-block in the prompt.
141
+ let finalContent = content;
142
+ if (content.length > effectiveBudget) {
143
+ const trimmed = content.slice(0, effectiveBudget);
144
+ const lastNewline = trimmed.lastIndexOf('\n');
145
+ finalContent = (lastNewline > 0 ? trimmed.slice(0, lastNewline) : trimmed) + '\n...(truncated)';
133
146
  }
134
- parts.push(content);
135
- remaining -= content.length;
147
+ parts.push(finalContent);
148
+ remaining -= finalContent.length;
136
149
  included.push(slot.name);
137
150
  }
138
151
  catch {
@@ -61,6 +61,22 @@ export declare class GraphStore {
61
61
  syncFromVault(vaultDir: string, agentsDir: string): Promise<GraphSyncStats>;
62
62
  extractAndStoreRelationships(triplets: RelationshipTriplet[]): Promise<void>;
63
63
  enrichWithGraphContext(entityIds: string[], _maxHops?: number): Promise<string>;
64
+ /**
65
+ * Drop Note nodes whose slug isn't in the caller-provided set of valid IDs.
66
+ * Wikilinks into deleted vault files leave dangling Note nodes with
67
+ * MENTIONS edges pointing at them — this cleans those up.
68
+ *
69
+ * Deliberately NOT auto-scheduled: blast radius is significant, and the
70
+ * caller (dashboard action, MCP tool, manual script) should supply the
71
+ * authoritative valid-IDs set. Runs DETACH DELETE so incoming edges go
72
+ * with the node.
73
+ *
74
+ * Returns counts of what was removed.
75
+ */
76
+ invalidateOrphanedNotes(validIds: Set<string>): Promise<{
77
+ scanned: number;
78
+ deleted: number;
79
+ }>;
64
80
  }
65
81
  export declare function getSharedGraphStore(persistenceDir: string): Promise<GraphStore | null>;
66
82
  //# sourceMappingURL=graph-store.d.ts.map
@@ -580,6 +580,53 @@ export class GraphStore {
580
580
  return '';
581
581
  return '\n## Relationship Context\n' + lines.join('\n');
582
582
  }
583
+ /**
584
+ * Drop Note nodes whose slug isn't in the caller-provided set of valid IDs.
585
+ * Wikilinks into deleted vault files leave dangling Note nodes with
586
+ * MENTIONS edges pointing at them — this cleans those up.
587
+ *
588
+ * Deliberately NOT auto-scheduled: blast radius is significant, and the
589
+ * caller (dashboard action, MCP tool, manual script) should supply the
590
+ * authoritative valid-IDs set. Runs DETACH DELETE so incoming edges go
591
+ * with the node.
592
+ *
593
+ * Returns counts of what was removed.
594
+ */
595
+ async invalidateOrphanedNotes(validIds) {
596
+ if (!this.available)
597
+ return { scanned: 0, deleted: 0 };
598
+ if (validIds.size === 0) {
599
+ // Defense: refuse to run with an empty set — would delete every Note.
600
+ logger.warn('invalidateOrphanedNotes called with empty validIds — refusing to run');
601
+ return { scanned: 0, deleted: 0 };
602
+ }
603
+ let scanned = 0;
604
+ let deleted = 0;
605
+ try {
606
+ const res = await this.graph.query('MATCH (n:Note) RETURN n.id AS id');
607
+ const rows = (res.data ?? []);
608
+ scanned = rows.length;
609
+ for (const row of rows) {
610
+ const id = row.id;
611
+ if (!id || validIds.has(id))
612
+ continue;
613
+ try {
614
+ await this.graph.query('MATCH (n:Note {id: $id}) DETACH DELETE n', { params: { id } });
615
+ deleted++;
616
+ }
617
+ catch (err) {
618
+ logger.debug({ err, id }, 'Orphan Note deletion failed');
619
+ }
620
+ }
621
+ }
622
+ catch (err) {
623
+ logger.warn({ err }, 'invalidateOrphanedNotes query failed');
624
+ }
625
+ if (deleted > 0) {
626
+ logger.info({ scanned, deleted, validIdsSize: validIds.size }, 'Invalidated orphan Note nodes');
627
+ }
628
+ return { scanned, deleted };
629
+ }
583
630
  }
584
631
  // ── Shared Client Helper ───────────────────────────────────────────────
585
632
  /**
@@ -14,6 +14,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statS
14
14
  import path from 'node:path';
15
15
  import Database from 'better-sqlite3';
16
16
  import { BASE_DIR } from '../config.js';
17
+ import { temporalDecay } from './search.js';
17
18
  import * as embeddingsModule from './embeddings.js';
18
19
  import { chunkFile } from './chunker.js';
19
20
  import { mmrRerank } from './mmr.js';
@@ -733,20 +734,30 @@ export class MemoryStore {
733
734
  * Get the most recently updated chunks.
734
735
  */
735
736
  getRecentChunks(limit = 5, agentSlug, filters, strict = false) {
736
- const mapRow = (row) => ({
737
- sourceFile: row.source_file,
738
- section: row.section,
739
- content: row.content,
740
- score: 0,
741
- chunkType: row.chunk_type,
742
- matchType: 'recency',
743
- lastUpdated: row.updated_at ?? '',
744
- chunkId: row.id,
745
- salience: row.salience ?? 0,
746
- agentSlug: row.agent_slug ?? null,
747
- category: row.category,
748
- topic: row.topic,
749
- });
737
+ const now = Date.now();
738
+ const mapRow = (row) => {
739
+ // Score recency by exponential decay (half-life 30 days). Previously
740
+ // every recent row got score=0, which meant MMR's min-max normalization
741
+ // ranked them at the floor — a two-day-old chunk and a six-month-old
742
+ // chunk were indistinguishable. Decay lets recent results actually
743
+ // compete with FTS and vector matches during rerank.
744
+ const daysOld = row.updated_at ? (now - Date.parse(row.updated_at)) / 86_400_000 : 0;
745
+ const decayed = temporalDecay(daysOld);
746
+ return {
747
+ sourceFile: row.source_file,
748
+ section: row.section,
749
+ content: row.content,
750
+ score: decayed,
751
+ chunkType: row.chunk_type,
752
+ matchType: 'recency',
753
+ lastUpdated: row.updated_at ?? '',
754
+ chunkId: row.id,
755
+ salience: row.salience ?? 0,
756
+ agentSlug: row.agent_slug ?? null,
757
+ category: row.category,
758
+ topic: row.topic,
759
+ };
760
+ };
750
761
  // Build optional WHERE clauses for category/topic
751
762
  let filterSql = '';
752
763
  const filterParams = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.33",
3
+ "version": "1.0.35",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",