context-vault 3.7.0 → 3.9.0

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 (73) hide show
  1. package/assets/agent-rules.md +28 -1
  2. package/assets/setup-prompt.md +16 -1
  3. package/bin/cli.js +1003 -7
  4. package/dist/auto-memory.d.ts +52 -0
  5. package/dist/auto-memory.d.ts.map +1 -0
  6. package/dist/auto-memory.js +142 -0
  7. package/dist/auto-memory.js.map +1 -0
  8. package/dist/register-tools.d.ts.map +1 -1
  9. package/dist/register-tools.js +2 -0
  10. package/dist/register-tools.js.map +1 -1
  11. package/dist/remote.d.ts +134 -0
  12. package/dist/remote.d.ts.map +1 -0
  13. package/dist/remote.js +242 -0
  14. package/dist/remote.js.map +1 -0
  15. package/dist/remote.test.d.ts +2 -0
  16. package/dist/remote.test.d.ts.map +1 -0
  17. package/dist/remote.test.js +107 -0
  18. package/dist/remote.test.js.map +1 -0
  19. package/dist/stats/recall.d.ts +33 -0
  20. package/dist/stats/recall.d.ts.map +1 -0
  21. package/dist/stats/recall.js +86 -0
  22. package/dist/stats/recall.js.map +1 -0
  23. package/dist/tools/context-status.d.ts.map +1 -1
  24. package/dist/tools/context-status.js +40 -0
  25. package/dist/tools/context-status.js.map +1 -1
  26. package/dist/tools/get-context.d.ts.map +1 -1
  27. package/dist/tools/get-context.js +44 -0
  28. package/dist/tools/get-context.js.map +1 -1
  29. package/dist/tools/publish-to-team.d.ts +11 -0
  30. package/dist/tools/publish-to-team.d.ts.map +1 -0
  31. package/dist/tools/publish-to-team.js +91 -0
  32. package/dist/tools/publish-to-team.js.map +1 -0
  33. package/dist/tools/publish-to-team.test.d.ts +2 -0
  34. package/dist/tools/publish-to-team.test.d.ts.map +1 -0
  35. package/dist/tools/publish-to-team.test.js +95 -0
  36. package/dist/tools/publish-to-team.test.js.map +1 -0
  37. package/dist/tools/recall.d.ts +1 -1
  38. package/dist/tools/recall.d.ts.map +1 -1
  39. package/dist/tools/recall.js +85 -1
  40. package/dist/tools/recall.js.map +1 -1
  41. package/dist/tools/save-context.d.ts +5 -1
  42. package/dist/tools/save-context.d.ts.map +1 -1
  43. package/dist/tools/save-context.js +163 -2
  44. package/dist/tools/save-context.js.map +1 -1
  45. package/dist/tools/session-start.d.ts.map +1 -1
  46. package/dist/tools/session-start.js +90 -86
  47. package/dist/tools/session-start.js.map +1 -1
  48. package/node_modules/@context-vault/core/dist/config.d.ts +3 -1
  49. package/node_modules/@context-vault/core/dist/config.d.ts.map +1 -1
  50. package/node_modules/@context-vault/core/dist/config.js +48 -2
  51. package/node_modules/@context-vault/core/dist/config.js.map +1 -1
  52. package/node_modules/@context-vault/core/dist/main.d.ts +1 -1
  53. package/node_modules/@context-vault/core/dist/main.d.ts.map +1 -1
  54. package/node_modules/@context-vault/core/dist/main.js.map +1 -1
  55. package/node_modules/@context-vault/core/dist/types.d.ts +7 -0
  56. package/node_modules/@context-vault/core/dist/types.d.ts.map +1 -1
  57. package/node_modules/@context-vault/core/package.json +1 -1
  58. package/node_modules/@context-vault/core/src/config.ts +50 -3
  59. package/node_modules/@context-vault/core/src/main.ts +1 -0
  60. package/node_modules/@context-vault/core/src/types.ts +8 -0
  61. package/package.json +2 -2
  62. package/src/auto-memory.ts +169 -0
  63. package/src/register-tools.ts +2 -0
  64. package/src/remote.test.ts +123 -0
  65. package/src/remote.ts +325 -0
  66. package/src/stats/recall.ts +139 -0
  67. package/src/tools/context-status.ts +40 -0
  68. package/src/tools/get-context.ts +44 -0
  69. package/src/tools/publish-to-team.test.ts +115 -0
  70. package/src/tools/publish-to-team.ts +112 -0
  71. package/src/tools/recall.ts +79 -1
  72. package/src/tools/save-context.ts +167 -1
  73. package/src/tools/session-start.ts +88 -100
package/src/remote.ts ADDED
@@ -0,0 +1,325 @@
1
+ import type { RemoteConfig } from '@context-vault/core/types';
2
+
3
+ const REQUEST_TIMEOUT_MS = 10_000;
4
+
5
+ export interface RemoteSearchResult {
6
+ id: string;
7
+ kind: string;
8
+ category: string;
9
+ title: string | null;
10
+ body: string;
11
+ tags: string | null;
12
+ tier: string;
13
+ score: number;
14
+ created_at: string;
15
+ updated_at: string | null;
16
+ source: string | null;
17
+ identity_key: string | null;
18
+ meta: string | null;
19
+ file_path: string | null;
20
+ superseded_by: string | null;
21
+ expires_at: string | null;
22
+ source_files: string | null;
23
+ related_to: string | null;
24
+ indexed: number;
25
+ hit_count: number;
26
+ last_accessed_at: string | null;
27
+ recall_count: number;
28
+ recall_sessions: number;
29
+ last_recalled_at: string | null;
30
+ recall_members?: number;
31
+ }
32
+
33
+ export interface RemoteHint {
34
+ id: string;
35
+ title: string;
36
+ summary: string;
37
+ relevance: 'high' | 'medium';
38
+ kind: string;
39
+ tags: string[];
40
+ }
41
+
42
+ export interface TeamSearchResult extends RemoteSearchResult {
43
+ source: 'team';
44
+ recall_count: number;
45
+ recall_members?: number;
46
+ }
47
+
48
+ export interface PrivacyScanMatch {
49
+ type: string;
50
+ value: string;
51
+ field: string;
52
+ line: number;
53
+ }
54
+
55
+ export interface PublishResult {
56
+ ok: boolean;
57
+ id?: string;
58
+ error?: string;
59
+ status?: number;
60
+ privacyMatches?: PrivacyScanMatch[];
61
+ conflict?: {
62
+ existing_entry_id: string;
63
+ existing_author: string;
64
+ similarity: number;
65
+ suggestion: string;
66
+ };
67
+ }
68
+
69
+ export class RemoteClient {
70
+ private url: string;
71
+ private apiKey: string;
72
+
73
+ constructor(config: RemoteConfig) {
74
+ this.url = config.url.replace(/\/$/, '');
75
+ this.apiKey = config.apiKey;
76
+ }
77
+
78
+ async testConnection(): Promise<{ ok: boolean; error?: string; status?: unknown }> {
79
+ try {
80
+ const res = await this.fetch('/api/vault/status');
81
+ if (res.ok) {
82
+ const data = await res.json();
83
+ return { ok: true, status: data };
84
+ }
85
+ const text = await res.text().catch(() => '');
86
+ return { ok: false, error: `HTTP ${res.status}: ${text}` };
87
+ } catch (e) {
88
+ return { ok: false, error: (e as Error).message };
89
+ }
90
+ }
91
+
92
+ async saveEntry(entry: Record<string, unknown>): Promise<{ ok: boolean; id?: string; error?: string }> {
93
+ try {
94
+ const res = await this.fetch('/api/vault/entries', {
95
+ method: 'POST',
96
+ body: JSON.stringify(entry),
97
+ });
98
+ if (res.ok) {
99
+ const data = await res.json().catch(() => ({})) as Record<string, unknown>;
100
+ return { ok: true, id: data.id as string | undefined };
101
+ }
102
+ const text = await res.text().catch(() => '');
103
+ return { ok: false, error: `HTTP ${res.status}: ${text}` };
104
+ } catch (e) {
105
+ return { ok: false, error: (e as Error).message };
106
+ }
107
+ }
108
+
109
+ async search(params: Record<string, unknown>): Promise<RemoteSearchResult[]> {
110
+ try {
111
+ const query = new URLSearchParams();
112
+ if (params.query) query.set('q', String(params.query));
113
+ if (Array.isArray(params.tags) && params.tags.length) query.set('tags', params.tags.join(','));
114
+ if (params.kind) query.set('kind', String(params.kind));
115
+ if (params.category) query.set('category', String(params.category));
116
+ if (params.scope) query.set('scope', String(params.scope));
117
+ if (params.limit) query.set('limit', String(params.limit));
118
+ if (params.since) query.set('since', String(params.since));
119
+ if (params.until) query.set('until', String(params.until));
120
+
121
+ const res = await this.fetch(`/api/vault/search?${query.toString()}`);
122
+ if (!res.ok) return [];
123
+ const data = await res.json() as Record<string, unknown>;
124
+ return Array.isArray(data.entries) ? data.entries : Array.isArray(data) ? data as RemoteSearchResult[] : [];
125
+ } catch {
126
+ return [];
127
+ }
128
+ }
129
+
130
+ async teamSearch(teamId: string, params: Record<string, unknown>): Promise<TeamSearchResult[]> {
131
+ try {
132
+ const query = new URLSearchParams();
133
+ if (params.query) query.set('q', String(params.query));
134
+ if (Array.isArray(params.tags) && params.tags.length) query.set('tags', params.tags.join(','));
135
+ if (params.kind) query.set('kind', String(params.kind));
136
+ if (params.category) query.set('category', String(params.category));
137
+ if (params.limit) query.set('limit', String(params.limit));
138
+ if (params.since) query.set('since', String(params.since));
139
+ if (params.until) query.set('until', String(params.until));
140
+
141
+ const res = await this.fetch(`/api/team/${teamId}/search?${query.toString()}`);
142
+ if (!res.ok) return [];
143
+ const data = await res.json() as Record<string, unknown>;
144
+ const entries = Array.isArray(data.entries) ? data.entries : Array.isArray(data) ? data : [];
145
+ return (entries as TeamSearchResult[]).map(e => ({ ...e, source: 'team' as const }));
146
+ } catch {
147
+ return [];
148
+ }
149
+ }
150
+
151
+ async teamRecall(teamId: string, params: {
152
+ signal: string;
153
+ signal_type: string;
154
+ bucket?: string;
155
+ max_hints?: number;
156
+ }): Promise<RemoteHint[]> {
157
+ try {
158
+ const res = await this.fetch(`/api/team/${teamId}/search`, {
159
+ method: 'POST',
160
+ body: JSON.stringify(params),
161
+ });
162
+ if (!res.ok) return [];
163
+ const data = await res.json() as Record<string, unknown>;
164
+ return Array.isArray(data.hints) ? data.hints : Array.isArray(data) ? data as RemoteHint[] : [];
165
+ } catch {
166
+ return [];
167
+ }
168
+ }
169
+
170
+ async teamStatus(teamId: string): Promise<{ ok: boolean; error?: string; data?: Record<string, unknown> }> {
171
+ try {
172
+ const res = await this.fetch(`/api/team/${teamId}/status`);
173
+ if (res.ok) {
174
+ const data = await res.json() as Record<string, unknown>;
175
+ return { ok: true, data };
176
+ }
177
+ const text = await res.text().catch(() => '');
178
+ return { ok: false, error: `HTTP ${res.status}: ${text}` };
179
+ } catch (e) {
180
+ return { ok: false, error: (e as Error).message };
181
+ }
182
+ }
183
+
184
+ async publishToTeam(params: {
185
+ entryId: string;
186
+ teamId: string;
187
+ visibility: string;
188
+ force?: boolean;
189
+ entry?: Record<string, unknown>;
190
+ }): Promise<PublishResult> {
191
+ try {
192
+ const res = await this.fetch('/api/vault/publish', {
193
+ method: 'POST',
194
+ body: JSON.stringify({
195
+ entryId: params.entryId,
196
+ teamId: params.teamId,
197
+ visibility: params.visibility,
198
+ ...(params.force ? { force: true } : {}),
199
+ ...(params.entry || {}),
200
+ }),
201
+ });
202
+ const data = await res.json().catch(() => ({})) as Record<string, unknown>;
203
+ if (res.ok) {
204
+ return {
205
+ ok: true,
206
+ id: data.id as string | undefined,
207
+ conflict: data.conflict as PublishResult['conflict'],
208
+ };
209
+ }
210
+ if (res.status === 422 && data.code === 'PRIVACY_SCAN_FAILED') {
211
+ const matches = Array.isArray(data.matches) ? data.matches as PrivacyScanMatch[] : [];
212
+ return {
213
+ ok: false,
214
+ error: typeof data.error === 'string' ? data.error : 'Privacy scan failed',
215
+ status: 422,
216
+ privacyMatches: matches,
217
+ };
218
+ }
219
+ const errorText = typeof data.error === 'string' ? data.error : `HTTP ${res.status}`;
220
+ return { ok: false, error: errorText, status: res.status, conflict: data.conflict as PublishResult['conflict'] };
221
+ } catch (e) {
222
+ return { ok: false, error: (e as Error).message };
223
+ }
224
+ }
225
+
226
+ async recall(params: {
227
+ signal: string;
228
+ signal_type: string;
229
+ bucket?: string;
230
+ max_hints?: number;
231
+ }): Promise<RemoteHint[]> {
232
+ try {
233
+ const res = await this.fetch('/api/vault/recall', {
234
+ method: 'POST',
235
+ body: JSON.stringify(params),
236
+ });
237
+ if (!res.ok) return [];
238
+ const data = await res.json() as Record<string, unknown>;
239
+ return Array.isArray(data.hints) ? data.hints : Array.isArray(data) ? data as RemoteHint[] : [];
240
+ } catch {
241
+ return [];
242
+ }
243
+ }
244
+
245
+ private fetch(path: string, opts: RequestInit = {}): Promise<Response> {
246
+ const controller = new AbortController();
247
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
248
+
249
+ return globalThis.fetch(`${this.url}${path}`, {
250
+ ...opts,
251
+ signal: controller.signal,
252
+ headers: {
253
+ 'Authorization': `Bearer ${this.apiKey}`,
254
+ 'Content-Type': 'application/json',
255
+ ...(opts.headers || {}),
256
+ },
257
+ }).finally(() => clearTimeout(timeout));
258
+ }
259
+ }
260
+
261
+ let cachedClient: RemoteClient | null = null;
262
+ let cachedConfigKey = '';
263
+
264
+ export function getRemoteClient(config: { remote?: RemoteConfig }): RemoteClient | null {
265
+ if (!config.remote?.enabled || !config.remote?.apiKey) return null;
266
+
267
+ const key = `${config.remote.url}:${config.remote.apiKey}`;
268
+ if (cachedClient && cachedConfigKey === key) return cachedClient;
269
+
270
+ cachedClient = new RemoteClient(config.remote);
271
+ cachedConfigKey = key;
272
+ return cachedClient;
273
+ }
274
+
275
+ export function getTeamId(config: { remote?: RemoteConfig }): string | null {
276
+ return config.remote?.teamId || null;
277
+ }
278
+
279
+ /**
280
+ * Apply recall-driven ranking boost to team results.
281
+ * Entries with higher recall_count and recall_members rank higher.
282
+ * Formula: score * log(1 + recall_count) * (1 + 0.1 * recall_members)
283
+ */
284
+ export function applyTeamRecallBoost<T extends { score?: number; recall_count?: number; recall_members?: number }>(
285
+ entries: T[]
286
+ ): T[] {
287
+ return entries.map(e => {
288
+ const baseScore = (e as any).score ?? 0;
289
+ const recallCount = e.recall_count ?? 0;
290
+ const recallMembers = e.recall_members ?? 0;
291
+ if (recallCount === 0 && recallMembers === 0) return e;
292
+ const boostedScore = baseScore * Math.log(1 + recallCount) * (1 + 0.1 * recallMembers);
293
+ return { ...e, score: boostedScore };
294
+ });
295
+ }
296
+
297
+ /**
298
+ * Merge local, personal remote, and team results.
299
+ * Priority: local > personal remote > team.
300
+ * Team results get recall-driven ranking boost before merge.
301
+ */
302
+ export function mergeRemoteResults<T extends { id: string; score?: number }>(
303
+ localResults: T[],
304
+ remoteResults: T[],
305
+ limit: number
306
+ ): T[] {
307
+ const localIds = new Set(localResults.map(r => r.id));
308
+ const uniqueRemote = remoteResults.filter(r => !localIds.has(r.id));
309
+ const merged = [...localResults, ...uniqueRemote];
310
+ merged.sort((a, b) => ((b as any).score ?? 0) - ((a as any).score ?? 0));
311
+ return merged.slice(0, limit);
312
+ }
313
+
314
+ export function mergeWithTeamResults<T extends { id: string; score?: number; recall_count?: number; recall_members?: number }>(
315
+ localAndPersonal: T[],
316
+ teamResults: T[],
317
+ limit: number
318
+ ): T[] {
319
+ const existingIds = new Set(localAndPersonal.map(r => r.id));
320
+ const uniqueTeam = teamResults.filter(r => !existingIds.has(r.id));
321
+ const boostedTeam = applyTeamRecallBoost(uniqueTeam);
322
+ const merged = [...localAndPersonal, ...boostedTeam];
323
+ merged.sort((a, b) => ((b as any).score ?? 0) - ((a as any).score ?? 0));
324
+ return merged.slice(0, limit);
325
+ }
@@ -0,0 +1,139 @@
1
+ import type { LocalCtx } from '../types.js';
2
+
3
+ const RECALL_TARGET = 0.30;
4
+ const DEAD_DAYS = 30;
5
+ const TOP_COUNT = 10;
6
+
7
+ export interface RecallSummary {
8
+ ratio: number;
9
+ target: number;
10
+ total_entries: number;
11
+ recalled_entries: number;
12
+ never_recalled: number;
13
+ avg_recall_count: number;
14
+ top_recalled: Array<{ title: string; recall_count: number; recall_sessions: number }>;
15
+ dead_entry_count: number;
16
+ dead_bucket_count: number;
17
+ top_dead_buckets: Array<{ bucket: string; count: number }>;
18
+ co_retrieval_pairs: number;
19
+ }
20
+
21
+ export interface CoRetrievalSummary {
22
+ total_pairs: number;
23
+ graph_density: number;
24
+ top_pairs: Array<{
25
+ title_a: string;
26
+ title_b: string;
27
+ weight: number;
28
+ }>;
29
+ }
30
+
31
+ export function gatherRecallSummary(ctx: LocalCtx): RecallSummary {
32
+ const { db } = ctx;
33
+
34
+ const totalRow = db.prepare('SELECT COUNT(*) as c FROM vault').get() as { c: number };
35
+ const total_entries = totalRow.c;
36
+
37
+ const recalledRow = db
38
+ .prepare('SELECT COUNT(*) as c FROM vault WHERE recall_count > 0')
39
+ .get() as { c: number };
40
+ const recalled_entries = recalledRow.c;
41
+ const never_recalled = total_entries - recalled_entries;
42
+ const ratio = total_entries > 0 ? Math.round((recalled_entries / total_entries) * 100) / 100 : 0;
43
+
44
+ const avgRow = db
45
+ .prepare(
46
+ 'SELECT AVG(recall_count) as avg FROM vault WHERE recall_count > 0'
47
+ )
48
+ .get() as { avg: number | null };
49
+ const avg_recall_count = Math.round((avgRow.avg ?? 0) * 10) / 10;
50
+
51
+ const top_recalled = db
52
+ .prepare(
53
+ 'SELECT title, recall_count, recall_sessions FROM vault WHERE recall_count > 0 ORDER BY recall_count DESC LIMIT ?'
54
+ )
55
+ .all(TOP_COUNT) as Array<{ title: string; recall_count: number; recall_sessions: number }>;
56
+
57
+ const deadRow = db
58
+ .prepare(
59
+ `SELECT COUNT(*) as c FROM vault WHERE recall_count = 0 AND created_at <= datetime('now', '-${DEAD_DAYS} days')`
60
+ )
61
+ .get() as { c: number };
62
+ const dead_entry_count = deadRow.c;
63
+
64
+ const deadBuckets = db
65
+ .prepare(
66
+ `SELECT tags, COUNT(*) as c FROM vault WHERE recall_count = 0 AND created_at <= datetime('now', '-${DEAD_DAYS} days') GROUP BY tags`
67
+ )
68
+ .all() as Array<{ tags: string; c: number }>;
69
+
70
+ const bucketMap = new Map<string, number>();
71
+ for (const row of deadBuckets) {
72
+ let tags: string[] = [];
73
+ try {
74
+ tags = JSON.parse(row.tags ?? '[]');
75
+ } catch {
76
+ tags = [];
77
+ }
78
+ const bucket = tags.find((t) => t.startsWith('bucket:'));
79
+ if (bucket) {
80
+ bucketMap.set(bucket, (bucketMap.get(bucket) ?? 0) + row.c);
81
+ }
82
+ }
83
+
84
+ const top_dead_buckets = [...bucketMap.entries()]
85
+ .sort((a, b) => b[1] - a[1])
86
+ .slice(0, 5)
87
+ .map(([bucket, count]) => ({ bucket: bucket.replace('bucket:', ''), count }));
88
+
89
+ const dead_bucket_count = bucketMap.size;
90
+
91
+ const coRow = db.prepare('SELECT COUNT(*) as c FROM co_retrievals').get() as { c: number };
92
+ const co_retrieval_pairs = coRow.c;
93
+
94
+ return {
95
+ ratio,
96
+ target: RECALL_TARGET,
97
+ total_entries,
98
+ recalled_entries,
99
+ never_recalled,
100
+ avg_recall_count,
101
+ top_recalled,
102
+ dead_entry_count,
103
+ dead_bucket_count,
104
+ top_dead_buckets,
105
+ co_retrieval_pairs,
106
+ };
107
+ }
108
+
109
+ export function gatherCoRetrievalSummary(ctx: LocalCtx): CoRetrievalSummary {
110
+ const { db } = ctx;
111
+
112
+ const pairsRow = db.prepare('SELECT COUNT(*) as c FROM co_retrievals').get() as { c: number };
113
+ const total_pairs = pairsRow.c;
114
+
115
+ const entryRow = db.prepare('SELECT COUNT(*) as c FROM vault').get() as { c: number };
116
+ const total_entries = entryRow.c;
117
+
118
+ const max_possible = total_entries > 1 ? (total_entries * (total_entries - 1)) / 2 : 1;
119
+ const graph_density = Math.round((total_pairs / max_possible) * 10000) / 10000;
120
+
121
+ const rawPairs = db
122
+ .prepare(
123
+ `SELECT c.entry_a, c.entry_b, c.count as weight, a.title as title_a, b.title as title_b
124
+ FROM co_retrievals c
125
+ JOIN vault a ON a.id = c.entry_a
126
+ JOIN vault b ON b.id = c.entry_b
127
+ ORDER BY c.count DESC
128
+ LIMIT 10`
129
+ )
130
+ .all() as Array<{ title_a: string; title_b: string; weight: number }>;
131
+
132
+ const top_pairs = rawPairs.map((row) => ({
133
+ title_a: row.title_a ?? '(untitled)',
134
+ title_b: row.title_b ?? '(untitled)',
135
+ weight: row.weight,
136
+ }));
137
+
138
+ return { total_pairs, graph_density, top_pairs };
139
+ }
@@ -1,7 +1,9 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { gatherVaultStatus, computeGrowthWarnings } from '../status.js';
4
+ import { gatherRecallSummary } from '../stats/recall.js';
4
5
  import { errorLogPath, errorLogCount } from '../error-log.js';
6
+ import { getAutoMemory } from '../auto-memory.js';
5
7
  import { ok, err, kindIcon } from '../helpers.js';
6
8
  import type { LocalCtx, ToolResult } from '../types.js';
7
9
 
@@ -179,6 +181,24 @@ export function handler(_args: Record<string, any>, ctx: LocalCtx): ToolResult {
179
181
  }
180
182
  }
181
183
 
184
+ // Auto-memory detection
185
+ const autoMemory = getAutoMemory();
186
+ if (autoMemory.detected) {
187
+ const LINES_CAP = 200;
188
+ const overflowRisk = autoMemory.linesUsed > 160;
189
+ lines.push(``, `### Auto-Memory`);
190
+ lines.push(`| | |`);
191
+ lines.push(`|---|---|`);
192
+ lines.push(`| **Path** | ${autoMemory.path} |`);
193
+ lines.push(`| **Entries** | ${autoMemory.entries.length} |`);
194
+ lines.push(`| **Lines** | ${autoMemory.linesUsed}/${LINES_CAP} |`);
195
+ if (overflowRisk) {
196
+ lines.push(`| **Status** | ⚠ Approaching limit. Run \`/vault overflow\` to graduate entries. |`);
197
+ } else {
198
+ lines.push(`| **Status** | ✓ Healthy |`);
199
+ }
200
+ }
201
+
182
202
  if (status.stalePaths) {
183
203
  lines.push(``);
184
204
  lines.push(`### ⚠ Stale Paths`);
@@ -272,6 +292,26 @@ export function handler(_args: Record<string, any>, ctx: LocalCtx): ToolResult {
272
292
  lines.push('', '### Suggested Actions', ...actions);
273
293
  }
274
294
 
295
+ // Recall ratio structured summary
296
+ let recallSection: Record<string, unknown> | null = null;
297
+ try {
298
+ const rs = gatherRecallSummary(ctx);
299
+ recallSection = {
300
+ ratio: rs.ratio,
301
+ target: rs.target,
302
+ total_entries: rs.total_entries,
303
+ recalled_entries: rs.recalled_entries,
304
+ avg_recall_count: rs.avg_recall_count,
305
+ co_retrieval_pairs: rs.co_retrieval_pairs,
306
+ };
307
+ lines.push('', '### Recall Ratio');
308
+ lines.push('```json');
309
+ lines.push(JSON.stringify({ recall: recallSection }, null, 2));
310
+ lines.push('```');
311
+ } catch {
312
+ // non-fatal: skip recall section if unavailable
313
+ }
314
+
275
315
  return ok(lines.join('\n'));
276
316
  } catch (e) {
277
317
  return err(e instanceof Error ? e.message : String(e), 'STATUS_FAILED');
@@ -10,6 +10,7 @@ import { resolveTemporalParams } from '../temporal.js';
10
10
  import { collectLinkedEntries } from '../linking.js';
11
11
  import { ok, err, errWithHint, kindIcon, fmtDate } from '../helpers.js';
12
12
  import { isEmbedAvailable } from '@context-vault/core/embed';
13
+ import { getRemoteClient, getTeamId, mergeRemoteResults, mergeWithTeamResults } from '../remote.js';
13
14
  import type { LocalCtx, SharedCtx, ToolResult } from '../types.js';
14
15
 
15
16
  const STALE_DUPLICATE_DAYS = 7;
@@ -575,6 +576,49 @@ export async function handler(
575
576
  trackAccess(ctx, filtered);
576
577
  }
577
578
 
579
+ // Remote merge: query hosted API and merge results (local wins for duplicates)
580
+ const remoteClient = getRemoteClient(ctx.config);
581
+ if (remoteClient && (hasQuery || hasFilters)) {
582
+ try {
583
+ const remoteResults = await remoteClient.search({
584
+ query: hasQuery ? query : undefined,
585
+ tags: effectiveTags.length ? effectiveTags : undefined,
586
+ kind: kindFilter || undefined,
587
+ category: scopedCategory || undefined,
588
+ scope: effectiveScope !== 'hot' ? effectiveScope : undefined,
589
+ limit: effectiveLimit,
590
+ since: effectiveSince || undefined,
591
+ until: effectiveUntil || undefined,
592
+ });
593
+ if (remoteResults.length > 0) {
594
+ filtered = mergeRemoteResults(filtered, remoteResults as any[], effectiveLimit);
595
+ }
596
+ } catch (e) {
597
+ console.warn(`[context-vault] Remote search failed: ${(e as Error).message}`);
598
+ }
599
+ }
600
+
601
+ // Team vault merge: query team vault if teamId is configured
602
+ const teamId = getTeamId(ctx.config);
603
+ if (remoteClient && teamId && (hasQuery || hasFilters)) {
604
+ try {
605
+ const teamResults = await remoteClient.teamSearch(teamId, {
606
+ query: hasQuery ? query : undefined,
607
+ tags: effectiveTags.length ? effectiveTags : undefined,
608
+ kind: kindFilter || undefined,
609
+ category: scopedCategory || undefined,
610
+ limit: effectiveLimit,
611
+ since: effectiveSince || undefined,
612
+ until: effectiveUntil || undefined,
613
+ });
614
+ if (teamResults.length > 0) {
615
+ filtered = mergeWithTeamResults(filtered, teamResults as any[], effectiveLimit);
616
+ }
617
+ } catch (e) {
618
+ console.warn(`[context-vault] Team search failed: ${(e as Error).message}`);
619
+ }
620
+ }
621
+
578
622
  // Brief score boost: briefs rank slightly higher so consolidated snapshots
579
623
  // surface above the individual entries they summarize.
580
624
  for (const r of filtered) {
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ vi.mock('../remote.js', () => ({
4
+ getRemoteClient: vi.fn(),
5
+ getTeamId: vi.fn(),
6
+ }));
7
+
8
+ vi.mock('../helpers.js', async () => {
9
+ const actual = await vi.importActual('../helpers.js');
10
+ return actual;
11
+ });
12
+
13
+ import { handler } from './publish-to-team.js';
14
+ import { getRemoteClient, getTeamId } from '../remote.js';
15
+
16
+ function makeCtx(entry?: Record<string, any>) {
17
+ return {
18
+ config: { remote: { enabled: true, url: 'http://test', apiKey: 'k', teamId: 'team-1' } },
19
+ stmts: {
20
+ getEntryById: {
21
+ get: vi.fn().mockReturnValue(entry ?? {
22
+ id: 'entry-1',
23
+ kind: 'insight',
24
+ category: 'knowledge',
25
+ title: 'Test Entry',
26
+ body: 'Some body',
27
+ tags: '["tag1"]',
28
+ meta: '{}',
29
+ source: null,
30
+ identity_key: null,
31
+ tier: 'working',
32
+ }),
33
+ },
34
+ },
35
+ } as any;
36
+ }
37
+
38
+ describe('publish_to_team handler', () => {
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ (getTeamId as any).mockReturnValue('team-1');
42
+ });
43
+
44
+ it('returns formatted advisory on 422 privacy scan failure', async () => {
45
+ const mockClient = {
46
+ publishToTeam: vi.fn().mockResolvedValue({
47
+ ok: false,
48
+ status: 422,
49
+ error: 'Entry contains potentially sensitive content',
50
+ privacyMatches: [
51
+ { type: 'email', value: 'fe***@klarhimmel.se', field: 'body', line: 3 },
52
+ { type: 'api_key', value: 'sk-proj-...abc', field: 'body', line: 7 },
53
+ ],
54
+ }),
55
+ };
56
+ (getRemoteClient as any).mockReturnValue(mockClient);
57
+
58
+ const result = await handler({ entry_id: 'entry-1' }, makeCtx(), {} as any);
59
+
60
+ expect(result.isError).toBeUndefined();
61
+ const text = result.content[0].text;
62
+ expect(text).toContain('sensitive content');
63
+ expect(text).toContain('email in body (line 3)');
64
+ expect(text).toContain('api_key in body (line 7)');
65
+ expect(text).toContain('force: true');
66
+ });
67
+
68
+ it('passes force: true through to the remote client', async () => {
69
+ const mockClient = {
70
+ publishToTeam: vi.fn().mockResolvedValue({
71
+ ok: true,
72
+ id: 'team-entry-1',
73
+ }),
74
+ };
75
+ (getRemoteClient as any).mockReturnValue(mockClient);
76
+
77
+ await handler({ entry_id: 'entry-1', force: true }, makeCtx(), {} as any);
78
+
79
+ expect(mockClient.publishToTeam).toHaveBeenCalledWith(
80
+ expect.objectContaining({ force: true })
81
+ );
82
+ });
83
+
84
+ it('passes force: false when not set', async () => {
85
+ const mockClient = {
86
+ publishToTeam: vi.fn().mockResolvedValue({
87
+ ok: true,
88
+ id: 'team-entry-1',
89
+ }),
90
+ };
91
+ (getRemoteClient as any).mockReturnValue(mockClient);
92
+
93
+ await handler({ entry_id: 'entry-1' }, makeCtx(), {} as any);
94
+
95
+ expect(mockClient.publishToTeam).toHaveBeenCalledWith(
96
+ expect.objectContaining({ force: false })
97
+ );
98
+ });
99
+
100
+ it('returns error for non-privacy 422 failures', async () => {
101
+ const mockClient = {
102
+ publishToTeam: vi.fn().mockResolvedValue({
103
+ ok: false,
104
+ status: 422,
105
+ error: 'Some other validation error',
106
+ }),
107
+ };
108
+ (getRemoteClient as any).mockReturnValue(mockClient);
109
+
110
+ const result = await handler({ entry_id: 'entry-1' }, makeCtx(), {} as any);
111
+
112
+ expect(result.isError).toBe(true);
113
+ expect(result.content[0].text).toContain('Failed to publish');
114
+ });
115
+ });