context-vault 3.9.0 → 3.10.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/src/remote.ts CHANGED
@@ -45,6 +45,12 @@ export interface TeamSearchResult extends RemoteSearchResult {
45
45
  recall_members?: number;
46
46
  }
47
47
 
48
+ export interface PublicSearchResult extends RemoteSearchResult {
49
+ source: 'public';
50
+ vault_slug: string;
51
+ recall_count: number;
52
+ }
53
+
48
54
  export interface PrivacyScanMatch {
49
55
  type: string;
50
56
  value: string;
@@ -242,6 +248,103 @@ export class RemoteClient {
242
248
  }
243
249
  }
244
250
 
251
+ async publicSearch(slug: string, params: Record<string, unknown>): Promise<PublicSearchResult[]> {
252
+ try {
253
+ const query = new URLSearchParams();
254
+ if (params.query) query.set('q', String(params.query));
255
+ if (Array.isArray(params.tags) && params.tags.length) query.set('tags', params.tags.join(','));
256
+ if (params.kind) query.set('kind', String(params.kind));
257
+ if (params.category) query.set('category', String(params.category));
258
+ if (params.limit) query.set('limit', String(params.limit));
259
+ if (params.since) query.set('since', String(params.since));
260
+ if (params.until) query.set('until', String(params.until));
261
+
262
+ const res = await this.fetch(`/api/public/${slug}/search?${query.toString()}`);
263
+ if (!res.ok) return [];
264
+ const data = await res.json() as Record<string, unknown>;
265
+ const entries = Array.isArray(data.entries) ? data.entries : Array.isArray(data) ? data : [];
266
+ return (entries as PublicSearchResult[]).map(e => ({ ...e, source: 'public' as const, vault_slug: slug }));
267
+ } catch {
268
+ return [];
269
+ }
270
+ }
271
+
272
+ async publicRecall(slug: string, params: {
273
+ signal: string;
274
+ signal_type: string;
275
+ bucket?: string;
276
+ max_hints?: number;
277
+ }): Promise<RemoteHint[]> {
278
+ try {
279
+ const res = await this.fetch(`/api/public/${slug}/search`, {
280
+ method: 'POST',
281
+ body: JSON.stringify(params),
282
+ });
283
+ if (!res.ok) return [];
284
+ const data = await res.json() as Record<string, unknown>;
285
+ return Array.isArray(data.hints) ? data.hints : Array.isArray(data) ? data as RemoteHint[] : [];
286
+ } catch {
287
+ return [];
288
+ }
289
+ }
290
+
291
+ async publicList(params?: { domain?: string; sort?: string }): Promise<Record<string, unknown>[]> {
292
+ try {
293
+ const query = new URLSearchParams();
294
+ if (params?.domain) query.set('domain', params.domain);
295
+ if (params?.sort) query.set('sort', params.sort);
296
+
297
+ const res = await this.fetch(`/api/public/vaults?${query.toString()}`);
298
+ if (!res.ok) return [];
299
+ const data = await res.json() as Record<string, unknown>;
300
+ return Array.isArray(data.vaults) ? data.vaults as Record<string, unknown>[] : Array.isArray(data) ? data as Record<string, unknown>[] : [];
301
+ } catch {
302
+ return [];
303
+ }
304
+ }
305
+
306
+ async publicCreate(params: {
307
+ name: string;
308
+ slug: string;
309
+ description?: string;
310
+ domain_tags?: string[];
311
+ visibility?: string;
312
+ }): Promise<{ ok: boolean; slug?: string; error?: string }> {
313
+ try {
314
+ const res = await this.fetch('/api/public/vaults', {
315
+ method: 'POST',
316
+ body: JSON.stringify(params),
317
+ });
318
+ const data = await res.json().catch(() => ({})) as Record<string, unknown>;
319
+ if (res.ok) {
320
+ return { ok: true, slug: (data.slug as string) || params.slug };
321
+ }
322
+ return { ok: false, error: typeof data.error === 'string' ? data.error : `HTTP ${res.status}` };
323
+ } catch (e) {
324
+ return { ok: false, error: (e as Error).message };
325
+ }
326
+ }
327
+
328
+ async publicSeed(slug: string, entry: Record<string, unknown>): Promise<PublishResult> {
329
+ try {
330
+ const res = await this.fetch(`/api/public/${slug}/entries`, {
331
+ method: 'POST',
332
+ body: JSON.stringify(entry),
333
+ });
334
+ const data = await res.json().catch(() => ({})) as Record<string, unknown>;
335
+ if (res.ok) {
336
+ return { ok: true, id: data.id as string | undefined };
337
+ }
338
+ if (res.status === 422 && data.code === 'PRIVACY_SCAN_FAILED') {
339
+ const matches = Array.isArray(data.matches) ? data.matches as PrivacyScanMatch[] : [];
340
+ return { ok: false, error: 'Privacy scan failed', status: 422, privacyMatches: matches };
341
+ }
342
+ return { ok: false, error: typeof data.error === 'string' ? data.error : `HTTP ${res.status}`, status: res.status };
343
+ } catch (e) {
344
+ return { ok: false, error: (e as Error).message };
345
+ }
346
+ }
347
+
245
348
  private fetch(path: string, opts: RequestInit = {}): Promise<Response> {
246
349
  const controller = new AbortController();
247
350
  const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
@@ -323,3 +426,45 @@ export function mergeWithTeamResults<T extends { id: string; score?: number; rec
323
426
  merged.sort((a, b) => ((b as any).score ?? 0) - ((a as any).score ?? 0));
324
427
  return merged.slice(0, limit);
325
428
  }
429
+
430
+ /**
431
+ * Apply recall-driven ranking boost to public vault results.
432
+ * Uses freshness decay: entries not updated in 90+ days get 0.8 multiplier.
433
+ * Formula: score * log(1 + recall_count) * freshness_decay
434
+ */
435
+ export function applyPublicRecallBoost<T extends { score?: number; recall_count?: number; updated_at?: string | null }>(
436
+ entries: T[]
437
+ ): T[] {
438
+ const now = Date.now();
439
+ const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;
440
+ return entries.map(e => {
441
+ const baseScore = (e as any).score ?? 0;
442
+ const recallCount = (e as any).recall_count ?? 0;
443
+ if (recallCount === 0) return e;
444
+ const updatedAt = (e as any).updated_at ? new Date((e as any).updated_at).getTime() : now;
445
+ const freshnessDecay = (now - updatedAt > NINETY_DAYS_MS) ? 0.8 : 1.0;
446
+ const boostedScore = baseScore * Math.log(1 + recallCount) * freshnessDecay;
447
+ return { ...e, score: boostedScore };
448
+ });
449
+ }
450
+
451
+ /**
452
+ * Merge existing results with public vault results.
453
+ * Public results get recall-driven ranking boost with freshness decay before merge.
454
+ */
455
+ export function mergeWithPublicResults<T extends { id: string; score?: number; recall_count?: number }>(
456
+ existing: T[],
457
+ publicResults: T[],
458
+ limit: number
459
+ ): T[] {
460
+ const existingIds = new Set(existing.map(r => r.id));
461
+ const uniquePublic = publicResults.filter(r => !existingIds.has(r.id));
462
+ const boostedPublic = applyPublicRecallBoost(uniquePublic);
463
+ const merged = [...existing, ...boostedPublic];
464
+ merged.sort((a, b) => ((b as any).score ?? 0) - ((a as any).score ?? 0));
465
+ return merged.slice(0, limit);
466
+ }
467
+
468
+ export function getPublicVaults(config: { remote?: RemoteConfig & { publicVaults?: string[] } }): string[] {
469
+ return (config.remote as any)?.publicVaults || [];
470
+ }
@@ -10,7 +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
+ import { getRemoteClient, getTeamId, getPublicVaults, mergeRemoteResults, mergeWithTeamResults, mergeWithPublicResults } from '../remote.js';
14
14
  import type { LocalCtx, SharedCtx, ToolResult } from '../types.js';
15
15
 
16
16
  const STALE_DUPLICATE_DAYS = 7;
@@ -619,6 +619,34 @@ export async function handler(
619
619
  }
620
620
  }
621
621
 
622
+ // Public vault merge: query each configured public vault
623
+ const publicVaultSlugs = getPublicVaults(ctx.config);
624
+ if (remoteClient && publicVaultSlugs.length > 0 && (hasQuery || hasFilters)) {
625
+ const publicSearches = publicVaultSlugs.map(slug =>
626
+ remoteClient.publicSearch(slug, {
627
+ query: hasQuery ? query : undefined,
628
+ tags: effectiveTags.length ? effectiveTags : undefined,
629
+ kind: kindFilter || undefined,
630
+ category: scopedCategory || undefined,
631
+ limit: effectiveLimit,
632
+ since: effectiveSince || undefined,
633
+ until: effectiveUntil || undefined,
634
+ }).catch(e => {
635
+ console.warn(`[context-vault] Public vault "${slug}" search failed: ${(e as Error).message}`);
636
+ return [];
637
+ })
638
+ );
639
+ try {
640
+ const allPublicResults = await Promise.all(publicSearches);
641
+ const combined = allPublicResults.flat();
642
+ if (combined.length > 0) {
643
+ filtered = mergeWithPublicResults(filtered, combined as any[], effectiveLimit);
644
+ }
645
+ } catch (e) {
646
+ console.warn(`[context-vault] Public vault search failed: ${(e as Error).message}`);
647
+ }
648
+ }
649
+
622
650
  // Brief score boost: briefs rank slightly higher so consolidated snapshots
623
651
  // surface above the individual entries they summarize.
624
652
  for (const r of filtered) {
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { ok } from '../helpers.js';
3
3
  import { isEmbedAvailable } from '@context-vault/core/embed';
4
4
  import { getAutoMemory, findAutoMemoryOverlaps } from '../auto-memory.js';
5
- import { getRemoteClient, getTeamId } from '../remote.js';
5
+ import { getRemoteClient, getTeamId, getPublicVaults } from '../remote.js';
6
6
  import type { LocalCtx, SharedCtx, ToolResult } from '../types.js';
7
7
 
8
8
  const SEMANTIC_SIMILARITY_THRESHOLD = 0.6;
@@ -227,6 +227,40 @@ export async function handler(
227
227
  }
228
228
  }
229
229
 
230
+ // Public vault recall: query each configured public vault
231
+ const publicVaultSlugs = getPublicVaults(ctx.config);
232
+ if (remoteClient && publicVaultSlugs.length > 0 && hints.length < limit) {
233
+ const publicRecalls = publicVaultSlugs.map(slug =>
234
+ remoteClient.publicRecall(slug, {
235
+ signal,
236
+ signal_type,
237
+ bucket,
238
+ max_hints: limit - hints.length,
239
+ }).catch(e => {
240
+ console.warn(`[context-vault] Public vault "${slug}" recall failed: ${(e as Error).message}`);
241
+ return [];
242
+ })
243
+ );
244
+ try {
245
+ const allPublicHints = await Promise.all(publicRecalls);
246
+ const existingIds = new Set(hints.map(h => h.id));
247
+ for (const publicHints of allPublicHints) {
248
+ for (const ph of publicHints) {
249
+ if (hints.length >= limit) break;
250
+ if (existingIds.has(ph.id)) continue;
251
+ if (sessionSet && !bypassDedup && sessionSet.has(ph.id)) {
252
+ suppressed++;
253
+ continue;
254
+ }
255
+ hints.push({ ...ph, tags: [...(ph.tags || []), '[public]'] });
256
+ if (sessionSet) sessionSet.add(ph.id);
257
+ }
258
+ }
259
+ } catch (e) {
260
+ console.warn(`[context-vault] Public vault recall failed: ${(e as Error).message}`);
261
+ }
262
+ }
263
+
230
264
  let method: 'tag_match' | 'semantic' | 'none' = hints.length > 0 ? 'tag_match' : 'none';
231
265
 
232
266
  // Semantic fallback: when fast-path returns 0 results and signal is not file-based
@@ -5,7 +5,7 @@ import { join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { ok, err, ensureVaultExists, kindIcon, fmtDate } from '../helpers.js';
7
7
  import { getAutoMemory } from '../auto-memory.js';
8
- import { getRemoteClient, getTeamId } from '../remote.js';
8
+ import { getRemoteClient, getTeamId, getPublicVaults } from '../remote.js';
9
9
  import type { AutoMemoryEntry, AutoMemoryResult } from '../auto-memory.js';
10
10
  import type { LocalCtx, SharedCtx, ToolResult } from '../types.js';
11
11
 
@@ -314,6 +314,50 @@ export async function handler(
314
314
  }
315
315
  }
316
316
 
317
+ // Public vault entries: include public knowledge if publicVaults are configured
318
+ let publicCount = 0;
319
+ const publicVaultSlugs = getPublicVaults(ctx.config);
320
+ if (remoteClient && publicVaultSlugs.length > 0 && tokensUsed < tokenBudget) {
321
+ try {
322
+ const allPublicSeenIds = new Set([
323
+ ...decisions.map((d: any) => d.id),
324
+ ...deduped.map((d: any) => d.id),
325
+ ...(lastSession ? [lastSession.id] : []),
326
+ ]);
327
+ const publicSearches = publicVaultSlugs.map(slug =>
328
+ remoteClient.publicSearch(slug, {
329
+ tags: effectiveTags.length ? effectiveTags : undefined,
330
+ limit: 5,
331
+ since: sinceDate,
332
+ }).catch(() => [])
333
+ );
334
+ const allPublicResults = await Promise.all(publicSearches);
335
+ const flatPublic = allPublicResults.flat().filter((r: any) => !allPublicSeenIds.has(r.id));
336
+ if (flatPublic.length > 0) {
337
+ const header = '## Public Knowledge\n';
338
+ const headerTokens = estimateTokens(header);
339
+ if (tokensUsed + headerTokens <= tokenBudget) {
340
+ const entryLines: string[] = [];
341
+ tokensUsed += headerTokens;
342
+ for (const entry of flatPublic) {
343
+ const slug = (entry as any).vault_slug || 'public';
344
+ const line = formatEntry(entry) + ` \`[public:${slug}]\``;
345
+ const lineTokens = estimateTokens(line);
346
+ if (tokensUsed + lineTokens > tokenBudget) break;
347
+ entryLines.push(line);
348
+ tokensUsed += lineTokens;
349
+ publicCount++;
350
+ }
351
+ if (entryLines.length > 0) {
352
+ sections.push(header + entryLines.join('\n') + '\n');
353
+ }
354
+ }
355
+ }
356
+ } catch (e) {
357
+ console.warn(`[context-vault] Public vault session_start failed: ${(e as Error).message}`);
358
+ }
359
+ }
360
+
317
361
  const totalEntries =
318
362
  (lastSession ? 1 : 0) +
319
363
  decisions.length +
@@ -321,7 +365,8 @@ export async function handler(
321
365
  return true;
322
366
  }).length +
323
367
  remoteCount +
324
- teamCount;
368
+ teamCount +
369
+ publicCount;
325
370
 
326
371
  if (indexWarning) {
327
372
  sections.push(indexWarning);