clementine-agent 1.0.33 → 1.0.34

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.
@@ -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.34",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",