@usewhisper/mcp-server 2.8.0 → 2.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.
Files changed (2) hide show
  1. package/dist/server.js +908 -482
  2. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -6,7 +6,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6
6
  import { z } from "zod";
7
7
  import { execSync, spawnSync } from "child_process";
8
8
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, appendFileSync } from "fs";
9
- import { join, relative, extname } from "path";
9
+ import { join, relative, extname, normalize as normalizePath, resolve as resolvePath } from "path";
10
10
  import { homedir } from "os";
11
11
  import { createHash, randomUUID } from "crypto";
12
12
 
@@ -2089,9 +2089,33 @@ ${lines.join("\n")}`;
2089
2089
  }
2090
2090
  });
2091
2091
  }
2092
+ async resolveLearningScope(overrides) {
2093
+ const merged = {
2094
+ ...this.baseContext,
2095
+ ...overrides
2096
+ };
2097
+ const { scope } = await this.resolveScope(overrides);
2098
+ return {
2099
+ project: scope.project,
2100
+ sessionId: merged.sessionId || scope.sessionId,
2101
+ userId: merged.userId
2102
+ };
2103
+ }
2092
2104
  async afterTurn(input, context = {}) {
2093
2105
  this.pushTouchedFiles(input.touchedFiles);
2094
- const { scope } = await this.resolveScope(context);
2106
+ if (input.auto_learn === false) {
2107
+ return {
2108
+ success: true,
2109
+ sessionIngested: false,
2110
+ memoriesCreated: 0,
2111
+ relationsCreated: 0,
2112
+ invalidatedCount: 0,
2113
+ mergedCount: 0,
2114
+ droppedCount: 0,
2115
+ warnings: []
2116
+ };
2117
+ }
2118
+ const scope = await this.resolveLearningScope(context);
2095
2119
  const result = await this.args.adapter.ingestSession({
2096
2120
  project: scope.project,
2097
2121
  session_id: scope.sessionId,
@@ -2474,6 +2498,19 @@ var WhisperClient = class _WhisperClient {
2474
2498
  });
2475
2499
  return response.data;
2476
2500
  }
2501
+ async learn(params) {
2502
+ const project = (await this.resolveProject(params.project)).id;
2503
+ const response = await this.runtimeClient.request({
2504
+ endpoint: "/v1/learn",
2505
+ method: "POST",
2506
+ operation: params.mode === "conversation" ? "session" : "bulk",
2507
+ body: {
2508
+ ...params,
2509
+ project
2510
+ }
2511
+ });
2512
+ return response.data;
2513
+ }
2477
2514
  createAgentRuntime(options = {}) {
2478
2515
  const baseContext = {
2479
2516
  workspacePath: options.workspacePath,
@@ -2522,6 +2559,14 @@ var WhisperClient = class _WhisperClient {
2522
2559
  sessionId: params.sessionId || context.sessionId || ""
2523
2560
  })
2524
2561
  },
2562
+ learn: (params) => base.learn({
2563
+ ...params,
2564
+ project: params.project || context.project || base.config.project,
2565
+ ...params.mode === "conversation" ? {
2566
+ user_id: params.user_id ?? context.userId,
2567
+ session_id: params.session_id || context.sessionId || ""
2568
+ } : {}
2569
+ }),
2525
2570
  queue: base.queue,
2526
2571
  diagnostics: base.diagnostics
2527
2572
  };
@@ -2983,91 +3028,125 @@ var WhisperContext = class _WhisperContext {
2983
3028
  return this.request(`/v1/sources/${sourceId}/sync`, { method: "POST" });
2984
3029
  }
2985
3030
  async addSourceByType(projectId, params) {
2986
- return this.withProjectPathFallback(
2987
- this.getRequiredProject(projectId),
2988
- (projectPathRef) => this.request(`/v1/projects/${encodeURIComponent(projectPathRef)}/add_source`, {
2989
- method: "POST",
2990
- body: JSON.stringify(params)
2991
- })
2992
- );
3031
+ const result = await this.learn({
3032
+ mode: "source",
3033
+ project: projectId,
3034
+ type: "video",
3035
+ name: params.name,
3036
+ url: params.url,
3037
+ platform: params.platform,
3038
+ language: params.language,
3039
+ options: {
3040
+ async: true,
3041
+ auto_index: params.auto_sync ?? true,
3042
+ ingestion_profile: params.ingestion_profile,
3043
+ strategy_override: params.strategy_override,
3044
+ profile_config: params.profile_config,
3045
+ allow_stt_fallback: params.allow_stt_fallback,
3046
+ max_duration_minutes: params.max_duration_minutes
3047
+ }
3048
+ });
3049
+ return {
3050
+ source_id: result.source_id,
3051
+ sync_job_id: result.job_id ?? null,
3052
+ status: result.status === "processing" || result.status === "queued" ? result.status : "created"
3053
+ };
2993
3054
  }
2994
3055
  async getSourceStatus(sourceId) {
2995
3056
  return this.request(`/v1/sources/${sourceId}/status`, { method: "GET" });
2996
3057
  }
2997
3058
  async createCanonicalSource(project, params) {
2998
- const connector_type = params.type === "github" ? "github" : params.type === "web" ? "website" : params.type === "pdf" ? "pdf" : params.type === "local" ? "local-folder" : "slack";
2999
- const config = {};
3000
- if (params.type === "github") {
3001
- if (!params.owner || !params.repo) throw new WhisperError({ code: "REQUEST_FAILED", message: "github source requires owner and repo" });
3002
- config.owner = params.owner;
3003
- config.repo = params.repo;
3004
- if (params.branch) config.branch = params.branch;
3005
- if (params.paths) config.paths = params.paths;
3006
- } else if (params.type === "web") {
3007
- if (!params.url) throw new WhisperError({ code: "REQUEST_FAILED", message: "web source requires url" });
3008
- config.url = params.url;
3009
- if (params.crawl_depth !== void 0) config.crawl_depth = params.crawl_depth;
3010
- if (params.include_paths) config.include_paths = params.include_paths;
3011
- if (params.exclude_paths) config.exclude_paths = params.exclude_paths;
3012
- } else if (params.type === "pdf") {
3013
- if (!params.url && !params.file_path) throw new WhisperError({ code: "REQUEST_FAILED", message: "pdf source requires url or file_path" });
3014
- if (params.url) config.url = params.url;
3015
- if (params.file_path) config.file_path = params.file_path;
3016
- } else if (params.type === "local") {
3017
- if (!params.path) throw new WhisperError({ code: "REQUEST_FAILED", message: "local source requires path" });
3018
- config.path = params.path;
3019
- if (params.glob) config.glob = params.glob;
3020
- if (params.max_files !== void 0) config.max_files = params.max_files;
3021
- } else {
3022
- config.channel_ids = params.channel_ids || [];
3023
- if (params.since) config.since = params.since;
3024
- if (params.workspace_id) config.workspace_id = params.workspace_id;
3025
- if (params.token) config.token = params.token;
3026
- if (params.auth_ref) config.auth_ref = params.auth_ref;
3027
- }
3028
- if (params.metadata) config.metadata = params.metadata;
3029
- if (params.ingestion_profile) config.ingestion_profile = params.ingestion_profile;
3030
- if (params.strategy_override) config.strategy_override = params.strategy_override;
3031
- if (params.profile_config) config.profile_config = params.profile_config;
3032
- config.auto_index = params.auto_index ?? true;
3033
- const created = await this.addSource(project, {
3034
- name: params.name || `${params.type}-source-${Date.now()}`,
3035
- connector_type,
3036
- config
3059
+ const result = await this.learn({
3060
+ mode: "source",
3061
+ project,
3062
+ type: params.type,
3063
+ name: params.name,
3064
+ metadata: params.metadata,
3065
+ owner: params.owner,
3066
+ repo: params.repo,
3067
+ branch: params.branch,
3068
+ paths: params.paths,
3069
+ url: params.url,
3070
+ file_path: params.file_path,
3071
+ path: params.path,
3072
+ channel_ids: params.channel_ids,
3073
+ since: params.since,
3074
+ token: params.token,
3075
+ auth_ref: params.auth_ref,
3076
+ platform: params.platform,
3077
+ language: params.language,
3078
+ options: {
3079
+ async: true,
3080
+ auto_index: params.auto_index ?? true,
3081
+ ingestion_profile: params.ingestion_profile,
3082
+ strategy_override: params.strategy_override,
3083
+ profile_config: params.profile_config,
3084
+ crawl_depth: params.crawl_depth,
3085
+ include_paths: params.include_paths,
3086
+ exclude_paths: params.exclude_paths,
3087
+ glob: params.glob,
3088
+ max_files: params.max_files,
3089
+ max_pages: params.max_pages,
3090
+ extract_mode: params.extract_mode,
3091
+ workspace_id: params.workspace_id,
3092
+ allow_stt_fallback: params.allow_stt_fallback,
3093
+ max_duration_minutes: params.max_duration_minutes,
3094
+ max_chunks: params.max_chunks
3095
+ }
3037
3096
  });
3038
- let status = "queued";
3039
- let jobId = null;
3040
- if (params.auto_index ?? true) {
3041
- const syncRes = await this.syncSource(created.id);
3042
- status = "indexing";
3043
- jobId = String(syncRes?.id || syncRes?.job_id || "");
3044
- }
3045
3097
  return {
3046
- source_id: created.id,
3047
- status,
3048
- job_id: jobId,
3049
- index_started: params.auto_index ?? true,
3098
+ source_id: result.source_id,
3099
+ status: result.status === "processing" ? "indexing" : result.status === "created" ? "queued" : result.status,
3100
+ job_id: result.job_id ?? null,
3101
+ index_started: result.index_started,
3050
3102
  warnings: []
3051
3103
  };
3052
3104
  }
3053
3105
  async ingest(projectId, documents) {
3054
- return this.withProjectPathFallback(
3055
- this.getRequiredProject(projectId),
3056
- (projectPathRef) => this.request(`/v1/projects/${encodeURIComponent(projectPathRef)}/ingest`, {
3057
- method: "POST",
3058
- body: JSON.stringify({ documents })
3059
- })
3106
+ await Promise.all(
3107
+ documents.map(
3108
+ (doc) => this.learn({
3109
+ mode: "text",
3110
+ project: projectId,
3111
+ title: doc.title,
3112
+ content: doc.content,
3113
+ metadata: {
3114
+ ...doc.metadata || {},
3115
+ ...doc.file_path ? { file_path: doc.file_path } : {}
3116
+ },
3117
+ options: {
3118
+ async: true,
3119
+ ingestion_profile: doc.ingestion_profile,
3120
+ strategy_override: doc.strategy_override,
3121
+ profile_config: doc.profile_config
3122
+ }
3123
+ })
3124
+ )
3060
3125
  );
3126
+ return { ingested: documents.length };
3061
3127
  }
3062
3128
  async addContext(params) {
3063
- const projectId = (await this.resolveProject(this.getRequiredProject(params.project))).id;
3064
- return this.ingest(projectId, [
3065
- {
3066
- title: params.title || "Context",
3067
- content: params.content,
3068
- metadata: params.metadata || { source: "addContext" }
3129
+ await this.learn({
3130
+ mode: "text",
3131
+ project: this.getRequiredProject(params.project),
3132
+ title: params.title || "Context",
3133
+ content: params.content,
3134
+ metadata: params.metadata || { source: "addContext" },
3135
+ options: {
3136
+ async: true
3069
3137
  }
3070
- ]);
3138
+ });
3139
+ return { ingested: 1 };
3140
+ }
3141
+ async learn(params) {
3142
+ const projectRef = this.getRequiredProject(params.project);
3143
+ return this.withProjectRefFallback(projectRef, (project) => this.request("/v1/learn", {
3144
+ method: "POST",
3145
+ body: JSON.stringify({
3146
+ ...params,
3147
+ project
3148
+ })
3149
+ }));
3071
3150
  }
3072
3151
  async listMemories(params) {
3073
3152
  const projectRef = this.getRequiredProject(params.project);
@@ -3800,15 +3879,50 @@ function ensureStateDir() {
3800
3879
  mkdirSync(STATE_DIR, { recursive: true });
3801
3880
  }
3802
3881
  }
3882
+ function canonicalizeWorkspacePath(path, cwd = process.cwd()) {
3883
+ const basePath = path?.trim() ? path.trim() : cwd;
3884
+ const absolute = resolvePath(cwd, basePath);
3885
+ const normalized = normalizePath(absolute).replace(/\\/g, "/");
3886
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
3887
+ }
3888
+ function chooseWorkspaceProjectSource(args) {
3889
+ const explicitProject = args.explicitProject?.trim();
3890
+ if (explicitProject) {
3891
+ return { project_ref: explicitProject, resolved_by: "explicit_project" };
3892
+ }
3893
+ const workspaceProjectRef = args.workspaceProjectRef?.trim();
3894
+ if (workspaceProjectRef) {
3895
+ return { project_ref: workspaceProjectRef, resolved_by: "workspace_binding" };
3896
+ }
3897
+ const defaultProject = args.defaultProject?.trim();
3898
+ if (defaultProject) {
3899
+ return { project_ref: defaultProject, resolved_by: "env_default" };
3900
+ }
3901
+ return { project_ref: null, resolved_by: "unresolved" };
3902
+ }
3903
+ function classifyWorkspaceHealth(args) {
3904
+ if (!args.project_ref) return "unbound";
3905
+ if (!args.last_indexed_at || (args.coverage ?? 0) <= 0) return "unindexed";
3906
+ const nowMs = args.now_ms ?? Date.now();
3907
+ const lastIndexedMs = new Date(args.last_indexed_at).getTime();
3908
+ const ageHours = Number.isFinite(lastIndexedMs) ? (nowMs - lastIndexedMs) / (60 * 60 * 1e3) : Number.POSITIVE_INFINITY;
3909
+ const pendingChanges = args.pending_changes ?? 0;
3910
+ const drifted = pendingChanges > 0 || Boolean(
3911
+ args.last_indexed_commit && args.current_commit && args.last_indexed_commit !== args.current_commit
3912
+ );
3913
+ if (drifted) return "drifted";
3914
+ if (ageHours > (args.max_staleness_hours ?? 168)) return "stale";
3915
+ return "healthy";
3916
+ }
3803
3917
  function getWorkspaceId(workspaceId) {
3804
3918
  if (workspaceId?.trim()) return workspaceId.trim();
3805
- const seed = `${process.cwd()}|${DEFAULT_PROJECT || "default"}|${API_KEY.slice(0, 12)}`;
3919
+ const seed = `${canonicalizeWorkspacePath(process.cwd())}|${API_KEY.slice(0, 12) || "anon"}`;
3806
3920
  return createHash("sha256").update(seed).digest("hex").slice(0, 20);
3807
3921
  }
3808
3922
  function getWorkspaceIdForPath(path, workspaceId) {
3809
3923
  if (workspaceId?.trim()) return workspaceId.trim();
3810
3924
  if (!path) return getWorkspaceId(void 0);
3811
- const seed = `${path}|${DEFAULT_PROJECT || "default"}|${API_KEY.slice(0, 12)}`;
3925
+ const seed = `${canonicalizeWorkspacePath(path)}|${API_KEY.slice(0, 12) || "anon"}`;
3812
3926
  return createHash("sha256").update(seed).digest("hex").slice(0, 20);
3813
3927
  }
3814
3928
  function clamp012(value) {
@@ -3899,7 +4013,10 @@ function getWorkspaceState(state, workspaceId) {
3899
4013
  annotations: [],
3900
4014
  session_summaries: [],
3901
4015
  events: [],
3902
- index_metadata: {}
4016
+ index_metadata: {},
4017
+ root_path: void 0,
4018
+ project_ref: void 0,
4019
+ project_id: void 0
3903
4020
  };
3904
4021
  }
3905
4022
  return state.workspaces[workspaceId];
@@ -3909,17 +4026,124 @@ function computeChecksum(value) {
3909
4026
  }
3910
4027
  var cachedProjectRef = DEFAULT_PROJECT || void 0;
3911
4028
  var cachedMcpSessionId = process.env.WHISPER_SESSION_ID || `mcp_${randomUUID().slice(0, 12)}`;
4029
+ async function resolveProjectDescriptor(projectRef) {
4030
+ try {
4031
+ const resolved = await whisper.resolveProject(projectRef);
4032
+ const resolvedRef = resolved.slug || resolved.name || resolved.id || projectRef;
4033
+ cachedProjectRef = resolvedRef;
4034
+ return { project_ref: resolvedRef, project_id: resolved.id || null };
4035
+ } catch {
4036
+ cachedProjectRef = projectRef;
4037
+ return { project_ref: projectRef, project_id: null };
4038
+ }
4039
+ }
4040
+ function getWorkspaceRecommendedNextCalls(health) {
4041
+ if (health === "unbound") return ["index.workspace_resolve", "context.list_projects", "index.workspace_status"];
4042
+ if (health === "unindexed") return ["index.workspace_status", "index.workspace_run", "search_code", "grep"];
4043
+ if (health === "stale" || health === "drifted") return ["index.workspace_status", "index.workspace_run", "grep", "search_code"];
4044
+ return [];
4045
+ }
4046
+ function getWorkspaceWarnings(args) {
4047
+ if (args.health === "unbound") {
4048
+ return [`Workspace ${args.rootPath} is not bound to a Whisper project.`];
4049
+ }
4050
+ if (args.health === "unindexed") {
4051
+ return [`Workspace ${args.rootPath} is bound to ${args.projectRef}, but no usable local index metadata exists yet.`];
4052
+ }
4053
+ if (args.health === "stale") {
4054
+ const age = args.freshness.age_hours == null ? "unknown" : `${Math.round(args.freshness.age_hours)}h`;
4055
+ return [`Workspace index for ${args.projectRef} is stale (${age} old).`];
4056
+ }
4057
+ if (args.health === "drifted") {
4058
+ const reasons = [];
4059
+ if (args.pendingChanges && args.pendingChanges > 0) reasons.push(`${args.pendingChanges} pending local changes`);
4060
+ if (args.lastIndexedCommit && args.currentCommit && args.lastIndexedCommit !== args.currentCommit) {
4061
+ reasons.push(`indexed commit ${args.lastIndexedCommit.slice(0, 12)} differs from current ${args.currentCommit.slice(0, 12)}`);
4062
+ }
4063
+ const suffix = reasons.length ? `: ${reasons.join("; ")}` : "";
4064
+ return [`Workspace state drifted since the last index${suffix}.`];
4065
+ }
4066
+ return [];
4067
+ }
4068
+ async function resolveWorkspaceTrust(args) {
4069
+ const rootPath = canonicalizeWorkspacePath(args.path);
4070
+ const workspaceId = getWorkspaceIdForPath(rootPath, args.workspace_id);
4071
+ const state = loadState();
4072
+ const workspace = getWorkspaceState(state, workspaceId);
4073
+ let mutated = false;
4074
+ if (workspace.root_path !== rootPath) {
4075
+ workspace.root_path = rootPath;
4076
+ mutated = true;
4077
+ }
4078
+ const selected = chooseWorkspaceProjectSource({
4079
+ explicitProject: args.project,
4080
+ workspaceProjectRef: workspace.project_ref,
4081
+ defaultProject: DEFAULT_PROJECT || null
4082
+ });
4083
+ let projectRef = null;
4084
+ let projectId = workspace.project_id || null;
4085
+ if (selected.project_ref) {
4086
+ const resolved = await resolveProjectDescriptor(selected.project_ref);
4087
+ projectRef = resolved.project_ref;
4088
+ projectId = resolved.project_id;
4089
+ if (workspace.project_ref !== projectRef || (workspace.project_id || null) !== projectId) {
4090
+ workspace.project_ref = projectRef;
4091
+ workspace.project_id = projectId || void 0;
4092
+ mutated = true;
4093
+ }
4094
+ }
4095
+ if (mutated) saveState(state);
4096
+ const lastIndexedAt = workspace.index_metadata?.last_indexed_at || null;
4097
+ const ageHours = lastIndexedAt ? (Date.now() - new Date(lastIndexedAt).getTime()) / (60 * 60 * 1e3) : null;
4098
+ const currentCommit = getGitHead(rootPath) || null;
4099
+ const pendingChanges = getGitPendingCount(rootPath);
4100
+ const lastIndexedCommit = workspace.index_metadata?.last_indexed_commit || null;
4101
+ const coverage = workspace.index_metadata?.coverage ?? 0;
4102
+ const health = classifyWorkspaceHealth({
4103
+ project_ref: projectRef,
4104
+ coverage,
4105
+ last_indexed_at: lastIndexedAt,
4106
+ last_indexed_commit: lastIndexedCommit,
4107
+ current_commit: currentCommit,
4108
+ pending_changes: pendingChanges ?? null,
4109
+ max_staleness_hours: args.max_staleness_hours ?? 168
4110
+ });
4111
+ const freshness = {
4112
+ stale: health === "stale",
4113
+ age_hours: ageHours,
4114
+ last_indexed_at: lastIndexedAt
4115
+ };
4116
+ const warnings = getWorkspaceWarnings({
4117
+ health,
4118
+ rootPath,
4119
+ projectRef,
4120
+ currentCommit,
4121
+ lastIndexedCommit,
4122
+ pendingChanges: pendingChanges ?? null,
4123
+ freshness
4124
+ });
4125
+ return {
4126
+ workspace_id: workspaceId,
4127
+ root_path: rootPath,
4128
+ project_ref: projectRef,
4129
+ project_id: projectId,
4130
+ resolved_by: selected.resolved_by,
4131
+ health,
4132
+ warnings,
4133
+ recommended_next_calls: getWorkspaceRecommendedNextCalls(health),
4134
+ freshness,
4135
+ coverage,
4136
+ last_indexed_commit: lastIndexedCommit,
4137
+ current_commit: currentCommit,
4138
+ pending_changes: pendingChanges ?? null,
4139
+ grounded_to_workspace: health === "healthy"
4140
+ };
4141
+ }
3912
4142
  async function resolveProjectRef(explicit) {
3913
4143
  if (explicit?.trim()) {
3914
4144
  const requestedRef = explicit.trim();
3915
- try {
3916
- const resolved = await whisper.resolveProject(requestedRef);
3917
- cachedProjectRef = resolved.slug || resolved.name || resolved.id;
3918
- return cachedProjectRef;
3919
- } catch {
3920
- cachedProjectRef = requestedRef;
3921
- return requestedRef;
3922
- }
4145
+ const resolved = await resolveProjectDescriptor(requestedRef);
4146
+ return resolved.project_ref;
3923
4147
  }
3924
4148
  if (cachedProjectRef) return cachedProjectRef;
3925
4149
  try {
@@ -4022,7 +4246,9 @@ function buildAbstain(args) {
4022
4246
  reason: args.reason,
4023
4247
  message: args.message,
4024
4248
  closest_evidence: args.closest_evidence,
4025
- recommended_next_calls: ["index.workspace_status", "index.workspace_run", "symbol_search", "context.get_relevant"],
4249
+ warnings: args.warnings || [],
4250
+ trust_state: args.trust_state,
4251
+ recommended_next_calls: args.recommended_next_calls || ["index.workspace_status", "index.workspace_run", "grep", "context.get_relevant"],
4026
4252
  diagnostics: {
4027
4253
  claims_evaluated: args.claims_evaluated,
4028
4254
  evidence_items_found: args.evidence_items_found,
@@ -4098,6 +4324,86 @@ function formatCanonicalMemoryResults(rawResults) {
4098
4324
  };
4099
4325
  });
4100
4326
  }
4327
+ function normalizeLooseText(value) {
4328
+ return String(value || "").trim().toLowerCase().replace(/\s+/g, " ");
4329
+ }
4330
+ function resolveForgetQueryCandidates(rawResults, query) {
4331
+ const normalizedQuery = normalizeLooseText(query);
4332
+ const candidates = normalizeCanonicalResults(rawResults).map((result) => {
4333
+ const memory = result?.memory || result;
4334
+ const metadata = memory?.metadata || {};
4335
+ return {
4336
+ id: String(memory?.id || "").trim(),
4337
+ content: normalizeLooseText(memory?.content),
4338
+ normalized_content: normalizeLooseText(metadata?.normalized_content),
4339
+ canonical_content: normalizeLooseText(metadata?.canonical_content),
4340
+ similarity: typeof result?.similarity === "number" ? result.similarity : typeof result?.score === "number" ? result.score : 0
4341
+ };
4342
+ }).filter((candidate) => candidate.id);
4343
+ const exactMatches = candidates.filter(
4344
+ (candidate) => candidate.id.toLowerCase() === normalizedQuery || candidate.content === normalizedQuery || candidate.normalized_content === normalizedQuery || candidate.canonical_content === normalizedQuery
4345
+ );
4346
+ if (exactMatches.length > 0) {
4347
+ return { memory_ids: [...new Set(exactMatches.map((candidate) => candidate.id))], resolved_by: "exact" };
4348
+ }
4349
+ const substringMatches = candidates.filter((candidate) => {
4350
+ const haystacks = [candidate.content, candidate.normalized_content, candidate.canonical_content].filter(Boolean);
4351
+ return haystacks.some(
4352
+ (value) => value.includes(normalizedQuery) || normalizedQuery.length >= 12 && normalizedQuery.includes(value)
4353
+ );
4354
+ });
4355
+ if (substringMatches.length === 1) {
4356
+ return { memory_ids: [substringMatches[0].id], resolved_by: "substring" };
4357
+ }
4358
+ if (substringMatches.length > 1) {
4359
+ return {
4360
+ memory_ids: [],
4361
+ resolved_by: "ambiguous",
4362
+ warning: "Query matched multiple memories by recognizable text. Use memory_id or a more specific query."
4363
+ };
4364
+ }
4365
+ const ranked = [...candidates].sort((a, b) => b.similarity - a.similarity);
4366
+ if (ranked[0] && ranked[0].similarity >= 0.9 && (!ranked[1] || ranked[0].similarity - ranked[1].similarity >= 0.08)) {
4367
+ return { memory_ids: [ranked[0].id], resolved_by: "high_confidence" };
4368
+ }
4369
+ return { memory_ids: [], resolved_by: "none", warning: "Query did not resolve to a reliable memory match. No memories were changed." };
4370
+ }
4371
+ async function searchMemoriesForContextQuery(args) {
4372
+ return whisper.searchMemoriesSOTA({
4373
+ project: args.project,
4374
+ query: args.query,
4375
+ user_id: args.user_id,
4376
+ session_id: args.session_id,
4377
+ top_k: args.top_k ?? 5,
4378
+ include_relations: false,
4379
+ include_pending: true
4380
+ });
4381
+ }
4382
+ async function runContextQueryMemoryRescue(args) {
4383
+ const scoped = await searchMemoriesForContextQuery(args);
4384
+ const scopedResults = formatCanonicalMemoryResults(scoped);
4385
+ if (scopedResults.length > 0) {
4386
+ return { results: scopedResults, rescue_mode: "scoped" };
4387
+ }
4388
+ if (args.user_id || args.session_id) {
4389
+ return { results: [], rescue_mode: null };
4390
+ }
4391
+ const broad = await searchMemoriesForContextQuery({
4392
+ project: args.project,
4393
+ query: args.query,
4394
+ top_k: args.top_k
4395
+ });
4396
+ return { results: formatCanonicalMemoryResults(broad), rescue_mode: formatCanonicalMemoryResults(broad).length > 0 ? "project_broad" : null };
4397
+ }
4398
+ function renderContextQueryMemoryRescue(args) {
4399
+ const lines = args.results.map(
4400
+ (result, index) => `${index + 1}. [${result.memory_type || "memory"}, score: ${result.similarity ?? "n/a"}] ${result.content}`
4401
+ );
4402
+ const scopeLabel = args.rescue_mode === "project_broad" ? `project=${args.project}, project_broad_memory_rescue=true` : `project=${args.project}, user=${args.scope.userId}, session=${args.scope.sessionId}, scoped_memory_rescue=true`;
4403
+ return `Found ${args.results.length} memory result(s) (${scopeLabel}):
4404
+
4405
+ ${lines.join("\n\n")}`;
4406
+ }
4101
4407
  function likelyEmbeddingFailure(error) {
4102
4408
  const message = String(error?.message || error || "").toLowerCase();
4103
4409
  return message.includes("embedding") || message.includes("vector") || message.includes("timeout") || message.includes("timed out") || message.includes("temporarily unavailable");
@@ -4139,6 +4445,186 @@ async function queryWithDegradedFallback(params) {
4139
4445
  };
4140
4446
  }
4141
4447
  }
4448
+ function collectCodeFiles(rootPath, allowedExts, maxFiles) {
4449
+ const files = [];
4450
+ function collect(dir) {
4451
+ if (files.length >= maxFiles) return;
4452
+ let entries;
4453
+ try {
4454
+ entries = readdirSync(dir, { withFileTypes: true });
4455
+ } catch {
4456
+ return;
4457
+ }
4458
+ for (const entry of entries) {
4459
+ if (files.length >= maxFiles) break;
4460
+ if (SKIP_DIRS.has(entry.name)) continue;
4461
+ const full = join(dir, entry.name);
4462
+ if (entry.isDirectory()) collect(full);
4463
+ else if (entry.isFile()) {
4464
+ const ext = extname(entry.name).replace(".", "");
4465
+ if (allowedExts.has(ext)) files.push(full);
4466
+ }
4467
+ }
4468
+ }
4469
+ collect(rootPath);
4470
+ return files;
4471
+ }
4472
+ function tokenizeQueryForLexicalRescue(query) {
4473
+ const stopWords = /* @__PURE__ */ new Set(["where", "what", "show", "find", "logic", "code", "file", "files", "handled", "handling"]);
4474
+ return Array.from(new Set(
4475
+ query.toLowerCase().split(/[^a-z0-9/_-]+/).map((token) => token.trim()).filter((token) => token.length >= 3 && !stopWords.has(token))
4476
+ ));
4477
+ }
4478
+ function buildLexicalRescueResults(args) {
4479
+ const tokens = tokenizeQueryForLexicalRescue(args.query);
4480
+ if (tokens.length === 0) return [];
4481
+ const scored = args.documents.map((doc) => {
4482
+ const haystack = `${doc.id}
4483
+ ${doc.content}
4484
+ ${doc.raw_content}`.toLowerCase();
4485
+ const tokenHits = tokens.filter((token) => haystack.includes(token));
4486
+ const uniqueHits = tokenHits.length;
4487
+ const exactPhraseBonus = haystack.includes(args.query.toLowerCase()) ? 0.25 : 0;
4488
+ const score = uniqueHits === 0 ? 0 : Math.min(0.95, uniqueHits / tokens.length + exactPhraseBonus);
4489
+ return {
4490
+ id: doc.id,
4491
+ score,
4492
+ content: doc.content,
4493
+ snippet: doc.content.split("\n").find((line) => line.trim().length > 10)?.slice(0, 200) || doc.id,
4494
+ search_mode: "lexical_rescue"
4495
+ };
4496
+ });
4497
+ return scored.filter((result) => result.score > 0).sort((a, b) => b.score - a.score).slice(0, args.top_k);
4498
+ }
4499
+ async function runSearchCodeSearch(args) {
4500
+ const topK = args.top_k ?? 10;
4501
+ const requestedThreshold = args.threshold ?? 0.2;
4502
+ const semanticDocuments = args.documents.map((doc) => ({ id: doc.id, content: doc.content }));
4503
+ const defaultResponse = await args.semantic_search({
4504
+ query: args.query,
4505
+ documents: semanticDocuments,
4506
+ top_k: topK,
4507
+ threshold: requestedThreshold
4508
+ });
4509
+ if (defaultResponse.results?.length) {
4510
+ return {
4511
+ results: defaultResponse.results.map((result) => ({ ...result, search_mode: "semantic" })),
4512
+ mode: "semantic",
4513
+ threshold_used: requestedThreshold,
4514
+ fallback_used: false,
4515
+ fallback_reason: null
4516
+ };
4517
+ }
4518
+ const adaptiveThreshold = Math.max(0.05, Math.min(requestedThreshold, requestedThreshold / 2));
4519
+ if (adaptiveThreshold < requestedThreshold) {
4520
+ const adaptiveResponse = await args.semantic_search({
4521
+ query: args.query,
4522
+ documents: semanticDocuments,
4523
+ top_k: topK,
4524
+ threshold: adaptiveThreshold
4525
+ });
4526
+ if (adaptiveResponse.results?.length) {
4527
+ return {
4528
+ results: adaptiveResponse.results.map((result) => ({ ...result, search_mode: "adaptive_semantic" })),
4529
+ mode: "adaptive_semantic",
4530
+ threshold_used: adaptiveThreshold,
4531
+ fallback_used: true,
4532
+ fallback_reason: `No results at threshold ${requestedThreshold}; lowered to ${adaptiveThreshold}.`
4533
+ };
4534
+ }
4535
+ }
4536
+ const lexicalResults = buildLexicalRescueResults({
4537
+ query: args.query,
4538
+ documents: args.documents,
4539
+ top_k: topK
4540
+ });
4541
+ return {
4542
+ results: lexicalResults,
4543
+ mode: "lexical_rescue",
4544
+ threshold_used: null,
4545
+ fallback_used: true,
4546
+ fallback_reason: lexicalResults.length ? "Semantic ranking returned no matches; lexical rescue over local file paths and content was used." : "Semantic ranking returned no matches and lexical rescue found no strong candidates."
4547
+ };
4548
+ }
4549
+ async function runSearchCodeTool(args) {
4550
+ const rootPath = canonicalizeWorkspacePath(args.path);
4551
+ const workspace = await resolveWorkspaceTrust({ path: rootPath });
4552
+ const allowedExts = args.file_types ? new Set(args.file_types) : CODE_EXTENSIONS;
4553
+ const files = collectCodeFiles(rootPath, allowedExts, args.max_files ?? 150);
4554
+ const sharedWarnings = [...workspace.warnings];
4555
+ if (files.length === 0) {
4556
+ return {
4557
+ tool: "search_code",
4558
+ query: args.query,
4559
+ path: rootPath,
4560
+ results: [],
4561
+ count: 0,
4562
+ warnings: sharedWarnings,
4563
+ diagnostics: {
4564
+ workspace_id: workspace.workspace_id,
4565
+ root_path: workspace.root_path,
4566
+ project_ref: workspace.project_ref,
4567
+ project_id: workspace.project_id,
4568
+ index_health: workspace.health,
4569
+ grounded_to_workspace: workspace.grounded_to_workspace,
4570
+ threshold_requested: args.threshold ?? 0.2,
4571
+ threshold_used: null,
4572
+ fallback_used: false,
4573
+ fallback_reason: null,
4574
+ search_mode: "semantic",
4575
+ warnings: sharedWarnings
4576
+ }
4577
+ };
4578
+ }
4579
+ const documents = [];
4580
+ for (const filePath of files) {
4581
+ try {
4582
+ const stat = statSync(filePath);
4583
+ if (stat.size > 500 * 1024) continue;
4584
+ const content = readFileSync(filePath, "utf-8");
4585
+ const relPath = relative(rootPath, filePath).replace(/\\/g, "/");
4586
+ documents.push({ id: relPath, content: extractSignature(relPath, content), raw_content: content });
4587
+ } catch {
4588
+ }
4589
+ }
4590
+ const searchResult = await runSearchCodeSearch({
4591
+ query: args.query,
4592
+ documents,
4593
+ top_k: args.top_k,
4594
+ threshold: args.threshold,
4595
+ semantic_search: (params) => whisper.semanticSearch(params)
4596
+ });
4597
+ if (searchResult.mode === "lexical_rescue") {
4598
+ sharedWarnings.push("Local lexical rescue was used because semantic search returned no matches.");
4599
+ } else if (searchResult.mode === "adaptive_semantic") {
4600
+ sharedWarnings.push(searchResult.fallback_reason || "Adaptive semantic thresholding was used.");
4601
+ }
4602
+ if (!workspace.grounded_to_workspace && workspace.health !== "unbound" && workspace.health !== "unindexed") {
4603
+ sharedWarnings.push("Local code search is live, but project-backed retrieval may disagree until the workspace is re-indexed.");
4604
+ }
4605
+ return {
4606
+ tool: "search_code",
4607
+ query: args.query,
4608
+ path: rootPath,
4609
+ results: searchResult.results,
4610
+ count: searchResult.results.length,
4611
+ warnings: sharedWarnings,
4612
+ diagnostics: {
4613
+ workspace_id: workspace.workspace_id,
4614
+ root_path: workspace.root_path,
4615
+ project_ref: workspace.project_ref,
4616
+ project_id: workspace.project_id,
4617
+ index_health: workspace.health,
4618
+ grounded_to_workspace: workspace.grounded_to_workspace,
4619
+ threshold_requested: args.threshold ?? 0.2,
4620
+ threshold_used: searchResult.threshold_used,
4621
+ fallback_used: searchResult.fallback_used,
4622
+ fallback_reason: searchResult.fallback_reason,
4623
+ search_mode: searchResult.mode,
4624
+ warnings: sharedWarnings
4625
+ }
4626
+ };
4627
+ }
4142
4628
  function getLocalAllowlistRoots() {
4143
4629
  const fromEnv = (process.env.WHISPER_LOCAL_ALLOWLIST || "").split(",").map((v) => v.trim()).filter(Boolean);
4144
4630
  if (fromEnv.length > 0) return fromEnv;
@@ -4218,7 +4704,8 @@ async function ingestLocalPath(params) {
4218
4704
  }
4219
4705
  collect(rootPath);
4220
4706
  const manifest = loadIngestManifest();
4221
- const workspaceId = getWorkspaceIdForPath(rootPath);
4707
+ const canonicalRootPath = canonicalizeWorkspacePath(rootPath);
4708
+ const workspaceId = getWorkspaceIdForPath(canonicalRootPath);
4222
4709
  if (!manifest[workspaceId]) manifest[workspaceId] = { last_run_at: (/* @__PURE__ */ new Date(0)).toISOString(), files: {} };
4223
4710
  const docs = [];
4224
4711
  const skipped = [];
@@ -4254,6 +4741,10 @@ async function ingestLocalPath(params) {
4254
4741
  }
4255
4742
  manifest[workspaceId].last_run_at = (/* @__PURE__ */ new Date()).toISOString();
4256
4743
  saveIngestManifest(manifest);
4744
+ const state = loadState();
4745
+ const workspace = getWorkspaceState(state, workspaceId);
4746
+ workspace.root_path = canonicalRootPath;
4747
+ saveState(state);
4257
4748
  appendFileSync(
4258
4749
  AUDIT_LOG_PATH,
4259
4750
  `${(/* @__PURE__ */ new Date()).toISOString()} local_ingest workspace=${workspaceId} root_hash=${createHash("sha256").update(rootPath).digest("hex").slice(0, 16)} files=${docs.length}
@@ -4262,79 +4753,40 @@ async function ingestLocalPath(params) {
4262
4753
  return { ingested, scanned: files.length, queued: docs.length, skipped, workspace_id: workspaceId };
4263
4754
  }
4264
4755
  async function createSourceByType(params) {
4265
- const connector_type = params.type === "github" ? "github" : params.type === "web" ? "website" : params.type === "pdf" ? "pdf" : params.type === "local" ? "local-folder" : params.type === "slack" ? "slack" : "video";
4266
- const config = {};
4267
- if (params.type === "github") {
4268
- if (!params.owner || !params.repo) throw new Error("github source requires owner and repo");
4269
- config.owner = params.owner;
4270
- config.repo = params.repo;
4271
- if (params.branch) config.branch = params.branch;
4272
- if (params.paths) config.paths = params.paths;
4273
- } else if (params.type === "web") {
4274
- if (!params.url) throw new Error("web source requires url");
4275
- config.url = params.url;
4276
- if (params.crawl_depth !== void 0) config.crawl_depth = params.crawl_depth;
4277
- if (params.include_paths) config.include_paths = params.include_paths;
4278
- if (params.exclude_paths) config.exclude_paths = params.exclude_paths;
4279
- } else if (params.type === "pdf") {
4280
- if (!params.url && !params.file_path) throw new Error("pdf source requires url or file_path");
4281
- if (params.url) config.url = params.url;
4282
- if (params.file_path) config.file_path = params.file_path;
4283
- } else if (params.type === "local") {
4284
- if (!params.path) throw new Error("local source requires path");
4285
- const ingest = await ingestLocalPath({
4286
- project: params.project,
4287
- path: params.path,
4288
- glob: params.glob,
4289
- max_files: params.max_files
4290
- });
4291
- return {
4292
- source_id: `local_${ingest.workspace_id}`,
4293
- status: "ready",
4294
- job_id: `local_ingest_${Date.now()}`,
4295
- index_started: true,
4296
- warnings: ingest.skipped.slice(0, 20),
4297
- details: ingest
4298
- };
4299
- } else if (params.type === "slack") {
4300
- config.channel_ids = params.channel_ids || [];
4301
- if (params.since) config.since = params.since;
4302
- if (params.workspace_id) config.workspace_id = params.workspace_id;
4303
- if (params.token) config.token = params.token;
4304
- if (params.auth_ref) config.auth_ref = params.auth_ref;
4305
- } else if (params.type === "video") {
4306
- if (!params.url) throw new Error("video source requires url");
4307
- config.url = params.url;
4308
- if (params.platform) config.platform = params.platform;
4309
- if (params.language) config.language = params.language;
4310
- if (params.allow_stt_fallback !== void 0) config.allow_stt_fallback = params.allow_stt_fallback;
4311
- if (params.max_duration_minutes !== void 0) config.max_duration_minutes = params.max_duration_minutes;
4312
- if (params.max_chunks !== void 0) config.max_chunks = params.max_chunks;
4313
- }
4314
- if (params.metadata) config.metadata = params.metadata;
4315
- if (params.ingestion_profile) config.ingestion_profile = params.ingestion_profile;
4316
- if (params.strategy_override) config.strategy_override = params.strategy_override;
4317
- if (params.profile_config) config.profile_config = params.profile_config;
4318
- config.auto_index = params.auto_index ?? true;
4319
- const created = await whisper.addSource(params.project, {
4320
- name: params.name || `${params.type}-source-${Date.now()}`,
4321
- connector_type,
4322
- config
4756
+ const result = await whisper.createCanonicalSource(params.project, {
4757
+ type: params.type,
4758
+ name: params.name,
4759
+ auto_index: params.auto_index,
4760
+ metadata: params.metadata,
4761
+ ingestion_profile: params.ingestion_profile,
4762
+ strategy_override: params.strategy_override,
4763
+ profile_config: params.profile_config,
4764
+ owner: params.owner,
4765
+ repo: params.repo,
4766
+ branch: params.branch,
4767
+ paths: params.paths,
4768
+ url: params.url,
4769
+ crawl_depth: params.crawl_depth,
4770
+ include_paths: params.include_paths,
4771
+ exclude_paths: params.exclude_paths,
4772
+ file_path: params.file_path,
4773
+ path: params.path,
4774
+ glob: params.glob,
4775
+ max_files: params.max_files,
4776
+ max_pages: params.max_pages,
4777
+ extract_mode: params.extract_mode,
4778
+ workspace_id: params.workspace_id,
4779
+ channel_ids: params.channel_ids,
4780
+ since: params.since,
4781
+ token: params.token,
4782
+ auth_ref: params.auth_ref,
4783
+ platform: params.platform,
4784
+ language: params.language,
4785
+ allow_stt_fallback: params.allow_stt_fallback,
4786
+ max_duration_minutes: params.max_duration_minutes,
4787
+ max_chunks: params.max_chunks
4323
4788
  });
4324
- let jobId;
4325
- let status = "queued";
4326
- if (params.auto_index ?? true) {
4327
- const syncRes = await whisper.syncSource(created.id);
4328
- jobId = String(syncRes?.id || syncRes?.job_id || "");
4329
- status = "indexing";
4330
- }
4331
- return {
4332
- source_id: created.id,
4333
- status,
4334
- job_id: jobId || null,
4335
- index_started: params.auto_index ?? true,
4336
- warnings: []
4337
- };
4789
+ return result;
4338
4790
  }
4339
4791
  function normalizeRecordMessages(input) {
4340
4792
  if (Array.isArray(input.messages) && input.messages.length > 0) {
@@ -4358,85 +4810,60 @@ async function learnFromInput(input) {
4358
4810
  if (!resolvedProject) {
4359
4811
  throw new Error("No project resolved. Set WHISPER_PROJECT or provide project.");
4360
4812
  }
4361
- if (input.content) {
4362
- const mergedMetadata = {
4363
- ...input.metadata || {},
4364
- ...input.ingestion_profile ? { ingestion_profile: input.ingestion_profile } : {},
4365
- ...input.strategy_override ? { strategy_override: input.strategy_override } : {},
4366
- ...input.profile_config ? { profile_config: input.profile_config } : {}
4367
- };
4368
- const result = await whisper.addContext({
4369
- project: resolvedProject,
4370
- content: input.content,
4371
- title: input.title || "Learned Context",
4372
- metadata: mergedMetadata
4373
- });
4374
- return {
4375
- mode: "text",
4376
- project: resolvedProject,
4377
- ingested: result.ingested
4378
- };
4379
- }
4380
- if (input.owner && input.repo) {
4381
- return createSourceByType({
4382
- project: resolvedProject,
4383
- type: "github",
4384
- owner: input.owner,
4385
- repo: input.repo,
4386
- branch: input.branch,
4387
- name: input.name,
4388
- auto_index: true,
4389
- metadata: input.metadata,
4390
- ingestion_profile: input.ingestion_profile,
4391
- strategy_override: input.strategy_override,
4392
- profile_config: input.profile_config
4393
- });
4394
- }
4395
- if (input.path) {
4396
- return createSourceByType({
4397
- project: resolvedProject,
4398
- type: "local",
4399
- path: input.path,
4400
- glob: input.glob,
4401
- max_files: input.max_files,
4402
- name: input.name,
4403
- metadata: input.metadata,
4404
- ingestion_profile: input.ingestion_profile,
4405
- strategy_override: input.strategy_override,
4406
- profile_config: input.profile_config
4407
- });
4408
- }
4409
- if (input.file_path) {
4410
- return createSourceByType({
4813
+ if (input.mode === "conversation") {
4814
+ if (!input.session_id || !input.messages || input.messages.length === 0) {
4815
+ throw new Error("conversation learn requires session_id and messages[]");
4816
+ }
4817
+ return whisper.learn({
4818
+ mode: "conversation",
4411
4819
  project: resolvedProject,
4412
- type: "pdf",
4413
- file_path: input.file_path,
4414
- name: input.name,
4415
- metadata: input.metadata,
4416
- ingestion_profile: input.ingestion_profile,
4417
- strategy_override: input.strategy_override,
4418
- profile_config: input.profile_config
4820
+ user_id: input.user_id,
4821
+ session_id: input.session_id,
4822
+ messages: input.messages.map((message) => ({
4823
+ ...message,
4824
+ timestamp: message.timestamp || (/* @__PURE__ */ new Date()).toISOString()
4825
+ }))
4419
4826
  });
4420
4827
  }
4421
- if (input.url) {
4422
- return createSourceByType({
4828
+ if (input.mode === "text") {
4829
+ if (!input.title || !input.content) {
4830
+ throw new Error("text learn requires title and content");
4831
+ }
4832
+ return whisper.learn({
4833
+ mode: "text",
4423
4834
  project: resolvedProject,
4424
- type: input.url.endsWith(".pdf") ? "pdf" : "web",
4425
- url: input.url,
4426
- name: input.name,
4835
+ title: input.title,
4836
+ content: input.content,
4427
4837
  metadata: input.metadata,
4428
- ingestion_profile: input.ingestion_profile,
4429
- strategy_override: input.strategy_override,
4430
- profile_config: input.profile_config,
4431
- crawl_depth: input.crawl_depth,
4432
- channel_ids: input.channel_ids,
4433
- token: input.token,
4434
- workspace_id: input.workspace_id,
4435
- since: input.since,
4436
- auth_ref: input.auth_ref
4838
+ tags: input.tags,
4839
+ namespace: input.namespace,
4840
+ options: input.options
4437
4841
  });
4438
4842
  }
4439
- throw new Error("Provide content, owner+repo, path, file_path, or url.");
4843
+ if (!input.type) {
4844
+ throw new Error("source learn requires type");
4845
+ }
4846
+ return whisper.learn({
4847
+ mode: "source",
4848
+ project: resolvedProject,
4849
+ type: input.type,
4850
+ name: input.name,
4851
+ metadata: input.metadata,
4852
+ owner: input.owner,
4853
+ repo: input.repo,
4854
+ branch: input.branch,
4855
+ paths: input.paths,
4856
+ url: input.url,
4857
+ file_path: input.file_path,
4858
+ path: input.path,
4859
+ channel_ids: input.channel_ids,
4860
+ since: input.since,
4861
+ token: input.token,
4862
+ auth_ref: input.auth_ref,
4863
+ platform: input.platform,
4864
+ language: input.language,
4865
+ options: input.options
4866
+ });
4440
4867
  }
4441
4868
  function renderScopedMcpConfig(project, source, client) {
4442
4869
  const serverDef = {
@@ -4473,22 +4900,25 @@ server.tool(
4473
4900
  },
4474
4901
  async ({ path, workspace_id, project }) => {
4475
4902
  try {
4476
- const workspaceId = getWorkspaceIdForPath(path, workspace_id);
4903
+ const rootPath = canonicalizeWorkspacePath(path);
4904
+ const workspaceId = getWorkspaceIdForPath(rootPath, workspace_id);
4477
4905
  const state = loadState();
4478
4906
  const existed = Boolean(state.workspaces[workspaceId]);
4479
- const workspace = getWorkspaceState(state, workspaceId);
4480
- const resolvedProject = await resolveProjectRef(project);
4481
- const resolvedBy = project?.trim() ? "explicit_project" : DEFAULT_PROJECT ? "env_default" : resolvedProject ? "auto_first_project" : "unresolved";
4482
- saveState(state);
4907
+ const trust = await resolveWorkspaceTrust({ path: rootPath, workspace_id, project });
4483
4908
  const payload = {
4484
4909
  workspace_id: workspaceId,
4485
- project_id: resolvedProject || null,
4910
+ root_path: trust.root_path,
4911
+ project_ref: trust.project_ref,
4912
+ project_id: trust.project_id,
4486
4913
  created: !existed,
4487
- resolved_by: resolvedBy,
4914
+ resolved_by: trust.resolved_by,
4915
+ health: trust.health,
4916
+ warnings: trust.warnings,
4917
+ recommended_next_calls: trust.recommended_next_calls,
4488
4918
  index_state: {
4489
- last_indexed_at: workspace.index_metadata?.last_indexed_at || null,
4490
- last_indexed_commit: workspace.index_metadata?.last_indexed_commit || null,
4491
- coverage: workspace.index_metadata?.coverage ?? 0
4919
+ last_indexed_at: trust.freshness.last_indexed_at,
4920
+ last_indexed_commit: trust.last_indexed_commit,
4921
+ coverage: trust.coverage
4492
4922
  }
4493
4923
  };
4494
4924
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
@@ -4506,24 +4936,22 @@ server.tool(
4506
4936
  },
4507
4937
  async ({ workspace_id, path }) => {
4508
4938
  try {
4509
- const rootPath = path || process.cwd();
4510
- const workspaceId = getWorkspaceIdForPath(rootPath, workspace_id);
4511
- const state = loadState();
4512
- const workspace = getWorkspaceState(state, workspaceId);
4513
- const lastIndexedAt = workspace.index_metadata?.last_indexed_at;
4514
- const ageHours = lastIndexedAt ? (Date.now() - new Date(lastIndexedAt).getTime()) / (60 * 60 * 1e3) : null;
4515
- const stale = ageHours === null ? true : ageHours > 168;
4939
+ const trust = await resolveWorkspaceTrust({ path, workspace_id });
4516
4940
  const payload = {
4517
- workspace_id: workspaceId,
4518
- freshness: {
4519
- stale,
4520
- age_hours: ageHours,
4521
- last_indexed_at: lastIndexedAt || null
4522
- },
4523
- coverage: workspace.index_metadata?.coverage ?? 0,
4524
- last_indexed_commit: workspace.index_metadata?.last_indexed_commit || null,
4525
- current_commit: getGitHead(rootPath) || null,
4526
- pending_changes: getGitPendingCount(rootPath)
4941
+ workspace_id: trust.workspace_id,
4942
+ root_path: trust.root_path,
4943
+ project_ref: trust.project_ref,
4944
+ project_id: trust.project_id,
4945
+ resolved_by: trust.resolved_by,
4946
+ health: trust.health,
4947
+ warnings: trust.warnings,
4948
+ recommended_next_calls: trust.recommended_next_calls,
4949
+ freshness: trust.freshness,
4950
+ coverage: trust.coverage,
4951
+ last_indexed_commit: trust.last_indexed_commit,
4952
+ current_commit: trust.current_commit,
4953
+ pending_changes: trust.pending_changes,
4954
+ grounded_to_workspace: trust.grounded_to_workspace
4527
4955
  };
4528
4956
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
4529
4957
  } catch (error) {
@@ -4542,13 +4970,14 @@ server.tool(
4542
4970
  },
4543
4971
  async ({ workspace_id, path, mode, max_files }) => {
4544
4972
  try {
4545
- const rootPath = path || process.cwd();
4973
+ const rootPath = canonicalizeWorkspacePath(path);
4546
4974
  const workspaceId = getWorkspaceIdForPath(rootPath, workspace_id);
4547
4975
  const state = loadState();
4548
4976
  const workspace = getWorkspaceState(state, workspaceId);
4549
4977
  const fileStats = countCodeFiles(rootPath, max_files);
4550
4978
  const coverage = Math.max(0, Math.min(1, fileStats.total / Math.max(1, max_files)));
4551
4979
  const now = (/* @__PURE__ */ new Date()).toISOString();
4980
+ workspace.root_path = rootPath;
4552
4981
  workspace.index_metadata = {
4553
4982
  last_indexed_at: now,
4554
4983
  last_indexed_commit: getGitHead(rootPath),
@@ -4557,6 +4986,7 @@ server.tool(
4557
4986
  saveState(state);
4558
4987
  const payload = {
4559
4988
  workspace_id: workspaceId,
4989
+ root_path: rootPath,
4560
4990
  mode,
4561
4991
  indexed_files: fileStats.total,
4562
4992
  skipped_files: fileStats.skipped,
@@ -4621,23 +5051,25 @@ server.tool(
4621
5051
  },
4622
5052
  async ({ question, workspace_id, project, top_k, include_memories, include_graph, session_id, user_id }) => {
4623
5053
  try {
4624
- const workspaceId = getWorkspaceId(workspace_id);
4625
- const resolvedProject = await resolveProjectRef(project);
4626
- if (!resolvedProject) {
5054
+ const trust = await resolveWorkspaceTrust({ workspace_id, project });
5055
+ if (trust.health === "unbound" || trust.health === "unindexed" || !trust.project_ref) {
4627
5056
  const payload2 = {
4628
5057
  question,
4629
- workspace_id: workspaceId,
5058
+ workspace_id: trust.workspace_id,
5059
+ trust_state: trust,
5060
+ grounded_to_workspace: false,
4630
5061
  total_results: 0,
4631
5062
  context: "",
4632
5063
  evidence: [],
4633
5064
  used_context_ids: [],
4634
5065
  latency_ms: 0,
4635
- warning: "No project resolved. Set WHISPER_PROJECT or create one in your account."
5066
+ warnings: trust.warnings,
5067
+ recommended_next_calls: trust.recommended_next_calls
4636
5068
  };
4637
5069
  return { content: [{ type: "text", text: JSON.stringify(payload2, null, 2) }] };
4638
5070
  }
4639
5071
  const queryResult = await queryWithDegradedFallback({
4640
- project: resolvedProject,
5072
+ project: trust.project_ref,
4641
5073
  query: question,
4642
5074
  top_k,
4643
5075
  include_memories,
@@ -4646,10 +5078,12 @@ server.tool(
4646
5078
  user_id
4647
5079
  });
4648
5080
  const response = queryResult.response;
4649
- const evidence = (response.results || []).map((r) => toEvidenceRef(r, workspaceId, "semantic"));
5081
+ const evidence = (response.results || []).map((r) => toEvidenceRef(r, trust.workspace_id, "semantic"));
4650
5082
  const payload = {
4651
5083
  question,
4652
- workspace_id: workspaceId,
5084
+ workspace_id: trust.workspace_id,
5085
+ trust_state: trust,
5086
+ grounded_to_workspace: trust.grounded_to_workspace,
4653
5087
  total_results: response.meta?.total || evidence.length,
4654
5088
  context: response.context || "",
4655
5089
  evidence,
@@ -4657,7 +5091,9 @@ server.tool(
4657
5091
  latency_ms: response.meta?.latency_ms || 0,
4658
5092
  degraded_mode: queryResult.degraded_mode,
4659
5093
  degraded_reason: queryResult.degraded_reason,
4660
- recommendation: queryResult.recommendation
5094
+ recommendation: queryResult.recommendation,
5095
+ warnings: trust.warnings,
5096
+ recommended_next_calls: trust.recommended_next_calls
4661
5097
  };
4662
5098
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
4663
5099
  } catch (error) {
@@ -4677,20 +5113,22 @@ server.tool(
4677
5113
  },
4678
5114
  async ({ claim, workspace_id, project, context_ids, strict }) => {
4679
5115
  try {
4680
- const workspaceId = getWorkspaceId(workspace_id);
4681
- const resolvedProject = await resolveProjectRef(project);
4682
- if (!resolvedProject) {
5116
+ const trust = await resolveWorkspaceTrust({ workspace_id, project });
5117
+ if (trust.health !== "healthy" || !trust.project_ref) {
4683
5118
  const payload2 = {
4684
5119
  verdict: "unsupported",
4685
5120
  confidence: 0,
4686
5121
  evidence: [],
4687
- missing_requirements: ["No project resolved. Set WHISPER_PROJECT or create one in your account."],
4688
- explanation: "Verifier could not run because no project is configured."
5122
+ trust_state: trust,
5123
+ warnings: trust.warnings,
5124
+ recommended_next_calls: trust.recommended_next_calls,
5125
+ missing_requirements: trust.warnings.length ? trust.warnings : ["Workspace trust requirements were not met."],
5126
+ explanation: "Verifier did not run because workspace grounding is insufficient."
4689
5127
  };
4690
5128
  return { content: [{ type: "text", text: JSON.stringify(payload2, null, 2) }] };
4691
5129
  }
4692
5130
  const response = await whisper.query({
4693
- project: resolvedProject,
5131
+ project: trust.project_ref,
4694
5132
  query: claim,
4695
5133
  top_k: strict ? 8 : 12,
4696
5134
  include_memories: true,
@@ -4699,7 +5137,7 @@ server.tool(
4699
5137
  const filtered = (response.results || []).filter(
4700
5138
  (r) => !context_ids || context_ids.length === 0 || context_ids.includes(String(r.id))
4701
5139
  );
4702
- const evidence = filtered.map((r) => toEvidenceRef(r, workspaceId, "semantic"));
5140
+ const evidence = filtered.map((r) => toEvidenceRef(r, trust.workspace_id, "semantic"));
4703
5141
  const directEvidence = evidence.filter((e) => e.score >= (strict ? 0.7 : 0.6));
4704
5142
  const weakEvidence = evidence.filter((e) => e.score >= (strict ? 0.45 : 0.35));
4705
5143
  let verdict = "unsupported";
@@ -4709,6 +5147,9 @@ server.tool(
4709
5147
  verdict,
4710
5148
  confidence: evidence.length ? Math.max(...evidence.map((e) => e.score)) : 0,
4711
5149
  evidence: verdict === "supported" ? directEvidence : weakEvidence,
5150
+ trust_state: trust,
5151
+ warnings: trust.warnings,
5152
+ recommended_next_calls: trust.recommended_next_calls,
4712
5153
  missing_requirements: verdict === "supported" ? [] : verdict === "partial" ? ["No direct evidence spans met strict threshold."] : ["No sufficient supporting evidence found for the claim."],
4713
5154
  explanation: verdict === "supported" ? "At least one direct evidence span supports the claim." : verdict === "partial" ? "Some related evidence exists, but direct support is incomplete." : "Retrieved context did not contain sufficient support."
4714
5155
  };
@@ -4739,39 +5180,38 @@ server.tool(
4739
5180
  },
4740
5181
  async ({ question, workspace_id, project, constraints, retrieval }) => {
4741
5182
  try {
4742
- const workspaceId = getWorkspaceId(workspace_id);
4743
5183
  const requireCitations = constraints?.require_citations ?? true;
4744
5184
  const minEvidenceItems = constraints?.min_evidence_items ?? 2;
4745
5185
  const minConfidence = constraints?.min_confidence ?? 0.65;
4746
5186
  const maxStalenessHours = constraints?.max_staleness_hours ?? 168;
4747
5187
  const topK = retrieval?.top_k ?? 12;
4748
- const resolvedProject = await resolveProjectRef(project);
4749
- if (!resolvedProject) {
5188
+ const trust = await resolveWorkspaceTrust({ workspace_id, project, max_staleness_hours: maxStalenessHours });
5189
+ if (trust.health !== "healthy" || !trust.project_ref) {
5190
+ const reason = trust.health === "stale" || trust.health === "drifted" ? "stale_index" : "no_retrieval_hits";
4750
5191
  const abstain = buildAbstain({
4751
- reason: "no_retrieval_hits",
4752
- message: "No project resolved. Set WHISPER_PROJECT or create one in your account.",
5192
+ reason,
5193
+ message: trust.warnings[0] || "Workspace trust requirements were not met.",
4753
5194
  closest_evidence: [],
4754
5195
  claims_evaluated: 1,
4755
5196
  evidence_items_found: 0,
4756
5197
  min_required: minEvidenceItems,
4757
- index_fresh: true
5198
+ index_fresh: trust.health === "healthy",
5199
+ warnings: trust.warnings,
5200
+ trust_state: trust,
5201
+ recommended_next_calls: trust.recommended_next_calls
4758
5202
  });
4759
5203
  return { content: [{ type: "text", text: JSON.stringify(abstain, null, 2) }] };
4760
5204
  }
4761
5205
  const response = await whisper.query({
4762
- project: resolvedProject,
5206
+ project: trust.project_ref,
4763
5207
  query: question,
4764
5208
  top_k: topK,
4765
5209
  include_memories: true,
4766
5210
  include_graph: true
4767
5211
  });
4768
- const evidence = (response.results || []).map((r) => toEvidenceRef(r, workspaceId, "semantic"));
5212
+ const evidence = (response.results || []).map((r) => toEvidenceRef(r, trust.workspace_id, "semantic"));
4769
5213
  const sorted = evidence.sort((a, b) => b.score - a.score);
4770
5214
  const confidence = sorted.length ? sorted[0].score : 0;
4771
- const state = loadState();
4772
- const workspace = getWorkspaceState(state, workspaceId);
4773
- const lastIndexedAt = workspace.index_metadata?.last_indexed_at;
4774
- const indexFresh = !lastIndexedAt || Date.now() - new Date(lastIndexedAt).getTime() <= maxStalenessHours * 60 * 60 * 1e3;
4775
5215
  if (sorted.length === 0) {
4776
5216
  const abstain = buildAbstain({
4777
5217
  reason: "no_retrieval_hits",
@@ -4780,24 +5220,15 @@ server.tool(
4780
5220
  claims_evaluated: 1,
4781
5221
  evidence_items_found: 0,
4782
5222
  min_required: minEvidenceItems,
4783
- index_fresh: indexFresh
5223
+ index_fresh: trust.health === "healthy",
5224
+ warnings: trust.warnings,
5225
+ trust_state: trust,
5226
+ recommended_next_calls: trust.recommended_next_calls
4784
5227
  });
4785
5228
  return { content: [{ type: "text", text: JSON.stringify(abstain, null, 2) }] };
4786
5229
  }
4787
5230
  const supportedEvidence = sorted.filter((e) => e.score >= minConfidence);
4788
5231
  const verdict = supportedEvidence.length >= 1 ? "supported" : "partial";
4789
- if (!indexFresh) {
4790
- const abstain = buildAbstain({
4791
- reason: "stale_index",
4792
- message: "Index freshness requirement not met. Re-index before answering.",
4793
- closest_evidence: sorted.slice(0, 3),
4794
- claims_evaluated: 1,
4795
- evidence_items_found: supportedEvidence.length,
4796
- min_required: minEvidenceItems,
4797
- index_fresh: false
4798
- });
4799
- return { content: [{ type: "text", text: JSON.stringify(abstain, null, 2) }] };
4800
- }
4801
5232
  if (requireCitations && (verdict !== "supported" || supportedEvidence.length < minEvidenceItems)) {
4802
5233
  const abstain = buildAbstain({
4803
5234
  reason: "insufficient_evidence",
@@ -4806,7 +5237,10 @@ server.tool(
4806
5237
  claims_evaluated: 1,
4807
5238
  evidence_items_found: supportedEvidence.length,
4808
5239
  min_required: minEvidenceItems,
4809
- index_fresh: true
5240
+ index_fresh: true,
5241
+ warnings: trust.warnings,
5242
+ trust_state: trust,
5243
+ recommended_next_calls: trust.recommended_next_calls
4810
5244
  });
4811
5245
  return { content: [{ type: "text", text: JSON.stringify(abstain, null, 2) }] };
4812
5246
  }
@@ -4819,6 +5253,9 @@ server.tool(
4819
5253
  answer: answerLines.join("\n"),
4820
5254
  citations,
4821
5255
  confidence,
5256
+ trust_state: trust,
5257
+ warnings: trust.warnings,
5258
+ recommended_next_calls: trust.recommended_next_calls,
4822
5259
  verification: {
4823
5260
  verdict,
4824
5261
  supported_claims: verdict === "supported" ? 1 : 0,
@@ -4852,6 +5289,7 @@ server.tool(
4852
5289
  if (!resolvedProject) {
4853
5290
  return { content: [{ type: "text", text: "Error: No project resolved. Set WHISPER_PROJECT or pass project." }] };
4854
5291
  }
5292
+ const scope = resolveMcpScope({ project: resolvedProject, user_id, session_id });
4855
5293
  const automaticMode = include_memories !== false && include_graph !== true && !(chunk_types && chunk_types.length > 0) && max_tokens === void 0 && runtimeClient;
4856
5294
  if (automaticMode) {
4857
5295
  try {
@@ -4863,6 +5301,27 @@ server.tool(
4863
5301
  session_id
4864
5302
  });
4865
5303
  if (!prepared.items.length) {
5304
+ const memoryRescue = include_memories !== false ? await runContextQueryMemoryRescue({
5305
+ project: resolvedProject,
5306
+ query,
5307
+ user_id: user_id ? scope2.userId : void 0,
5308
+ session_id: session_id ? scope2.sessionId : void 0,
5309
+ top_k
5310
+ }) : { results: [], rescue_mode: null };
5311
+ if (memoryRescue.results.length && memoryRescue.rescue_mode) {
5312
+ return {
5313
+ content: [{
5314
+ type: "text",
5315
+ text: renderContextQueryMemoryRescue({
5316
+ project: resolvedProject,
5317
+ query,
5318
+ scope: scope2,
5319
+ results: memoryRescue.results,
5320
+ rescue_mode: memoryRescue.rescue_mode
5321
+ })
5322
+ }]
5323
+ };
5324
+ }
4866
5325
  return { content: [{ type: "text", text: "No relevant context found." }] };
4867
5326
  }
4868
5327
  const warnings = prepared.retrieval.warnings.length ? `
@@ -4897,13 +5356,36 @@ ${prepared.context}${warnings}`
4897
5356
  });
4898
5357
  const response2 = queryResult2.response;
4899
5358
  if (response2.results.length === 0) {
5359
+ const memoryRescue = include_memories !== false ? await runContextQueryMemoryRescue({
5360
+ project: resolvedProject,
5361
+ query,
5362
+ user_id: user_id ? scope.userId : void 0,
5363
+ session_id: session_id ? scope.sessionId : void 0,
5364
+ top_k
5365
+ }) : { results: [], rescue_mode: null };
5366
+ if (memoryRescue.results.length && memoryRescue.rescue_mode) {
5367
+ return {
5368
+ content: [{
5369
+ type: "text",
5370
+ text: `${renderContextQueryMemoryRescue({
5371
+ project: resolvedProject,
5372
+ query,
5373
+ scope,
5374
+ results: memoryRescue.results,
5375
+ rescue_mode: memoryRescue.rescue_mode
5376
+ })}
5377
+
5378
+ [automatic_runtime]
5379
+ ${automaticWarning}`
5380
+ }]
5381
+ };
5382
+ }
4900
5383
  return { content: [{ type: "text", text: `No relevant context found.
4901
5384
 
4902
5385
  [automatic_runtime]
4903
5386
  ${automaticWarning}` }] };
4904
5387
  }
4905
- const scope2 = resolveMcpScope({ user_id, session_id });
4906
- const header2 = `Found ${response2.meta.total} results (${response2.meta.latency_ms}ms${response2.meta.cache_hit ? ", cached" : ""}, project=${resolvedProject}, user=${scope2.userId}, session=${scope2.sessionId}):
5388
+ const header2 = `Found ${response2.meta.total} results (${response2.meta.latency_ms}ms${response2.meta.cache_hit ? ", cached" : ""}, project=${resolvedProject}, user=${scope.userId}, session=${scope.sessionId}):
4907
5389
 
4908
5390
  `;
4909
5391
  const suffix2 = queryResult2.degraded_mode ? `
@@ -4927,9 +5409,29 @@ ${automaticWarning}${suffix2}` }] };
4927
5409
  });
4928
5410
  const response = queryResult.response;
4929
5411
  if (response.results.length === 0) {
5412
+ const memoryRescue = include_memories !== false ? await runContextQueryMemoryRescue({
5413
+ project: resolvedProject,
5414
+ query,
5415
+ user_id: user_id ? scope.userId : void 0,
5416
+ session_id: session_id ? scope.sessionId : void 0,
5417
+ top_k
5418
+ }) : { results: [], rescue_mode: null };
5419
+ if (memoryRescue.results.length && memoryRescue.rescue_mode) {
5420
+ return {
5421
+ content: [{
5422
+ type: "text",
5423
+ text: renderContextQueryMemoryRescue({
5424
+ project: resolvedProject,
5425
+ query,
5426
+ scope,
5427
+ results: memoryRescue.results,
5428
+ rescue_mode: memoryRescue.rescue_mode
5429
+ })
5430
+ }]
5431
+ };
5432
+ }
4930
5433
  return { content: [{ type: "text", text: "No relevant context found." }] };
4931
5434
  }
4932
- const scope = resolveMcpScope({ user_id, session_id });
4933
5435
  const header = `Found ${response.meta.total} results (${response.meta.latency_ms}ms${response.meta.cache_hit ? ", cached" : ""}, project=${resolvedProject}, user=${scope.userId}, session=${scope.sessionId}):
4934
5436
 
4935
5437
  `;
@@ -5067,10 +5569,10 @@ server.tool(
5067
5569
  );
5068
5570
  server.tool(
5069
5571
  "context.add_source",
5070
- "Add a source to a project with normalized source contract and auto-index by default.",
5572
+ "Compatibility learning tool. Add a source to a project with normalized source contract and auto-index by default. Prefer `learn` for new integrations.",
5071
5573
  {
5072
5574
  project: z.string().optional().describe("Project name or slug"),
5073
- type: z.enum(["github", "web", "pdf", "local", "slack", "video"]).default("github"),
5575
+ type: z.enum(["github", "web", "playwright", "pdf", "local", "slack", "video"]).default("github"),
5074
5576
  name: z.string().optional(),
5075
5577
  auto_index: z.boolean().optional().default(true),
5076
5578
  metadata: z.record(z.string()).optional(),
@@ -5089,6 +5591,8 @@ server.tool(
5089
5591
  path: z.string().optional(),
5090
5592
  glob: z.string().optional(),
5091
5593
  max_files: z.number().optional(),
5594
+ max_pages: z.number().optional(),
5595
+ extract_mode: z.enum(["text", "structured", "markdown"]).optional(),
5092
5596
  workspace_id: z.string().optional(),
5093
5597
  channel_ids: z.array(z.string()).optional(),
5094
5598
  since: z.string().optional(),
@@ -5127,6 +5631,8 @@ server.tool(
5127
5631
  path: input.path,
5128
5632
  glob: input.glob,
5129
5633
  max_files: input.max_files,
5634
+ max_pages: input.max_pages,
5635
+ extract_mode: input.extract_mode,
5130
5636
  workspace_id: input.workspace_id,
5131
5637
  channel_ids: input.channel_ids,
5132
5638
  since: input.since,
@@ -5165,7 +5671,7 @@ server.tool(
5165
5671
  );
5166
5672
  server.tool(
5167
5673
  "context.add_text",
5168
- "Add text content to a project's knowledge base.",
5674
+ "Compatibility learning tool. Add text content to a project's knowledge base. Prefer `learn` for new integrations.",
5169
5675
  {
5170
5676
  project: z.string().optional().describe("Project name or slug"),
5171
5677
  title: z.string().describe("Title for this content"),
@@ -5198,7 +5704,7 @@ server.tool(
5198
5704
  );
5199
5705
  server.tool(
5200
5706
  "context.add_document",
5201
- "Ingest a document into project knowledge. Supports plain text and video URLs.",
5707
+ "Compatibility learning tool. Ingest a document into project knowledge. Supports plain text and video URLs. Prefer `learn` for new integrations.",
5202
5708
  {
5203
5709
  project: z.string().optional().describe("Project name or slug"),
5204
5710
  source_type: z.enum(["text", "video"]).default("text"),
@@ -5303,7 +5809,7 @@ server.tool(
5303
5809
  );
5304
5810
  server.tool(
5305
5811
  "memory.ingest_conversation",
5306
- "Extract memories from a conversation session. Automatically handles disambiguation, temporal grounding, and relation detection.",
5812
+ "Compatibility learning tool. Extract memories from a conversation session. Prefer `learn` for new integrations.",
5307
5813
  {
5308
5814
  project: z.string().optional().describe("Project name or slug"),
5309
5815
  session_id: z.string().describe("Session identifier"),
@@ -5568,6 +6074,7 @@ server.tool(
5568
6074
  return { content: [{ type: "text", text: "Error: target.memory_id or target.query is required." }] };
5569
6075
  }
5570
6076
  const affectedIds = [];
6077
+ let queryResolution = null;
5571
6078
  const now = (/* @__PURE__ */ new Date()).toISOString();
5572
6079
  const actor = process.env.WHISPER_AGENT_ID || process.env.USERNAME || "api_key_principal";
5573
6080
  const resolvedProject = await resolveProjectRef(project);
@@ -5626,24 +6133,18 @@ server.tool(
5626
6133
  project: resolvedProject,
5627
6134
  query: target.query || "",
5628
6135
  top_k: 25,
5629
- include_relations: false
5630
- });
5631
- const normalizedQuery = String(target.query || "").trim().toLowerCase();
5632
- const exactMatches = (search.results || []).filter((r) => {
5633
- const memory = r?.memory || r;
5634
- const content = String(memory?.content || "").trim().toLowerCase();
5635
- const memoryId = String(memory?.id || "").trim().toLowerCase();
5636
- const metadata = memory?.metadata || {};
5637
- const normalizedContent = String(metadata?.normalized_content || "").trim().toLowerCase();
5638
- const canonicalContent = String(metadata?.canonical_content || "").trim().toLowerCase();
5639
- return memoryId === normalizedQuery || content === normalizedQuery || normalizedContent === normalizedQuery || canonicalContent === normalizedQuery;
6136
+ include_relations: false,
6137
+ include_pending: true
5640
6138
  });
5641
- const memoryIds = exactMatches.map((r) => String(r?.memory?.id || "")).filter(Boolean);
6139
+ const resolved = resolveForgetQueryCandidates(search, target.query || "");
6140
+ queryResolution = resolved.resolved_by;
6141
+ const memoryIds = resolved.memory_ids;
5642
6142
  if (memoryIds.length === 0) {
5643
6143
  const payload2 = {
5644
6144
  status: "completed",
5645
6145
  affected_ids: affectedIds,
5646
- warning: "Query did not resolve to an exact memory match. No memories were changed."
6146
+ warning: resolved.warning || "Query did not resolve to a reliable memory match. No memories were changed.",
6147
+ resolved_by: resolved.resolved_by
5647
6148
  };
5648
6149
  return { content: [{ type: "text", text: JSON.stringify(payload2, null, 2) }] };
5649
6150
  }
@@ -5665,6 +6166,7 @@ server.tool(
5665
6166
  const payload = {
5666
6167
  status: "completed",
5667
6168
  affected_ids: affectedIds,
6169
+ ...queryResolution ? { resolved_by: queryResolution } : {},
5668
6170
  audit: {
5669
6171
  audit_id: audit.audit_id,
5670
6172
  actor: audit.actor,
@@ -5959,30 +6461,45 @@ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", ".next"
5959
6461
  var CODE_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "cpp", "c", "cs", "rb", "php", "swift", "kt", "sql", "prisma", "graphql", "json", "yaml", "yml", "toml", "env"]);
5960
6462
  function extractSignature(filePath, content) {
5961
6463
  const lines = content.split("\n");
5962
- const signature = [`// File: ${filePath}`];
6464
+ const signature = /* @__PURE__ */ new Set([`// File: ${filePath}`]);
6465
+ for (const segment of filePath.split(/[\\/._-]+/).filter(Boolean)) {
6466
+ signature.add(`path:${segment}`);
6467
+ }
5963
6468
  const head = lines.slice(0, 60);
5964
6469
  for (const line of head) {
5965
6470
  const trimmed = line.trim();
5966
6471
  if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
6472
+ const urlMatches = trimmed.match(/https?:\/\/[^\s"'`]+/g);
6473
+ if (urlMatches) {
6474
+ for (const match of urlMatches.slice(0, 2)) signature.add(`url:${match.slice(0, 120)}`);
6475
+ }
6476
+ const routeMatches = trimmed.match(/\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{2,}/g);
6477
+ if (routeMatches) {
6478
+ for (const match of routeMatches.slice(0, 3)) signature.add(`route:${match.slice(0, 120)}`);
6479
+ }
5967
6480
  if (/^(import|from|require|use |pub use )/.test(trimmed)) {
5968
- signature.push(trimmed.slice(0, 120));
6481
+ signature.add(trimmed.slice(0, 120));
5969
6482
  continue;
5970
6483
  }
5971
6484
  if (/^(export|async function|function|class|interface|type |const |let |def |pub fn |fn |struct |impl |enum )/.test(trimmed)) {
5972
- signature.push(trimmed.slice(0, 120));
6485
+ signature.add(trimmed.slice(0, 120));
5973
6486
  continue;
5974
6487
  }
5975
6488
  if (trimmed.startsWith("@") || trimmed.startsWith("#[")) {
5976
- signature.push(trimmed.slice(0, 80));
6489
+ signature.add(trimmed.slice(0, 80));
5977
6490
  }
5978
6491
  }
5979
6492
  for (const line of lines.slice(60)) {
5980
6493
  const trimmed = line.trim();
5981
6494
  if (/^(export (default |async )?function|export (default )?class|export const|export type|export interface|async function|function |class |def |pub fn |fn )/.test(trimmed)) {
5982
- signature.push(trimmed.slice(0, 120));
6495
+ signature.add(trimmed.slice(0, 120));
6496
+ continue;
6497
+ }
6498
+ if (/^[A-Za-z0-9_$]+\s*[:=]\s*["'`][^"'`]{3,}["'`]/.test(trimmed)) {
6499
+ signature.add(trimmed.slice(0, 120));
5983
6500
  }
5984
6501
  }
5985
- return signature.join("\n").slice(0, 2e3);
6502
+ return Array.from(signature).join("\n").slice(0, 2500);
5986
6503
  }
5987
6504
  server.tool(
5988
6505
  "code.search_semantic",
@@ -5996,91 +6513,21 @@ server.tool(
5996
6513
  max_files: z.number().optional().default(150).describe("Max files to scan. For large codebases, narrow with file_types instead of raising this.")
5997
6514
  },
5998
6515
  async ({ query, path: searchPath, file_types, top_k, threshold, max_files }) => {
5999
- const rootPath = searchPath || process.cwd();
6000
- const allowedExts = file_types ? new Set(file_types) : CODE_EXTENSIONS;
6001
- const files = [];
6002
- function collect(dir) {
6003
- if (files.length >= (max_files ?? 300)) return;
6004
- let entries;
6005
- try {
6006
- entries = readdirSync(dir, { withFileTypes: true });
6007
- } catch {
6008
- return;
6009
- }
6010
- for (const entry of entries) {
6011
- if (files.length >= (max_files ?? 300)) break;
6012
- if (SKIP_DIRS.has(entry.name)) continue;
6013
- const full = join(dir, entry.name);
6014
- if (entry.isDirectory()) {
6015
- collect(full);
6016
- } else if (entry.isFile()) {
6017
- const ext = extname(entry.name).replace(".", "");
6018
- if (allowedExts.has(ext)) files.push(full);
6019
- }
6020
- }
6021
- }
6022
- collect(rootPath);
6023
- if (files.length === 0) {
6024
- return { content: [{ type: "text", text: `No code files found in ${rootPath}` }] };
6025
- }
6026
- const documents = [];
6027
- for (const filePath of files) {
6028
- try {
6029
- const stat = statSync(filePath);
6030
- if (stat.size > 500 * 1024) continue;
6031
- const content = readFileSync(filePath, "utf-8");
6032
- const relPath = relative(rootPath, filePath);
6033
- const signature = extractSignature(relPath, content);
6034
- documents.push({ id: relPath, content: signature });
6035
- } catch {
6036
- }
6037
- }
6038
- if (documents.length === 0) {
6039
- return { content: [{ type: "text", text: "Could not read any files." }] };
6040
- }
6041
- let response;
6042
6516
  try {
6043
- response = await whisper.semanticSearch({
6044
- query,
6045
- documents,
6046
- top_k: top_k ?? 10,
6047
- threshold: threshold ?? 0.2
6517
+ return primaryToolSuccess({
6518
+ ...await runSearchCodeTool({
6519
+ query,
6520
+ path: searchPath,
6521
+ file_types,
6522
+ top_k,
6523
+ threshold,
6524
+ max_files
6525
+ }),
6526
+ tool: "code.search_semantic"
6048
6527
  });
6049
6528
  } catch (error) {
6050
- return { content: [{ type: "text", text: `Semantic search failed: ${error.message}` }] };
6051
- }
6052
- if (!response.results || response.results.length === 0) {
6053
- return { content: [{ type: "text", text: `No semantically relevant files found for: "${query}"
6054
-
6055
- Searched ${documents.length} files in ${rootPath}.
6056
-
6057
- Try lowering the threshold or rephrasing your query.` }] };
6058
- }
6059
- const lines = [
6060
- `Semantic search: "${query}"`,
6061
- `Searched ${documents.length} files \u2192 ${response.results.length} relevant (${response.latency_ms}ms)
6062
- `
6063
- ];
6064
- for (const result of response.results) {
6065
- lines.push(`\u{1F4C4} ${result.id} (score: ${result.score})`);
6066
- if (result.snippet) {
6067
- lines.push(` ${result.snippet}`);
6068
- }
6069
- if (result.score > 0.5) {
6070
- try {
6071
- const fullPath = join(rootPath, result.id);
6072
- const content = readFileSync(fullPath, "utf-8");
6073
- const excerpt = content.split("\n").slice(0, 30).join("\n");
6074
- lines.push(`
6075
- \`\`\`
6076
- ${excerpt}
6077
- \`\`\``);
6078
- } catch {
6079
- }
6080
- }
6081
- lines.push("");
6529
+ return primaryToolError(`Semantic search failed: ${error.message}`);
6082
6530
  }
6083
- return { content: [{ type: "text", text: lines.join("\n") }] };
6084
6531
  }
6085
6532
  );
6086
6533
  function* walkDir(dir, fileTypes) {
@@ -6397,72 +6844,15 @@ server.tool(
6397
6844
  max_files: z.number().optional().default(150)
6398
6845
  },
6399
6846
  async ({ query, path, file_types, top_k, threshold, max_files }) => {
6400
- const rootPath = path || process.cwd();
6401
- const allowedExts = file_types ? new Set(file_types) : CODE_EXTENSIONS;
6402
- const files = [];
6403
- function collect(dir) {
6404
- if (files.length >= (max_files ?? 150)) return;
6405
- let entries;
6406
- try {
6407
- entries = readdirSync(dir, { withFileTypes: true });
6408
- } catch {
6409
- return;
6410
- }
6411
- for (const entry of entries) {
6412
- if (files.length >= (max_files ?? 150)) break;
6413
- if (SKIP_DIRS.has(entry.name)) continue;
6414
- const full = join(dir, entry.name);
6415
- if (entry.isDirectory()) collect(full);
6416
- else if (entry.isFile()) {
6417
- const ext = extname(entry.name).replace(".", "");
6418
- if (allowedExts.has(ext)) files.push(full);
6419
- }
6420
- }
6421
- }
6422
- collect(rootPath);
6423
- if (files.length === 0) {
6424
- return primaryToolSuccess({
6425
- tool: "search_code",
6426
- query,
6427
- path: rootPath,
6428
- results: [],
6429
- count: 0
6430
- });
6431
- }
6432
- const documents = [];
6433
- for (const filePath of files) {
6434
- try {
6435
- const stat = statSync(filePath);
6436
- if (stat.size > 500 * 1024) continue;
6437
- const content = readFileSync(filePath, "utf-8");
6438
- const relPath = relative(rootPath, filePath);
6439
- documents.push({ id: relPath, content: extractSignature(relPath, content) });
6440
- } catch {
6441
- }
6442
- }
6443
6847
  try {
6444
- const response = await whisper.semanticSearch({
6848
+ return primaryToolSuccess(await runSearchCodeTool({
6445
6849
  query,
6446
- documents,
6447
- top_k: top_k ?? 10,
6448
- threshold: threshold ?? 0.2
6449
- });
6450
- if (!response.results?.length) {
6451
- return primaryToolSuccess({
6452
- tool: "search_code",
6453
- query,
6454
- path: rootPath,
6455
- results: [],
6456
- count: 0
6457
- });
6458
- }
6459
- return primaryToolSuccess({
6460
- tool: "search_code",
6461
- query,
6462
- path: rootPath,
6463
- results: response.results,
6464
- count: response.results.length
6465
- });
6850
+ path,
6851
+ file_types,
6852
+ top_k,
6853
+ threshold,
6854
+ max_files
6855
+ }));
6466
6856
  } catch (error) {
6467
6857
  return primaryToolError(`Semantic search failed: ${error.message}`);
6468
6858
  }
@@ -6610,7 +7000,7 @@ server.tool(
6610
7000
  {
6611
7001
  action: z.enum(["source", "workspace"]).default("source"),
6612
7002
  project: z.string().optional(),
6613
- type: z.enum(["github", "web", "pdf", "local", "slack", "video"]).optional(),
7003
+ type: z.enum(["github", "web", "playwright", "pdf", "local", "slack", "video"]).optional(),
6614
7004
  name: z.string().optional(),
6615
7005
  owner: z.string().optional(),
6616
7006
  repo: z.string().optional(),
@@ -6766,25 +7156,55 @@ server.tool(
6766
7156
  );
6767
7157
  server.tool(
6768
7158
  "learn",
6769
- "Learn content or connect a source so it becomes retrievable later. Use this for docs, URLs, repos, files, or local paths.",
7159
+ "Unified learning tool for conversation memory, text ingestion, and source indexing. Prefer this over the older learning-adjacent compatibility tools.",
6770
7160
  {
7161
+ mode: z.enum(["conversation", "text", "source"]).describe("What kind of learning to perform"),
6771
7162
  project: z.string().optional(),
7163
+ user_id: z.string().optional().describe("Optional end-user identity for conversation learning"),
7164
+ session_id: z.string().optional().describe("Session identifier for conversation learning"),
7165
+ messages: z.array(z.object({
7166
+ role: z.string(),
7167
+ content: z.string(),
7168
+ timestamp: z.string().optional()
7169
+ })).optional().describe("Conversation messages to learn from"),
7170
+ title: z.string().optional().describe("Title for text learning"),
6772
7171
  content: z.string().optional().describe("Inline text content to ingest"),
6773
- title: z.string().optional().describe("Title for inline content"),
7172
+ metadata: z.record(z.any()).optional(),
7173
+ tags: z.array(z.string()).optional(),
7174
+ namespace: z.string().optional(),
7175
+ type: z.enum(["github", "web", "playwright", "pdf", "local", "slack", "video"]).optional().describe("Source type when mode=source"),
6774
7176
  url: z.string().optional().describe("URL to learn from"),
6775
7177
  owner: z.string().optional().describe("GitHub owner"),
6776
7178
  repo: z.string().optional().describe("GitHub repository"),
6777
7179
  branch: z.string().optional(),
7180
+ paths: z.array(z.string()).optional(),
6778
7181
  path: z.string().optional().describe("Local path to learn from"),
6779
7182
  file_path: z.string().optional().describe("Single file path to learn from"),
6780
7183
  name: z.string().optional().describe("Optional source name"),
6781
- metadata: z.record(z.string()).optional(),
6782
- ingestion_profile: z.enum(["auto", "repo", "web_docs", "pdf_layout", "video_transcript", "plain_text"]).optional(),
6783
- strategy_override: z.enum(["fixed", "recursive", "semantic", "hierarchical", "adaptive"]).optional(),
6784
- profile_config: z.record(z.any()).optional(),
6785
- max_files: z.number().optional(),
6786
- glob: z.string().optional(),
6787
- crawl_depth: z.number().optional()
7184
+ channel_ids: z.array(z.string()).optional(),
7185
+ since: z.string().optional(),
7186
+ token: z.string().optional(),
7187
+ auth_ref: z.string().optional(),
7188
+ platform: z.enum(["youtube", "loom", "generic"]).optional(),
7189
+ language: z.string().optional(),
7190
+ options: z.object({
7191
+ async: z.boolean().optional(),
7192
+ auto_index: z.boolean().optional(),
7193
+ ingestion_profile: z.enum(["auto", "repo", "web_docs", "pdf_layout", "video_transcript", "plain_text"]).optional(),
7194
+ strategy_override: z.enum(["fixed", "recursive", "semantic", "hierarchical", "adaptive"]).optional(),
7195
+ profile_config: z.record(z.any()).optional(),
7196
+ crawl_depth: z.number().optional(),
7197
+ include_paths: z.array(z.string()).optional(),
7198
+ exclude_paths: z.array(z.string()).optional(),
7199
+ glob: z.string().optional(),
7200
+ max_files: z.number().optional(),
7201
+ max_pages: z.number().optional(),
7202
+ extract_mode: z.enum(["text", "structured", "markdown"]).optional(),
7203
+ workspace_id: z.string().optional(),
7204
+ allow_stt_fallback: z.boolean().optional(),
7205
+ max_duration_minutes: z.number().optional(),
7206
+ max_chunks: z.number().optional()
7207
+ }).optional()
6788
7208
  },
6789
7209
  async (input) => {
6790
7210
  try {
@@ -6867,8 +7287,14 @@ if (process.argv[1] && /server\.(mjs|cjs|js|ts)$/.test(process.argv[1])) {
6867
7287
  main().catch(console.error);
6868
7288
  }
6869
7289
  export {
7290
+ canonicalizeWorkspacePath,
7291
+ chooseWorkspaceProjectSource,
7292
+ classifyWorkspaceHealth,
6870
7293
  createMcpServer,
6871
7294
  createWhisperMcpClient,
6872
7295
  createWhisperMcpRuntimeClient,
6873
- renderScopedMcpConfig
7296
+ extractSignature,
7297
+ renderScopedMcpConfig,
7298
+ resolveForgetQueryCandidates,
7299
+ runSearchCodeSearch
6874
7300
  };