context-vault 3.8.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.
- package/assets/agent-rules.md +28 -1
- package/assets/setup-prompt.md +16 -1
- package/bin/cli.js +876 -4
- package/dist/auto-memory.d.ts +52 -0
- package/dist/auto-memory.d.ts.map +1 -0
- package/dist/auto-memory.js +142 -0
- package/dist/auto-memory.js.map +1 -0
- package/dist/register-tools.d.ts.map +1 -1
- package/dist/register-tools.js +2 -0
- package/dist/register-tools.js.map +1 -1
- package/dist/remote.d.ts +134 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +242 -0
- package/dist/remote.js.map +1 -0
- package/dist/remote.test.d.ts +2 -0
- package/dist/remote.test.d.ts.map +1 -0
- package/dist/remote.test.js +107 -0
- package/dist/remote.test.js.map +1 -0
- package/dist/tools/context-status.d.ts.map +1 -1
- package/dist/tools/context-status.js +19 -0
- package/dist/tools/context-status.js.map +1 -1
- package/dist/tools/get-context.d.ts.map +1 -1
- package/dist/tools/get-context.js +44 -0
- package/dist/tools/get-context.js.map +1 -1
- package/dist/tools/publish-to-team.d.ts +11 -0
- package/dist/tools/publish-to-team.d.ts.map +1 -0
- package/dist/tools/publish-to-team.js +91 -0
- package/dist/tools/publish-to-team.js.map +1 -0
- package/dist/tools/publish-to-team.test.d.ts +2 -0
- package/dist/tools/publish-to-team.test.d.ts.map +1 -0
- package/dist/tools/publish-to-team.test.js +95 -0
- package/dist/tools/publish-to-team.test.js.map +1 -0
- package/dist/tools/recall.d.ts +1 -1
- package/dist/tools/recall.d.ts.map +1 -1
- package/dist/tools/recall.js +85 -1
- package/dist/tools/recall.js.map +1 -1
- package/dist/tools/save-context.d.ts +5 -1
- package/dist/tools/save-context.d.ts.map +1 -1
- package/dist/tools/save-context.js +163 -2
- package/dist/tools/save-context.js.map +1 -1
- package/dist/tools/session-start.d.ts.map +1 -1
- package/dist/tools/session-start.js +90 -86
- package/dist/tools/session-start.js.map +1 -1
- package/node_modules/@context-vault/core/dist/config.d.ts +3 -1
- package/node_modules/@context-vault/core/dist/config.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/config.js +48 -2
- package/node_modules/@context-vault/core/dist/config.js.map +1 -1
- package/node_modules/@context-vault/core/dist/main.d.ts +1 -1
- package/node_modules/@context-vault/core/dist/main.d.ts.map +1 -1
- package/node_modules/@context-vault/core/dist/main.js.map +1 -1
- package/node_modules/@context-vault/core/dist/types.d.ts +7 -0
- package/node_modules/@context-vault/core/dist/types.d.ts.map +1 -1
- package/node_modules/@context-vault/core/package.json +1 -1
- package/node_modules/@context-vault/core/src/config.ts +50 -3
- package/node_modules/@context-vault/core/src/main.ts +1 -0
- package/node_modules/@context-vault/core/src/types.ts +8 -0
- package/package.json +2 -2
- package/src/auto-memory.ts +169 -0
- package/src/register-tools.ts +2 -0
- package/src/remote.test.ts +123 -0
- package/src/remote.ts +325 -0
- package/src/tools/context-status.ts +19 -0
- package/src/tools/get-context.ts +44 -0
- package/src/tools/publish-to-team.test.ts +115 -0
- package/src/tools/publish-to-team.ts +112 -0
- package/src/tools/recall.ts +79 -1
- package/src/tools/save-context.ts +167 -1
- 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
|
+
}
|
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { gatherVaultStatus, computeGrowthWarnings } from '../status.js';
|
|
4
4
|
import { gatherRecallSummary } from '../stats/recall.js';
|
|
5
5
|
import { errorLogPath, errorLogCount } from '../error-log.js';
|
|
6
|
+
import { getAutoMemory } from '../auto-memory.js';
|
|
6
7
|
import { ok, err, kindIcon } from '../helpers.js';
|
|
7
8
|
import type { LocalCtx, ToolResult } from '../types.js';
|
|
8
9
|
|
|
@@ -180,6 +181,24 @@ export function handler(_args: Record<string, any>, ctx: LocalCtx): ToolResult {
|
|
|
180
181
|
}
|
|
181
182
|
}
|
|
182
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
|
+
|
|
183
202
|
if (status.stalePaths) {
|
|
184
203
|
lines.push(``);
|
|
185
204
|
lines.push(`### ⚠ Stale Paths`);
|
package/src/tools/get-context.ts
CHANGED
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ok, err } from '../helpers.js';
|
|
3
|
+
import { getRemoteClient, getTeamId } from '../remote.js';
|
|
4
|
+
import type { LocalCtx, SharedCtx, ToolResult } from '../types.js';
|
|
5
|
+
|
|
6
|
+
export const name = 'publish_to_team';
|
|
7
|
+
|
|
8
|
+
export const description =
|
|
9
|
+
'Publish a local vault entry to a team vault. Wraps the POST /api/vault/publish endpoint. Returns the published entry and any conflict advisory from the server. If the server detects sensitive content, returns an advisory with matched patterns. Use force: true to override.';
|
|
10
|
+
|
|
11
|
+
export const inputSchema = {
|
|
12
|
+
entry_id: z
|
|
13
|
+
.string()
|
|
14
|
+
.describe('The ULID of the local entry to publish to the team vault.'),
|
|
15
|
+
team_id: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe('Team ID to publish to. Defaults to the teamId in remote config.'),
|
|
19
|
+
force: z
|
|
20
|
+
.boolean()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe('When true, skip the server-side privacy scan and publish anyway. Use when you have reviewed the content and confirmed it is safe to share.'),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function handler(
|
|
26
|
+
{ entry_id, team_id, force }: Record<string, any>,
|
|
27
|
+
ctx: LocalCtx,
|
|
28
|
+
_shared: SharedCtx
|
|
29
|
+
): Promise<ToolResult> {
|
|
30
|
+
const remoteClient = getRemoteClient(ctx.config);
|
|
31
|
+
if (!remoteClient) {
|
|
32
|
+
return err('Remote is not configured. Run `context-vault remote setup` first.', 'NOT_CONFIGURED');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const effectiveTeamId = team_id || getTeamId(ctx.config);
|
|
36
|
+
if (!effectiveTeamId) {
|
|
37
|
+
return err(
|
|
38
|
+
'No team ID specified and none configured. Use `context-vault team join <team-id>` or pass team_id parameter.',
|
|
39
|
+
'NO_TEAM'
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const existing = ctx.stmts.getEntryById.get(entry_id) as Record<string, any> | undefined;
|
|
44
|
+
if (!existing) {
|
|
45
|
+
return err(`Entry not found locally: ${entry_id}`, 'NOT_FOUND');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (existing.category === 'event') {
|
|
49
|
+
return err('Event entries cannot be published to team vaults (private by design).', 'FORBIDDEN');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const tags = existing.tags ? JSON.parse(existing.tags) : [];
|
|
53
|
+
const meta = existing.meta ? JSON.parse(existing.meta) : {};
|
|
54
|
+
|
|
55
|
+
const result = await remoteClient.publishToTeam({
|
|
56
|
+
entryId: entry_id,
|
|
57
|
+
teamId: effectiveTeamId,
|
|
58
|
+
visibility: 'team',
|
|
59
|
+
force: !!force,
|
|
60
|
+
entry: {
|
|
61
|
+
kind: existing.kind,
|
|
62
|
+
title: existing.title,
|
|
63
|
+
body: existing.body,
|
|
64
|
+
tags,
|
|
65
|
+
meta,
|
|
66
|
+
source: existing.source,
|
|
67
|
+
identity_key: existing.identity_key,
|
|
68
|
+
tier: existing.tier,
|
|
69
|
+
category: existing.category,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (!result.ok) {
|
|
74
|
+
if (result.status === 422 && result.privacyMatches?.length) {
|
|
75
|
+
return formatPrivacyAdvisory(result.privacyMatches);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const parts = [`Failed to publish: ${result.error}`];
|
|
79
|
+
if (result.conflict) {
|
|
80
|
+
parts.push('');
|
|
81
|
+
parts.push(`Conflict detected: similar entry by ${result.conflict.existing_author} (${(result.conflict.similarity * 100).toFixed(0)}% match)`);
|
|
82
|
+
parts.push(` Entry ID: ${result.conflict.existing_entry_id}`);
|
|
83
|
+
parts.push(` ${result.conflict.suggestion}`);
|
|
84
|
+
}
|
|
85
|
+
return err(parts.join('\n'), 'PUBLISH_FAILED');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const parts = [
|
|
89
|
+
`## Published to team`,
|
|
90
|
+
`Entry \`${entry_id}\` published to team \`${effectiveTeamId}\``,
|
|
91
|
+
];
|
|
92
|
+
if (result.id) {
|
|
93
|
+
parts.push(`Team entry ID: \`${result.id}\``);
|
|
94
|
+
}
|
|
95
|
+
if (result.conflict) {
|
|
96
|
+
parts.push('');
|
|
97
|
+
parts.push(`**Conflict advisory:** Similar entry by ${result.conflict.existing_author} (${(result.conflict.similarity * 100).toFixed(0)}% match)`);
|
|
98
|
+
parts.push(` Entry: \`${result.conflict.existing_entry_id}\``);
|
|
99
|
+
parts.push(` ${result.conflict.suggestion}`);
|
|
100
|
+
}
|
|
101
|
+
return ok(parts.join('\n'));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatPrivacyAdvisory(matches: Array<{ type: string; value: string; field: string; line: number }>): ToolResult {
|
|
105
|
+
const lines = ['This entry contains sensitive content that should be removed before sharing:'];
|
|
106
|
+
for (const m of matches) {
|
|
107
|
+
lines.push(`- ${m.type} in ${m.field} (line ${m.line}): ${m.value}`);
|
|
108
|
+
}
|
|
109
|
+
lines.push('');
|
|
110
|
+
lines.push('Remove these and try again, or set force: true to override.');
|
|
111
|
+
return ok(lines.join('\n'));
|
|
112
|
+
}
|