diff --git a/.env.example b/.env.example
index 4b8df20..912b13a 100644
--- a/.env.example
+++ b/.env.example
@@ -270,6 +270,24 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind
# Sandbox MCP 调用 Portal 内部生图入口;容器内通常使用 host.docker.internal。
# MINDSPACE_AGENT_API_BASE_URL=http://127.0.0.1:8081/api
+# ---------------------------------------------------------------------------
+# MindSearch / standalone Deep Search
+# ---------------------------------------------------------------------------
+# Production SearXNG on 103 is bound to the host at 127.0.0.1:20080.
+# Goosed MCP endpoints are automatically rewritten to host.docker.internal.
+# TKMIND_SEARCH_SEARXNG_URL=http://127.0.0.1:20080/search
+# TKMIND_SEARCH_MCP_HOST_GATEWAY=host.docker.internal
+#
+# Deep Search runs as an independently released LaunchAgent on 127.0.0.1:20100.
+# Its production environment is stored separately at:
+# /Users/john/Project/deep-search-runtime/shared/.env
+# TKMIND_DEEP_SEARCH_SECRET=
+# TKMIND_SEARCH_GITHUB_GATEWAY_URL=http://127.0.0.1:20100
+# TKMIND_SEARCH_GITHUB_GATEWAY_SECRET=${TKMIND_DEEP_SEARCH_SECRET}
+#
+# GITHUB_TOKEN must only be set in the standalone Deep Search environment.
+# Do not pass it to goosed MCP extension envs or commit it to Git.
+
# ---------------------------------------------------------------------------
# Memind runtime profile(本地 vs 生产 必须区分)
# 由 scripts/memind-runtime-profile.mjs 在 pnpm dev / server.mjs 启动时应用。
diff --git a/.gitignore b/.gitignore
index 5599414..629cc3f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ ops/node_modules/
dist/
ops/dist/
.runtime/
+.deep-search/
.env
.env.local
diff --git a/admin-routes.mjs b/admin-routes.mjs
index 0a9b58f..debe42d 100644
--- a/admin-routes.mjs
+++ b/admin-routes.mjs
@@ -207,6 +207,15 @@ export function createAdminApi({
if (!mindSearchConfigService?.getRuntimeState) return res.status(503).json({ message: 'MindSearch 配置服务未启用' });
return res.json(await mindSearchConfigService.getRuntimeState());
});
+ adminApi.post('/mindsearch/services/:serviceId/test', requireAdmin, async (req, res) => {
+ if (!mindSearchConfigService?.testService) return res.status(503).json({ message: 'MindSearch 服务测试未启用' });
+ try {
+ return res.json(await mindSearchConfigService.testService(req.params.serviceId));
+ } catch (error) {
+ if (error?.code === 'SEARCH_SERVICE_NOT_FOUND') return res.status(404).json({ message: error.message });
+ throw error;
+ }
+ });
adminApi.get('/memory-v2/runtime', requireAdmin, async (_req, res) => {
if (!memoryV2ConfigService?.getRuntimeState) {
diff --git a/admin-routes.test.mjs b/admin-routes.test.mjs
index c6b8f3b..18c0b90 100644
--- a/admin-routes.test.mjs
+++ b/admin-routes.test.mjs
@@ -165,6 +165,7 @@ test('admin system disclosure policy routes version config through the injected
test('admin MindSearch routes persist only through injected control-plane service', async () => {
const updates = [];
+ const testedServices = [];
const router = createAdminApi({
jsonBody: express.json(), getToken() { return 'token-admin'; },
userAuth: { async getMe() { return { id: 'admin-1', role: 'admin' }; } },
@@ -173,6 +174,7 @@ test('admin MindSearch routes persist only through injected control-plane servic
async getAdminConfig() { return { config: { enabled: false, mode: 'off', providers: { searxng: false, github: false, reader: false } }, source: 'env' }; },
async updateAdminConfig(patch, context) { updates.push({ patch, context }); return { config: { enabled: true, mode: 'shadow', providers: { searxng: true, github: false, reader: false } }, source: 'admin' }; },
async getRuntimeState() { return { effective: false, mode: 'off' }; },
+ async testService(serviceId) { testedServices.push(serviceId); return { ok: true, serviceId, latencyMs: 3 }; },
},
plazaPosts: null, plazaOps: null, wechatAdmin: null, subscriptionService: null,
});
@@ -185,6 +187,9 @@ test('admin MindSearch routes persist only through injected control-plane servic
assert.deepEqual(updates, [{ patch: { enabled: true, mode: 'shadow' }, context: { updatedBy: 'admin-1' } }]);
const runtime = await fetch(`${server.baseUrl}/admin-api/mindsearch/runtime`, { headers: { cookie: 'h5_user_session=token-admin' } });
assert.deepEqual(await runtime.json(), { effective: false, mode: 'off' });
+ const serviceTest = await fetch(`${server.baseUrl}/admin-api/mindsearch/services/searxng/test`, { method: 'POST', headers: { cookie: 'h5_user_session=token-admin' } });
+ assert.deepEqual(await serviceTest.json(), { ok: true, serviceId: 'searxng', latencyMs: 3 });
+ assert.deepEqual(testedServices, ['searxng']);
} finally { await server.close(); }
});
diff --git a/capabilities.mjs b/capabilities.mjs
index 187a91f..3b26844 100644
--- a/capabilities.mjs
+++ b/capabilities.mjs
@@ -48,6 +48,23 @@ export function resolveMindSearchMcpServerPath(overridePath) {
return path.join(path.dirname(fileURLToPath(import.meta.url)), 'tkmind-search-mcp.mjs');
}
+export function resolveMindSearchMcpEndpoint(
+ endpoint,
+ { hostGateway = process.env.TKMIND_SEARCH_MCP_HOST_GATEWAY || 'host.docker.internal' } = {},
+) {
+ const source = String(endpoint ?? '').trim();
+ if (!source) return source;
+ try {
+ const parsed = new URL(source);
+ if (LOOPBACK_PG_HOSTS.has(parsed.hostname)) {
+ parsed.hostname = String(hostGateway || 'host.docker.internal').trim();
+ }
+ return parsed.toString();
+ } catch {
+ return source;
+ }
+}
+
export function resolveExcelMcpServerPath(overridePath) {
const normalized = String(overridePath ?? '').trim();
if (normalized) return normalized;
@@ -401,7 +418,14 @@ function sandboxMcpEnvs(sandboxMcp, mcpTools) {
*/
export function buildAgentExtensionPolicy(
capabilities,
- { unrestricted = false, policies = null, sandboxMcp = null, toolMode = 'chat', mindSearchConfig = null } = {},
+ {
+ unrestricted = false,
+ policies = null,
+ sandboxMcp = null,
+ toolMode = 'chat',
+ mindSearchConfig = null,
+ userId = null,
+ } = {},
) {
if (unrestricted) {
return { extensionOverrides: null, enableContextMemory: true, gooseMode: 'auto' };
@@ -496,7 +520,59 @@ export function buildAgentExtensionPolicy(
}
const searchEnabled = capabilities.search_external && mindSearchConfig?.enabled && mindSearchConfig.mode !== 'off';
if (searchEnabled && (!policies || policies.network_egress !== 'deny')) {
- extensions.push({ type: 'stdio', name: 'tkmind-search', description: 'MindSearch 外部搜索增强(补充能力)', display_name: 'tkmind-search', bundled: false, cmd: resolveSandboxMcpNodeExecPath(process.env.GOOSED_MCP_NODE_PATH), args: [resolveMindSearchMcpServerPath(process.env.TKMIND_SEARCH_MCP_SERVER_PATH)], envs: { TKMIND_SEARCH_ENABLED: '1', TKMIND_SEARCH_MODE: mindSearchConfig.mode, TKMIND_SEARCH_PROVIDER_SEARXNG_ENABLED: mindSearchConfig.providers?.searxng ? '1' : '0', TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED: mindSearchConfig.providers?.github ? '1' : '0', TKMIND_SEARCH_READER_ENABLED: mindSearchConfig.providers?.reader ? '1' : '0', TKMIND_SEARCH_SEARXNG_URL: mindSearchConfig.settings?.searxngEndpoint ?? '', TKMIND_SEARCH_MAX_RESULTS: String(mindSearchConfig.settings?.maxResults ?? 10), TKMIND_SEARCH_TIMEOUT_MS: String(mindSearchConfig.settings?.timeoutMs ?? 8000), TKMIND_SEARCH_READER_MAX_CHARS: String(mindSearchConfig.settings?.readerMaxChars ?? 12000) }, available_tools: ['tkmind_search', 'tkmind_read'] });
+ const mcpServices = (mindSearchConfig.services ?? []).map((service) => ({
+ ...service,
+ endpoint: resolveMindSearchMcpEndpoint(service.endpoint),
+ }));
+ const researchService = mcpServices.find((service) =>
+ service.id === mindSearchConfig.routes?.research && service.adapter === 'research-http');
+ const deepSearchService = mcpServices.find((service) =>
+ service.id === 'deep-search' && service.adapter === 'research-http');
+ const hasResearchService = Boolean(researchService?.enabled);
+ const githubGatewayUrl = resolveMindSearchMcpEndpoint(
+ process.env.TKMIND_SEARCH_GITHUB_GATEWAY_URL
+ || deepSearchService?.endpoint
+ || researchService?.endpoint
+ || '',
+ );
+ extensions.push({
+ type: 'stdio',
+ name: 'tkmind-search',
+ description: 'MindSearch 外部搜索增强(补充能力)',
+ display_name: 'tkmind-search',
+ bundled: false,
+ cmd: resolveSandboxMcpNodeExecPath(process.env.GOOSED_MCP_NODE_PATH),
+ args: [resolveMindSearchMcpServerPath(process.env.TKMIND_SEARCH_MCP_SERVER_PATH)],
+ envs: {
+ TKMIND_SEARCH_ENABLED: '1',
+ TKMIND_SEARCH_MODE: mindSearchConfig.mode,
+ TKMIND_SEARCH_PROVIDER_SEARXNG_ENABLED: mindSearchConfig.providers?.searxng ? '1' : '0',
+ TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED: mindSearchConfig.providers?.github ? '1' : '0',
+ TKMIND_SEARCH_READER_ENABLED: mindSearchConfig.providers?.reader ? '1' : '0',
+ TKMIND_SEARCH_SEARXNG_URL: resolveMindSearchMcpEndpoint(
+ mindSearchConfig.settings?.searxngEndpoint ?? '',
+ ),
+ TKMIND_SEARCH_MAX_RESULTS: String(mindSearchConfig.settings?.maxResults ?? 10),
+ TKMIND_SEARCH_TIMEOUT_MS: String(mindSearchConfig.settings?.timeoutMs ?? 8000),
+ TKMIND_SEARCH_READER_MAX_CHARS: String(mindSearchConfig.settings?.readerMaxChars ?? 12000),
+ TKMIND_SEARCH_SERVICES_JSON: JSON.stringify(mcpServices),
+ TKMIND_SEARCH_ROUTES_JSON: JSON.stringify(mindSearchConfig.routes ?? {}),
+ TKMIND_SEARCH_USER_ID: userId ? String(userId) : '',
+ TKMIND_DEEP_SEARCH_SECRET: process.env.TKMIND_DEEP_SEARCH_SECRET ?? '',
+ TKMIND_SEARCH_GITHUB_GATEWAY_URL: githubGatewayUrl,
+ TKMIND_SEARCH_GITHUB_GATEWAY_SECRET:
+ process.env.TKMIND_SEARCH_GITHUB_GATEWAY_SECRET
+ ?? process.env.TKMIND_DEEP_SEARCH_SECRET
+ ?? '',
+ },
+ available_tools: [
+ 'tkmind_search',
+ 'tkmind_read',
+ ...(hasResearchService
+ ? ['tkmind_research', 'tkmind_research_status', 'tkmind_research_cancel']
+ : []),
+ ],
+ });
}
if (capabilities.excel_analysis) {
const excelWorkspaceRoot = resolveSandboxMcpLocalRoot(sandboxMcp);
diff --git a/capabilities.test.mjs b/capabilities.test.mjs
index 84bf9cd..be36471 100644
--- a/capabilities.test.mjs
+++ b/capabilities.test.mjs
@@ -8,6 +8,7 @@ import {
clampUserCapabilities,
DEFAULT_USER_CAPABILITIES,
normalizeCapabilityPatch,
+ resolveMindSearchMcpEndpoint,
resolveSandboxMcpNodeExecPath,
resolveSandboxMcpServerPath,
resolveSandboxMcpUserDataPgUrl,
@@ -53,6 +54,17 @@ test('resolveSandboxMcpUserDataPgUrl preserves native URLs and honors an explici
);
});
+test('resolveMindSearchMcpEndpoint rewrites host loopback for goosed containers', () => {
+ assert.equal(
+ resolveMindSearchMcpEndpoint('http://127.0.0.1:20080/search'),
+ 'http://host.docker.internal:20080/search',
+ );
+ assert.equal(
+ resolveMindSearchMcpEndpoint('https://search.internal/query'),
+ 'https://search.internal/query',
+ );
+});
+
test('resolveSandboxMcpNodeExecPath honors container-path override without host fs checks', () => {
assert.equal(resolveSandboxMcpNodeExecPath('/usr/local/bin/node'), '/usr/local/bin/node');
assert.equal(resolveSandboxMcpNodeExecPath(''), process.execPath);
diff --git a/deep-search-engine.mjs b/deep-search-engine.mjs
new file mode 100644
index 0000000..2ffdbcf
--- /dev/null
+++ b/deep-search-engine.mjs
@@ -0,0 +1,802 @@
+import dns from 'node:dns/promises';
+import { EventEmitter } from 'node:events';
+import net from 'node:net';
+import { randomUUID } from 'node:crypto';
+import { searchSearxng } from './mindsearch-providers.mjs';
+
+const DEPTH_PROFILES = Object.freeze({
+ quick: { goals: 3, rounds: 2, resultsPerQuery: 6, maxSources: 8 },
+ standard: { goals: 4, rounds: 2, resultsPerQuery: 7, maxSources: 14 },
+ deep: { goals: 6, rounds: 3, resultsPerQuery: 10, maxSources: 24 },
+});
+
+const TRACKING_PARAMS = /^(utm_|fbclid$|gclid$|mc_)/i;
+
+function clamp(value, min, max) {
+ return Math.max(min, Math.min(max, value));
+}
+
+function throwIfAborted(signal) {
+ if (signal?.aborted) {
+ const error = new Error('Research task was cancelled');
+ error.name = 'AbortError';
+ throw error;
+ }
+}
+
+function decodeHtml(value) {
+ return value
+ .replace(/ /gi, ' ')
+ .replace(/&/gi, '&')
+ .replace(/</gi, '<')
+ .replace(/>/gi, '>')
+ .replace(/"/gi, '"')
+ .replace(/'|'/gi, "'")
+ .replace(/—/gi, '—')
+ .replace(/–/gi, '–')
+ .replace(/…/gi, '…')
+ .replace(/(\d+);/g, (_match, code) => String.fromCodePoint(Number(code)));
+}
+
+export function htmlToPlainText(html) {
+ return decodeHtml(String(html ?? '')
+ .replace(/
Hello & world
'), /Hello & world/);
+});
+
+test('Deep Search reader blocks private destinations and extracts public HTML', async () => {
+ await assert.rejects(
+ () => assertSafePublicUrl('http://127.0.0.1/private'),
+ /Unsafe source URL/,
+ );
+ const document = await readResearchSource('https://example.com/article', {
+ lookup: async () => [{ address: '93.184.216.34', family: 4 }],
+ fetchImpl: async () => new Response(
+ 'ResearchUseful evidence for the report.',
+ { status: 200, headers: { 'content-type': 'text/html' } },
+ ),
+ });
+ assert.equal(document.title, 'Research');
+ assert.match(document.content, /Useful evidence/);
+ assert.doesNotMatch(document.content, /ignore/);
+});
+
+test('Deep Search evidence extraction drops page chrome and HTML attribute noise', () => {
+ const evidence = extractEvidence({
+ title: 'SearXNG privacy',
+ url: 'https://example.com/privacy',
+ content: [
+ 'data-hydro-click="{\\"event_type\\":\\"authentication.click\\"}" class="HeaderMenu-link" this is navigation noise that must not become evidence.',
+ 'SearXNG protects search privacy by avoiding user profiling and by proxying requests to multiple upstream search engines without forwarding browser cookies.',
+ ].join('\n'),
+ }, {
+ question: 'How does SearXNG protect privacy?',
+ goal: 'Privacy design',
+ });
+ assert.ok(evidence.length > 0);
+ assert.doesNotMatch(evidence.map((item) => item.claim).join('\n'), /data-hydro|HeaderMenu/);
+});
+
+test('Deep Search executes multi-round research and creates a cited report', async () => {
+ const { engine, store, searches } = createMockEngine();
+ const started = engine.start({ question: '分析 AI Agent 市场', depth: 'standard', userId: 'user-1' });
+ assert.deepEqual(started, {
+ taskId: 'task-complete',
+ status: 'queued',
+ service: 'tkmind-deep-search',
+ });
+ const task = await waitFor(() => {
+ const current = engine.getTask(started.taskId);
+ return current.status === 'completed' ? current : null;
+ });
+ assert.equal(task.progress, 100);
+ assert.equal(task.plan.length, 4);
+ assert.ok(searches.length >= 8);
+ assert.ok(task.sources.length > 0);
+ assert.match(task.report, /^# 分析 AI Agent 市场/m);
+ assert.match(task.report, /\[1\]/);
+ assert.match(task.report, /## 来源/);
+ assert.ok(task.events.some((event) => event.type === 'completed'));
+ assert.equal(store.listMemories({ userId: 'user-1' }).length, 1);
+ store.close();
+});
+
+test('Deep Search uses a configured planner and citation-safe LLM report', async () => {
+ const store = createDeepSearchStore({ databasePath: ':memory:' });
+ let memoryPayload = null;
+ const llmReport = `# Model report
+
+This model-generated analysis is intentionally long enough to pass report validation and is grounded in the supplied evidence. It explains the finding, distinguishes evidence from inference, and preserves the required source citation. [1]
+
+## Sources
+
+1. Primary evidence [1]`;
+ const engine = createDeepSearchEngine({
+ store,
+ idFactory: () => 'task-llm',
+ llmResolver: async ({ providerKeyId, model }) => {
+ assert.equal(providerKeyId, 'provider-key-1');
+ assert.equal(model, 'research-model');
+ return {
+ async plan() {
+ return {
+ research_plan: [{
+ goal: 'Primary evidence',
+ queries: ['official evidence', 'independent evidence'],
+ sources: ['web'],
+ }],
+ };
+ },
+ async synthesize() {
+ return llmReport;
+ },
+ };
+ },
+ memorySink: async (payload) => {
+ memoryPayload = payload;
+ },
+ searchProvider: {
+ name: 'mock',
+ async search() {
+ return [{
+ title: 'Primary source',
+ url: 'https://example.com/primary',
+ snippet: 'Primary evidence supports the tested claim.',
+ source: 'mock',
+ rank: 1,
+ }];
+ },
+ },
+ reader: async (url) => ({
+ url,
+ title: 'Primary source',
+ content: 'Primary evidence supports the tested claim with enough detail for a grounded research report and a verifiable citation.',
+ }),
+ });
+ engine.start({
+ question: 'Evaluate the tested claim',
+ depth: 'quick',
+ userId: 'memory-user',
+ llmProviderKeyId: 'provider-key-1',
+ llmModel: 'research-model',
+ });
+ const task = await waitFor(() => {
+ const current = engine.getTask('task-llm');
+ return current.status === 'completed' ? current : null;
+ });
+ assert.equal(task.report, llmReport);
+ assert.equal(task.llmProviderKeyId, 'provider-key-1');
+ assert.equal(task.llmModel, 'research-model');
+ assert.ok(task.events.some((event) => event.type === 'llm_selected'));
+ assert.equal(memoryPayload.userId, 'memory-user');
+ assert.equal(memoryPayload.taskId, 'task-llm');
+ assert.ok(task.events.some((event) => event.type === 'memory_saved'));
+ store.close();
+});
+
+test('Deep Search portal LLM gateway keeps provider secrets inside the portal', async () => {
+ let request = null;
+ const unauthorized = await executeDeepSearchLlmGateway({
+ llmProviderService: {},
+ expectedSecret: 'expected',
+ providedSecret: 'wrong',
+ input: {},
+ });
+ assert.equal(unauthorized.status, 401);
+ const authorized = await executeDeepSearchLlmGateway({
+ expectedSecret: 'expected',
+ providedSecret: 'expected',
+ input: {
+ providerKeyId: 'key-1',
+ model: 'model-1',
+ messages: [{ role: 'user', content: 'Plan research' }],
+ },
+ llmProviderService: {
+ async createChatCompletion(input) {
+ request = input;
+ return {
+ ok: true,
+ providerKeyId: 'key-1',
+ providerId: 'custom',
+ model: 'model-1',
+ reply: '{"research_plan":[]}',
+ };
+ },
+ },
+ });
+ assert.equal(authorized.status, 200);
+ assert.equal(request.providerKeyId, 'key-1');
+ assert.equal(request.model, 'model-1');
+
+ const resolver = createPortalGatewayResearchLlmResolver({
+ endpoint: 'http://portal.local/api/internal/deep-search/llm',
+ secret: 'expected',
+ fetchImpl: async (_url, options) => {
+ const body = JSON.parse(options.body);
+ assert.equal(options.headers.authorization, 'Bearer expected');
+ assert.equal(body.providerKeyId, 'key-1');
+ return new Response(JSON.stringify({ ok: true, reply: '{"research_plan":[]}' }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+ },
+ });
+ assert.equal(resolver.configured, true);
+ const resolved = await resolver({ providerKeyId: 'key-1', model: 'model-1' });
+ assert.deepEqual(await resolved.plan('Question', 'quick'), { research_plan: [] });
+});
+
+test('Deep Search supports cancellation while a provider is running', async () => {
+ const store = createDeepSearchStore({ databasePath: ':memory:' });
+ const engine = createDeepSearchEngine({
+ store,
+ idFactory: () => 'task-cancel',
+ searchProvider: {
+ name: 'slow',
+ search(_query, { signal }) {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => resolve([]), 2000);
+ signal.addEventListener('abort', () => {
+ clearTimeout(timeout);
+ const error = new Error('cancelled');
+ error.name = 'AbortError';
+ reject(error);
+ }, { once: true });
+ });
+ },
+ },
+ });
+ engine.start({ question: 'A long-running investigation', depth: 'deep' });
+ await waitFor(() => engine.getTask('task-cancel').status === 'researching');
+ const cancelling = engine.cancel('task-cancel');
+ assert.equal(cancelling.status, 'cancelling');
+ const cancelled = await waitFor(() => {
+ const current = engine.getTask('task-cancel');
+ return current.status === 'cancelled' ? current : null;
+ });
+ assert.equal(cancelled.phase, 'cancelled');
+ store.close();
+});
+
+test('Deep Search HTTP service exposes health, async start, status, search, and auth', async (t) => {
+ const { engine, store } = createMockEngine({ id: 'task-http' });
+ let githubRequest;
+ const server = createDeepSearchHttpServer({
+ engine,
+ secret: 'local-secret',
+ githubToken: 'github-secret-token',
+ githubSearch: async (query, options) => {
+ githubRequest = { query, options };
+ return [{
+ title: 'tkmind/code:server.mjs',
+ url: 'https://github.com/tkmind/code/blob/main/server.mjs',
+ snippet: 'server',
+ source: 'github',
+ rank: 1,
+ }];
+ },
+ logger: { warn() {} },
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ t.after(async () => {
+ await new Promise((resolve) => server.close(resolve));
+ store.close();
+ });
+ const address = server.address();
+ const base = `http://127.0.0.1:${address.port}`;
+ const health = await fetch(`${base}/health`).then((response) => response.json());
+ assert.equal(health.ok, true);
+ assert.equal(health.providers.github.configured, true);
+ assert.equal((await fetch(`${base}/v1/research`)).status, 401);
+ const headers = { 'content-type': 'application/json', 'x-secret-key': 'local-secret' };
+ const github = await fetch(`${base}/v1/providers/github/search`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ query: 'repo:tkmind/code server', limit: 3 }),
+ }).then((response) => response.json());
+ assert.equal(github.results.length, 1);
+ assert.equal(githubRequest.options.token, 'github-secret-token');
+ assert.equal(githubRequest.options.limit, 3);
+ const search = await fetch(`${base}/v1/search`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ query: 'Deep Search', limit: 2 }),
+ }).then((response) => response.json());
+ assert.equal(search.results.length, 2);
+ const started = await fetch(`${base}/v1/research`, {
+ method: 'POST',
+ headers: { ...headers, 'x-user-id': 'user-a' },
+ body: JSON.stringify({
+ question: 'Evaluate Deep Search',
+ depth: 'quick',
+ llm_provider_key_id: 'key-http',
+ llm_model: 'model-http',
+ }),
+ }).then((response) => response.json());
+ assert.equal(started.task_id, 'task-http');
+ assert.equal((await fetch(`${base}/v1/research/${started.task_id}`, {
+ headers: { ...headers, 'x-user-id': 'user-b' },
+ })).status, 403);
+ const completed = await waitFor(async () => {
+ const response = await fetch(`${base}/v1/research/${started.task_id}`, {
+ headers: { ...headers, 'x-user-id': 'user-a' },
+ });
+ const task = await response.json();
+ return task.status === 'completed' ? task : null;
+ });
+ assert.equal(completed.progress, 100);
+ assert.equal(completed.llmProviderKeyId, 'key-http');
+ assert.equal(completed.llmModel, 'model-http');
+ assert.ok(completed.sources.every((source) => !Object.hasOwn(source, 'content')));
+});
diff --git a/docs/deep-search-service.md b/docs/deep-search-service.md
new file mode 100644
index 0000000..ea062b8
--- /dev/null
+++ b/docs/deep-search-service.md
@@ -0,0 +1,152 @@
+# TKMind Deep Search Service
+
+TKMind Deep Search is a local-first, independently deployable research orchestrator. MindSearch treats it as a `research-http` service, in the same way that SearXNG is registered as a search provider.
+
+The standalone runtime uses the built-in `node:sqlite` module and therefore requires a Node.js release that provides `DatabaseSync` (Node 22.5 or newer; the local verification used Node 26).
+
+## Runtime flow
+
+1. Validate and persist the task.
+2. Build a depth-aware research plan.
+3. Run bounded, multi-round searches through SearXNG.
+4. Canonicalize, deduplicate, and rerank source URLs.
+5. Resolve DNS and block private destinations before reading public pages.
+6. Extract evidence and remove duplicate claims.
+7. Generate a Markdown report with numbered citations.
+8. Persist task state, sources, events, report, and research memory in SQLite.
+
+The planner and report writer use an optional model selected from TKMind's Unified Model Center. MindSearch stores only the Provider Key ID and model name. Deep Search calls a secret-protected Portal gateway, and the provider API key never leaves the Portal process. If the selected model is unavailable, deterministic planning and citation-safe extractive reporting keep the service operational.
+
+`createDeepSearchEngine` also accepts an explicit `memorySink` callback. It is disabled by default; an in-process TKMind integration can inject `memoryV2.write` without giving the standalone service an unrestricted outbound memory endpoint. Memory sink failures are fail-open and recorded as task events.
+
+## Local run
+
+```bash
+TKMIND_DEEP_SEARCH_SEARXNG_URL=http://127.0.0.1:8080/search \
+TKMIND_DEEP_SEARCH_DB=.deep-search/research.sqlite \
+TKMIND_DEEP_SEARCH_SECRET=local-shared-secret \
+npm run dev:deep-search
+```
+
+The server listens on `127.0.0.1:20100` by default.
+
+On production host `103`, SearXNG is exposed at
+`http://127.0.0.1:20080/search`. Goosed containers reach host services through
+`host.docker.internal`; Portal rewrites loopback MindSearch service endpoints
+only when constructing the MCP extension environment.
+
+## API
+
+| Method | Path | Purpose |
+| --- | --- | --- |
+| `GET` | `/health` | Runtime health and task counts |
+| `POST` | `/v1/search` | Synchronous normalized search |
+| `POST` | `/v1/research` | Start an asynchronous research task |
+| `GET` | `/v1/research` | List recent tasks |
+| `GET` | `/v1/research/:id` | Get progress, evidence metadata, and report |
+| `GET` | `/v1/research/:id/events` | Stream progress as server-sent events |
+| `DELETE` | `/v1/research/:id` | Cancel a queued or running task |
+
+Start request:
+
+```json
+{
+ "question": "分析 AI Agent 市场",
+ "depth": "standard",
+ "userId": "optional-user-id"
+}
+```
+
+Start response:
+
+```json
+{
+ "task_id": "uuid",
+ "status": "queued",
+ "service": "tkmind-deep-search"
+}
+```
+
+## Configuration
+
+| Variable | Default | Meaning |
+| --- | --- | --- |
+| `TKMIND_DEEP_SEARCH_HOST` | `127.0.0.1` | Bind host |
+| `TKMIND_DEEP_SEARCH_PORT` | `20100` | HTTP port |
+| `TKMIND_DEEP_SEARCH_DB` | `.deep-search/research.sqlite` | SQLite database |
+| `TKMIND_DEEP_SEARCH_SEARXNG_URL` | `http://127.0.0.1:8080/search` | Upstream search endpoint |
+| `TKMIND_DEEP_SEARCH_SECRET` | empty | Optional `X-Secret-Key` required by non-health routes |
+| `TKMIND_DEEP_SEARCH_LLM_GATEWAY_URL` | `http://127.0.0.1:8081/api/internal/deep-search/llm` | Portal LLM gateway |
+| `TKMIND_DEEP_SEARCH_LLM_GATEWAY_SECRET` | `TKMIND_DEEP_SEARCH_SECRET` | Portal gateway bearer secret |
+| `GITHUB_TOKEN` | empty | GitHub code-search token, stored only in the standalone service environment |
+
+## Isolation and safety
+
+- The default bind address is loopback only.
+- An optional service secret protects all non-health endpoints.
+- The same secret can authenticate the local service to the Portal LLM gateway.
+- MCP forwards the current TKMind user ID as `X-User-Id`.
+- Task status, event, and cancellation routes enforce user ownership when a user ID is present.
+- Source reading rejects credentials, loopback, link-local, private, multicast, and private-DNS destinations.
+- Redirect targets are resolved and validated again.
+- Request bodies, result counts, source content, timeouts, and task depths are bounded.
+- GitHub Code Search is proxied by Deep Search so the provider token is never
+ copied into goosed extension environments.
+
+## Production runtime on 103
+
+Deep Search is released independently from Portal and `memindadm`:
+
+```bash
+npm run build:deep-search-runtime
+CONFIRMED_CI_SHA="$(git rev-parse HEAD)" \
+ bash scripts/release-deep-search-runtime-prod.sh
+```
+
+The production layout is:
+
+```text
+/Users/john/Project/deep-search-runtime/
+├── current -> releases//
+├── releases//
+├── shared/.env
+├── data/research.sqlite
+├── logs/deep-search.log
+└── backups//
+```
+
+The release workflow requires a clean `main` that exactly matches
+`origin/main`, a matching confirmed CI SHA, artifact checksum verification,
+SQLite online backup, LaunchAgent rollback, host health/search probes, and a
+Goose-container reachability probe. It never releases or restarts Portal or
+`memindadm`.
+
+The installer creates a random service secret on first install. Add a newly
+issued, least-privilege GitHub token to `shared/.env` only after revoking any
+token that has appeared in chat or logs.
+
+## MindSearch registration
+
+The built-in disabled service is:
+
+```json
+{
+ "id": "deep-search",
+ "adapter": "research-http",
+ "endpoint": "http://127.0.0.1:20100",
+ "healthPath": "/health",
+ "enabled": false,
+ "llmProviderKeyId": "",
+ "llmModel": ""
+}
+```
+
+In `memindadm` → `MindSearch` → `TKMind Deep Search`, select an optional active Provider configuration and one of its models. The list comes directly from Unified Model Center. The `测试 LLM` action checks that exact Provider/model combination before saving the MindSearch configuration. Selecting “不使用 LLM(确定性降级)” leaves both fields empty.
+
+After local verification, enable the service and MindSearch, then select `deep-search` for the `research` route. New Goose sessions receive:
+
+- `tkmind_research`
+- `tkmind_research_status`
+- `tkmind_research_cancel`
+
+Enabling or publishing this service to production is a separate release action and is not performed by local development or tests.
diff --git a/mindsearch-config.mjs b/mindsearch-config.mjs
index 3585043..021baf5 100644
--- a/mindsearch-config.mjs
+++ b/mindsearch-config.mjs
@@ -1,11 +1,70 @@
const CONFIG_TABLE = 'h5_mindsearch_config';
const CONFIG_SCOPE = 'global';
+const SERVICE_KINDS = new Set(['provider', 'reader', 'orchestrator']);
+const SERVICE_ADAPTERS = new Set(['searxng', 'github', 'reader', 'research-http']);
+const ROUTE_KEYS = ['web', 'news', 'code', 'read', 'research'];
+
+const BUILTIN_SERVICES = Object.freeze([
+ {
+ id: 'searxng',
+ name: 'SearXNG',
+ kind: 'provider',
+ adapter: 'searxng',
+ capabilities: ['search.web', 'search.news'],
+ endpoint: '',
+ healthPath: '/config',
+ enabled: false,
+ timeoutMs: 8000,
+ priority: 100,
+ },
+ {
+ id: 'github',
+ name: 'GitHub Code',
+ kind: 'provider',
+ adapter: 'github',
+ capabilities: ['search.code'],
+ endpoint: 'https://api.github.com/search/code',
+ healthPath: '',
+ enabled: false,
+ timeoutMs: 8000,
+ priority: 90,
+ },
+ {
+ id: 'reader',
+ name: 'Safe Reader',
+ kind: 'reader',
+ adapter: 'reader',
+ capabilities: ['search.read'],
+ endpoint: '',
+ healthPath: '',
+ enabled: false,
+ timeoutMs: 8000,
+ priority: 80,
+ },
+ {
+ id: 'deep-search',
+ name: 'TKMind Deep Search',
+ kind: 'orchestrator',
+ adapter: 'research-http',
+ capabilities: ['search.web', 'research.plan', 'research.execute'],
+ endpoint: 'http://127.0.0.1:20100',
+ healthPath: '/health',
+ enabled: false,
+ timeoutMs: 120000,
+ priority: 100,
+ llmProviderKeyId: '',
+ llmModel: '',
+ },
+]);
+
export const MINDSEARCH_DEFAULT_CONFIG = Object.freeze({
enabled: false,
mode: 'off',
providers: { searxng: false, github: false, reader: false },
settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 },
+ services: BUILTIN_SERVICES,
+ routes: { web: 'searxng', news: 'searxng', code: 'github', read: 'reader', research: 'deep-search' },
});
const clone = (value) => JSON.parse(JSON.stringify(value));
@@ -15,22 +74,199 @@ const bool = (value, fallback = false) => {
return /^(1|true|yes|on)$/i.test(String(value));
};
+const boundedNumber = (value, fallback, min, max) =>
+ Math.max(min, Math.min(max, Number(value ?? fallback) || fallback));
+
+export function isValidSearchServiceEndpoint(value, { allowEmpty = true } = {}) {
+ const endpoint = String(value ?? '').trim();
+ if (!endpoint) return allowEmpty;
+ try {
+ const url = new URL(endpoint);
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return false;
+ return !['0.0.0.0', '::', '[::]', '169.254.169.254'].includes(url.hostname);
+ } catch {
+ return false;
+ }
+}
+
+function normalizeCapabilities(value, fallback = []) {
+ const capabilities = Array.isArray(value) ? value : fallback;
+ return [...new Set(capabilities
+ .map((item) => String(item ?? '').trim())
+ .filter((item) => /^[a-z][a-z0-9_.-]{1,63}$/.test(item)))]
+ .slice(0, 20);
+}
+
+function normalizeService(input = {}, fallback = {}) {
+ const id = String(input.id ?? fallback.id ?? '').trim().toLowerCase();
+ if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(id)) return null;
+ const kind = SERVICE_KINDS.has(input.kind) ? input.kind : (fallback.kind ?? 'provider');
+ const adapter = SERVICE_ADAPTERS.has(input.adapter) ? input.adapter : (fallback.adapter ?? 'searxng');
+ const endpoint = String(input.endpoint ?? fallback.endpoint ?? '').trim();
+ if (!isValidSearchServiceEndpoint(endpoint)) return null;
+ const healthPathValue = String(input.healthPath ?? fallback.healthPath ?? '').trim();
+ const healthPath = healthPathValue && /^\/[^\s]{0,255}$/.test(healthPathValue) ? healthPathValue : '';
+ const llmProviderKeyIdValue = String(input.llmProviderKeyId ?? fallback.llmProviderKeyId ?? '').trim();
+ const llmProviderKeyId = /^[a-zA-Z0-9._:-]{1,128}$/.test(llmProviderKeyIdValue)
+ ? llmProviderKeyIdValue
+ : '';
+ const llmModelValue = String(input.llmModel ?? fallback.llmModel ?? '').trim();
+ const llmModel = llmProviderKeyId && !/[\u0000-\u001f\u007f]/.test(llmModelValue)
+ ? llmModelValue.slice(0, 200)
+ : '';
+ return {
+ id,
+ name: String(input.name ?? fallback.name ?? id).trim().slice(0, 80) || id,
+ kind,
+ adapter,
+ capabilities: normalizeCapabilities(input.capabilities, fallback.capabilities),
+ endpoint,
+ healthPath,
+ enabled: bool(input.enabled, bool(fallback.enabled)),
+ timeoutMs: boundedNumber(input.timeoutMs, fallback.timeoutMs ?? 8000, 1000, 120000),
+ priority: boundedNumber(input.priority, fallback.priority ?? 100, 1, 1000),
+ llmProviderKeyId,
+ llmModel,
+ };
+}
+
+function builtinsFromLegacy(input = {}) {
+ const providers = input.providers ?? {};
+ const settings = input.settings ?? {};
+ return BUILTIN_SERVICES.map((service) => normalizeService({
+ ...service,
+ enabled: bool(providers[service.adapter], service.enabled),
+ endpoint: service.adapter === 'searxng'
+ ? String(settings.searxngEndpoint ?? service.endpoint)
+ : service.endpoint,
+ timeoutMs: service.adapter === 'research-http'
+ ? service.timeoutMs
+ : (settings.timeoutMs ?? service.timeoutMs),
+ }, service));
+}
+
+function normalizeServices(input = {}) {
+ const source = Array.isArray(input.services) ? input.services : builtinsFromLegacy(input);
+ const seen = new Set();
+ const services = [];
+ for (const item of source) {
+ const fallback = BUILTIN_SERVICES.find((candidate) => candidate.id === item?.id) ?? {};
+ const service = normalizeService(item, fallback);
+ if (!service || seen.has(service.id)) continue;
+ seen.add(service.id);
+ services.push(service);
+ }
+ for (const builtin of builtinsFromLegacy(input)) {
+ if (!seen.has(builtin.id)) services.push(builtin);
+ }
+ return services.slice(0, 30);
+}
+
+function normalizeRoutes(value, services) {
+ const ids = new Set(services.map((service) => service.id));
+ const defaults = MINDSEARCH_DEFAULT_CONFIG.routes;
+ return Object.fromEntries(ROUTE_KEYS.map((key) => {
+ const requested = String(value?.[key] ?? defaults[key] ?? '').trim();
+ return [key, !requested || ids.has(requested) ? requested : ''];
+ }));
+}
+
+export function resolveMindSearchService(config, route) {
+ const normalized = normalizeMindSearchConfig(config);
+ const serviceId = normalized.routes?.[route] ?? '';
+ return normalized.services.find((service) => service.id === serviceId && service.enabled) ?? null;
+}
+
+export async function probeMindSearchService(service, { fetchImpl = fetch } = {}) {
+ const normalized = normalizeService(service);
+ if (!normalized) throw new Error('Invalid search service configuration');
+ if (!normalized.endpoint) {
+ return {
+ ok: normalized.adapter === 'reader',
+ serviceId: normalized.id,
+ adapter: normalized.adapter,
+ status: null,
+ latencyMs: 0,
+ message: normalized.adapter === 'reader' ? 'Built-in reader is available' : 'Service endpoint is not configured',
+ };
+ }
+ const startedAt = Date.now();
+ let url;
+ if (normalized.adapter === 'searxng') {
+ url = new URL(normalized.endpoint);
+ url.searchParams.set('q', 'OpenAI');
+ url.searchParams.set('format', 'json');
+ } else if (normalized.healthPath) {
+ url = new URL(normalized.healthPath, normalized.endpoint);
+ } else {
+ return {
+ ok: true,
+ serviceId: normalized.id,
+ adapter: normalized.adapter,
+ status: null,
+ latencyMs: 0,
+ message: 'Configuration is valid; no health path is configured',
+ };
+ }
+ try {
+ const response = await fetchImpl(url, {
+ signal: AbortSignal.timeout(normalized.timeoutMs),
+ headers: {
+ accept: 'application/json,text/plain',
+ ...(normalized.adapter === 'research-http' && process.env.TKMIND_DEEP_SEARCH_SECRET
+ ? { 'x-secret-key': process.env.TKMIND_DEEP_SEARCH_SECRET }
+ : {}),
+ },
+ });
+ let resultCount = null;
+ if (normalized.adapter === 'searxng' && response.ok) {
+ const body = await response.json();
+ resultCount = Array.isArray(body.results) ? body.results.length : 0;
+ }
+ return {
+ ok: response.ok && (resultCount == null || resultCount > 0),
+ serviceId: normalized.id,
+ adapter: normalized.adapter,
+ status: response.status,
+ latencyMs: Date.now() - startedAt,
+ resultCount,
+ message: response.ok ? 'Health check completed' : `Service returned HTTP ${response.status}`,
+ };
+ } catch (error) {
+ return {
+ ok: false,
+ serviceId: normalized.id,
+ adapter: normalized.adapter,
+ status: null,
+ latencyMs: Date.now() - startedAt,
+ message: error instanceof Error ? error.message : 'Service health check failed',
+ };
+ }
+}
+
export function normalizeMindSearchConfig(input = {}) {
const mode = ['off', 'shadow', 'assist'].includes(input.mode) ? input.mode : 'off';
+ const services = normalizeServices(input);
+ const serviceByAdapter = (adapter) => services.find((service) => service.adapter === adapter);
+ const searxng = serviceByAdapter('searxng');
+ const github = serviceByAdapter('github');
+ const reader = serviceByAdapter('reader');
const config = {
enabled: bool(input.enabled),
mode,
providers: {
- searxng: bool(input.providers?.searxng),
- github: bool(input.providers?.github),
- reader: bool(input.providers?.reader),
+ searxng: bool(searxng?.enabled),
+ github: bool(github?.enabled),
+ reader: bool(reader?.enabled),
},
settings: {
- searxngEndpoint: String(input.settings?.searxngEndpoint ?? '').trim(),
- maxResults: Math.max(1, Math.min(20, Number(input.settings?.maxResults ?? 10) || 10)),
- timeoutMs: Math.max(1000, Math.min(30000, Number(input.settings?.timeoutMs ?? 8000) || 8000)),
- readerMaxChars: Math.max(1000, Math.min(50000, Number(input.settings?.readerMaxChars ?? 12000) || 12000)),
+ searxngEndpoint: String(searxng?.endpoint ?? input.settings?.searxngEndpoint ?? '').trim(),
+ maxResults: boundedNumber(input.settings?.maxResults, 10, 1, 20),
+ timeoutMs: boundedNumber(input.settings?.timeoutMs, 8000, 1000, 30000),
+ readerMaxChars: boundedNumber(input.settings?.readerMaxChars, 12000, 1000, 50000),
},
+ services,
+ routes: normalizeRoutes(input.routes, services),
};
if (!config.enabled || config.mode === 'off') {
config.enabled = false;
@@ -49,6 +285,57 @@ function envConfig(env = process.env) {
github: env.TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED,
reader: env.TKMIND_SEARCH_READER_ENABLED,
},
+ settings: {
+ searxngEndpoint: env.TKMIND_SEARCH_SEARXNG_URL,
+ maxResults: env.TKMIND_SEARCH_MAX_RESULTS,
+ timeoutMs: env.TKMIND_SEARCH_TIMEOUT_MS,
+ readerMaxChars: env.TKMIND_SEARCH_READER_MAX_CHARS,
+ },
+ services: parseJsonArray(env.TKMIND_SEARCH_SERVICES_JSON),
+ routes: parseJsonObject(env.TKMIND_SEARCH_ROUTES_JSON),
+ });
+}
+
+function parseJsonArray(value) {
+ if (!value) return undefined;
+ try {
+ const parsed = JSON.parse(value);
+ return Array.isArray(parsed) ? parsed : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function parseJsonObject(value) {
+ if (!value) return undefined;
+ try {
+ const parsed = JSON.parse(value);
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function applyLegacyPatchToServices(current, patch) {
+ if (Array.isArray(patch?.services)) return patch.services;
+ const providerPatch = patch?.providers ?? {};
+ const settingPatch = patch?.settings ?? {};
+ return current.services.map((service) => {
+ if (service.adapter === 'searxng') {
+ return {
+ ...service,
+ ...(providerPatch.searxng == null ? {} : { enabled: providerPatch.searxng }),
+ ...(settingPatch.searxngEndpoint == null ? {} : { endpoint: settingPatch.searxngEndpoint }),
+ ...(settingPatch.timeoutMs == null ? {} : { timeoutMs: settingPatch.timeoutMs }),
+ };
+ }
+ if (service.adapter === 'github' && providerPatch.github != null) {
+ return { ...service, enabled: providerPatch.github };
+ }
+ if (service.adapter === 'reader' && providerPatch.reader != null) {
+ return { ...service, enabled: providerPatch.reader };
+ }
+ return service;
});
}
@@ -61,7 +348,7 @@ export async function ensureMindSearchConfigSchema(pool) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
}
-export function createMindSearchConfigService(pool, { env = process.env } = {}) {
+export function createMindSearchConfigService(pool, { env = process.env, fetchImpl = fetch } = {}) {
let ready = null;
const ensure = async () => {
if (!pool) return;
@@ -82,9 +369,22 @@ export function createMindSearchConfigService(pool, { env = process.env } = {})
ensureSchema: ensure,
getEffectiveConfig: async () => (await load()).config,
getAdminConfig: async () => load(),
+ testService: async (serviceId) => {
+ const current = await load();
+ const service = current.config.services.find((item) => item.id === String(serviceId ?? '').trim());
+ if (!service) throw Object.assign(new Error('Search service not found'), { code: 'SEARCH_SERVICE_NOT_FOUND' });
+ return probeMindSearchService(service, { fetchImpl });
+ },
updateAdminConfig: async (patch, { updatedBy = null } = {}) => {
const current = await load();
- const next = normalizeMindSearchConfig({ ...current.config, ...patch, providers: { ...current.config.providers, ...(patch?.providers ?? {}) }, settings: { ...current.config.settings, ...(patch?.settings ?? {}) } });
+ const next = normalizeMindSearchConfig({
+ ...current.config,
+ ...patch,
+ providers: { ...current.config.providers, ...(patch?.providers ?? {}) },
+ settings: { ...current.config.settings, ...(patch?.settings ?? {}) },
+ services: applyLegacyPatchToServices(current.config, patch),
+ routes: { ...current.config.routes, ...(patch?.routes ?? {}) },
+ });
if (!pool) return { config: next, source: 'env' };
await ensure();
const now = Date.now();
@@ -93,7 +393,16 @@ export function createMindSearchConfigService(pool, { env = process.env } = {})
},
getRuntimeState: async () => {
const config = await (async () => (await load()).config)();
- return { enabled: config.enabled, mode: config.mode, providers: config.providers, effective: config.enabled && config.mode !== 'off' };
+ return {
+ enabled: config.enabled,
+ mode: config.mode,
+ providers: config.providers,
+ services: config.services.map(({ id, name, kind, adapter, capabilities, enabled, timeoutMs, priority }) => ({
+ id, name, kind, adapter, capabilities, enabled, timeoutMs, priority,
+ })),
+ routes: config.routes,
+ effective: config.enabled && config.mode !== 'off',
+ };
},
};
}
diff --git a/mindsearch-providers.mjs b/mindsearch-providers.mjs
index 3ef66b3..b669d39 100644
--- a/mindsearch-providers.mjs
+++ b/mindsearch-providers.mjs
@@ -1,10 +1,21 @@
import { isSafeHttpUrl, normalizeSearchResult } from './search-capability.mjs';
+import { isValidSearchServiceEndpoint } from './mindsearch-config.mjs';
-export async function searchSearxng(query, { limit = 10, endpoint = process.env.TKMIND_SEARCH_SEARXNG_URL, fetchImpl = fetch } = {}) {
+export async function searchSearxng(query, {
+ limit = 10,
+ endpoint = process.env.TKMIND_SEARCH_SEARXNG_URL,
+ fetchImpl = fetch,
+ timeoutMs = Number(process.env.TKMIND_SEARCH_TIMEOUT_MS ?? 8000),
+ signal,
+} = {}) {
if (!endpoint) throw new Error('SearXNG endpoint is not configured');
try { if (!['http:', 'https:'].includes(new URL(endpoint).protocol)) throw new Error('unsupported protocol'); } catch { throw new Error('SearXNG endpoint is not configured safely'); }
const url = new URL(endpoint); url.searchParams.set('q', query); url.searchParams.set('format', 'json');
- const response = await fetchImpl(url, { signal: AbortSignal.timeout(Number(process.env.TKMIND_SEARCH_TIMEOUT_MS ?? 8000)), headers: { accept: 'application/json' } });
+ const timeout = AbortSignal.timeout(timeoutMs);
+ const response = await fetchImpl(url, {
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
+ headers: { accept: 'application/json' },
+ });
if (!response.ok) throw new Error(`SearXNG returned ${response.status}`);
const body = await response.json();
return (Array.isArray(body.results) ? body.results : []).slice(0, limit).map((item, index) => normalizeSearchResult({ ...item, source: 'searxng' }, index));
@@ -29,3 +40,136 @@ export async function searchGithubCode(query, { limit = 10, token = process.env.
const body = await response.json();
return (Array.isArray(body.items) ? body.items : []).slice(0, limit).map((item, index) => normalizeSearchResult({ title: `${item.repository?.full_name ?? ''}:${item.path ?? item.name ?? ''}`, url: item.html_url, snippet: item.repository?.description ?? '', source: 'github' }, index));
}
+
+function serviceUrl(endpoint, path) {
+ if (!isValidSearchServiceEndpoint(endpoint, { allowEmpty: false })) throw new Error('Search service endpoint is not configured safely');
+ const base = endpoint.endsWith('/') ? endpoint : `${endpoint}/`;
+ return new URL(path.replace(/^\//, ''), base);
+}
+
+async function postServiceJson(endpoint, path, payload, {
+ timeoutMs = 30000,
+ fetchImpl = fetch,
+ userId = null,
+ secret = process.env.TKMIND_DEEP_SEARCH_SECRET,
+} = {}) {
+ const response = await fetchImpl(serviceUrl(endpoint, path), {
+ method: 'POST',
+ signal: AbortSignal.timeout(timeoutMs),
+ headers: {
+ accept: 'application/json',
+ 'content-type': 'application/json',
+ ...(userId ? { 'x-user-id': String(userId) } : {}),
+ ...(secret ? { 'x-secret-key': String(secret) } : {}),
+ },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) throw new Error(`Search service returned ${response.status}`);
+ return response.json();
+}
+
+export async function searchGithubGateway(query, {
+ endpoint = process.env.TKMIND_SEARCH_GITHUB_GATEWAY_URL,
+ limit = 10,
+ timeoutMs = 12000,
+ fetchImpl = fetch,
+ secret = process.env.TKMIND_SEARCH_GITHUB_GATEWAY_SECRET
+ || process.env.TKMIND_DEEP_SEARCH_SECRET,
+} = {}) {
+ const body = await postServiceJson(endpoint, '/v1/providers/github/search', {
+ query,
+ limit,
+ }, {
+ timeoutMs,
+ fetchImpl,
+ secret,
+ });
+ return (Array.isArray(body.results) ? body.results : [])
+ .slice(0, limit)
+ .map((item, index) => normalizeSearchResult({ ...item, source: 'github' }, index));
+}
+
+export async function searchResearchService(query, {
+ endpoint,
+ type = 'web',
+ limit = 10,
+ timeoutMs = 30000,
+ fetchImpl = fetch,
+ secret = process.env.TKMIND_DEEP_SEARCH_SECRET,
+} = {}) {
+ const body = await postServiceJson(endpoint, '/v1/search', { query, type, limit }, {
+ timeoutMs,
+ fetchImpl,
+ secret,
+ });
+ return (Array.isArray(body.results) ? body.results : [])
+ .slice(0, limit)
+ .map((item, index) => normalizeSearchResult({ ...item, source: item.source || 'research-service' }, index));
+}
+
+export async function startResearchTask(question, {
+ endpoint,
+ depth = 'standard',
+ userId = null,
+ llmProviderKeyId = '',
+ llmModel = '',
+ timeoutMs = 30000,
+ fetchImpl = fetch,
+ secret = process.env.TKMIND_DEEP_SEARCH_SECRET,
+} = {}) {
+ const body = await postServiceJson(endpoint, '/v1/research', {
+ question,
+ depth,
+ ...(userId ? { userId } : {}),
+ ...(llmProviderKeyId && llmModel
+ ? { llm_provider_key_id: llmProviderKeyId, llm_model: llmModel }
+ : {}),
+ }, { timeoutMs, fetchImpl, userId, secret });
+ if (!body || typeof body !== 'object' || !body.task_id) throw new Error('Research service returned an invalid task');
+ return {
+ taskId: String(body.task_id),
+ status: String(body.status ?? 'running'),
+ service: String(body.service ?? 'research-service'),
+ };
+}
+
+export async function getResearchTask(taskId, {
+ endpoint,
+ userId = null,
+ timeoutMs = 30000,
+ fetchImpl = fetch,
+ secret = process.env.TKMIND_DEEP_SEARCH_SECRET,
+} = {}) {
+ const response = await fetchImpl(serviceUrl(endpoint, `/v1/research/${encodeURIComponent(taskId)}`), {
+ signal: AbortSignal.timeout(timeoutMs),
+ headers: {
+ accept: 'application/json',
+ ...(userId ? { 'x-user-id': String(userId) } : {}),
+ ...(secret ? { 'x-secret-key': String(secret) } : {}),
+ },
+ });
+ if (response.status === 404) throw new Error('Research task was not found');
+ if (!response.ok) throw new Error(`Search service returned ${response.status}`);
+ return response.json();
+}
+
+export async function cancelResearchTask(taskId, {
+ endpoint,
+ userId = null,
+ timeoutMs = 30000,
+ fetchImpl = fetch,
+ secret = process.env.TKMIND_DEEP_SEARCH_SECRET,
+} = {}) {
+ const response = await fetchImpl(serviceUrl(endpoint, `/v1/research/${encodeURIComponent(taskId)}`), {
+ method: 'DELETE',
+ signal: AbortSignal.timeout(timeoutMs),
+ headers: {
+ accept: 'application/json',
+ ...(userId ? { 'x-user-id': String(userId) } : {}),
+ ...(secret ? { 'x-secret-key': String(secret) } : {}),
+ },
+ });
+ if (response.status === 404) throw new Error('Research task was not found');
+ if (!response.ok) throw new Error(`Search service returned ${response.status}`);
+ return response.json();
+}
diff --git a/mindsearch.test.mjs b/mindsearch.test.mjs
index 18dcae1..3f20559 100644
--- a/mindsearch.test.mjs
+++ b/mindsearch.test.mjs
@@ -1,13 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildCitations, isSafeHttpUrl, validateSearchRequest } from './search-capability.mjs';
-import { MINDSEARCH_DEFAULT_CONFIG, normalizeMindSearchConfig } from './mindsearch-config.mjs';
-import { buildAgentExtensionPolicy, DEFAULT_USER_CAPABILITIES } from './capabilities.mjs';
-import { searchGithubCode, searchSearxng } from './mindsearch-providers.mjs';
+import {
+ MINDSEARCH_DEFAULT_CONFIG,
+ normalizeMindSearchConfig,
+ probeMindSearchService,
+ resolveMindSearchService,
+} from './mindsearch-config.mjs';
+import {
+ buildAgentExtensionPolicy,
+ DEFAULT_USER_CAPABILITIES,
+} from './capabilities.mjs';
+import {
+ cancelResearchTask,
+ getResearchTask,
+ searchGithubCode,
+ searchGithubGateway,
+ searchResearchService,
+ searchSearxng,
+ startResearchTask,
+} from './mindsearch-providers.mjs';
test('MindSearch defaults to disabled and normalizes unsafe modes', () => {
assert.equal(MINDSEARCH_DEFAULT_CONFIG.enabled, false);
- assert.deepEqual(normalizeMindSearchConfig({ enabled: true, mode: 'invalid', providers: { github: true } }), { enabled: false, mode: 'off', providers: { searxng: false, github: true, reader: false }, settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 } });
+ const config = normalizeMindSearchConfig({ enabled: true, mode: 'invalid', providers: { github: true } });
+ assert.equal(config.enabled, false);
+ assert.equal(config.mode, 'off');
+ assert.deepEqual(config.providers, { searxng: false, github: true, reader: false });
+ assert.equal(config.services.find((service) => service.id === 'github').enabled, true);
+ assert.deepEqual(config.routes, { web: 'searxng', news: 'searxng', code: 'github', read: 'reader', research: 'deep-search' });
+ assert.equal(config.services.find((service) => service.id === 'deep-search').enabled, false);
+});
+
+test('MindSearch service registry accepts research orchestrators and rejects unsafe endpoints', () => {
+ const config = normalizeMindSearchConfig({
+ enabled: true,
+ mode: 'assist',
+ services: [
+ {
+ id: 'research-main',
+ name: 'Research',
+ kind: 'orchestrator',
+ adapter: 'research-http',
+ endpoint: 'http://127.0.0.1:20100',
+ healthPath: '/health',
+ capabilities: ['search.web', 'research.execute', 'research.execute', 'INVALID CAPABILITY'],
+ enabled: true,
+ timeoutMs: 500000,
+ llmProviderKeyId: 'provider-key-1',
+ llmModel: 'research-model',
+ },
+ { id: 'metadata', adapter: 'research-http', endpoint: 'http://169.254.169.254/latest', enabled: true },
+ ],
+ routes: { research: 'research-main', web: 'research-main' },
+ });
+ assert.equal(config.services.some((service) => service.id === 'metadata'), false);
+ assert.deepEqual(config.services.find((service) => service.id === 'research-main').capabilities, ['search.web', 'research.execute']);
+ assert.equal(config.services.find((service) => service.id === 'research-main').timeoutMs, 120000);
+ assert.equal(config.services.find((service) => service.id === 'research-main').llmProviderKeyId, 'provider-key-1');
+ assert.equal(config.services.find((service) => service.id === 'research-main').llmModel, 'research-model');
+ assert.equal(resolveMindSearchService(config, 'research').id, 'research-main');
+});
+
+test('MindSearch service probe supports loopback SearXNG without exposing arbitrary response data', async () => {
+ let requested;
+ const result = await probeMindSearchService({
+ id: 'searxng-local',
+ name: 'Local',
+ kind: 'provider',
+ adapter: 'searxng',
+ endpoint: 'http://127.0.0.1:8080/search',
+ enabled: true,
+ }, {
+ fetchImpl: async (url) => {
+ requested = String(url);
+ return { ok: true, status: 200, json: async () => ({ results: [{ title: 'A' }] }) };
+ },
+ });
+ assert.match(requested, /q=OpenAI/);
+ assert.equal(result.ok, true);
+ assert.equal(result.resultCount, 1);
});
test('search request validation and citations are deterministic', () => {
@@ -28,6 +100,48 @@ test('MindSearch never changes legacy web extension and is gated by capability/c
assert.equal(policy.extensionOverrides.some((ext) => ext.name === 'tkmind-search'), false);
policy = buildAgentExtensionPolicy({ ...base, search_external: true }, { mindSearchConfig: { enabled: true, mode: 'assist', providers: {} } });
assert.ok(policy.extensionOverrides.some((ext) => ext.name === 'tkmind-search'));
+ policy = buildAgentExtensionPolicy({ ...base, search_external: true }, {
+ userId: 'user-123',
+ mindSearchConfig: {
+ enabled: true,
+ mode: 'assist',
+ providers: {},
+ settings: { searxngEndpoint: 'http://127.0.0.1:20080/search' },
+ services: [
+ {
+ id: 'searxng',
+ enabled: true,
+ adapter: 'searxng',
+ endpoint: 'http://127.0.0.1:20080/search',
+ },
+ {
+ id: 'deep-search',
+ enabled: true,
+ adapter: 'research-http',
+ endpoint: 'http://127.0.0.1:20100',
+ },
+ ],
+ routes: { research: 'deep-search' },
+ },
+ });
+ const extension = policy.extensionOverrides.find((ext) => ext.name === 'tkmind-search');
+ assert.equal(extension.envs.TKMIND_SEARCH_USER_ID, 'user-123');
+ assert.equal(
+ extension.envs.TKMIND_SEARCH_SEARXNG_URL,
+ 'http://host.docker.internal:20080/search',
+ );
+ assert.equal(
+ extension.envs.TKMIND_SEARCH_GITHUB_GATEWAY_URL,
+ 'http://host.docker.internal:20100/',
+ );
+ assert.equal(extension.envs.GITHUB_TOKEN, undefined);
+ const mcpServices = JSON.parse(extension.envs.TKMIND_SEARCH_SERVICES_JSON);
+ assert.equal(
+ mcpServices.find((service) => service.id === 'deep-search').endpoint,
+ 'http://host.docker.internal:20100/',
+ );
+ assert.ok(extension.available_tools.includes('tkmind_research_status'));
+ assert.ok(extension.available_tools.includes('tkmind_research_cancel'));
});
test('SearXNG adapter normalizes provider results without requiring a live network', async () => {
@@ -42,3 +156,77 @@ test('GitHub code adapter sends bounded queries and normalizes results', async (
assert.equal(requested.options.headers.accept, 'application/vnd.github+json');
assert.equal(result[0].source, 'github');
});
+
+test('GitHub gateway keeps the provider token outside the MCP process', async () => {
+ let requested;
+ const result = await searchGithubGateway('repo:openai goose', {
+ endpoint: 'http://deep-search.local:20100',
+ secret: 'gateway-secret',
+ fetchImpl: async (url, options) => {
+ requested = { url: String(url), options };
+ return {
+ ok: true,
+ json: async () => ({
+ results: [{
+ title: 'openai/goose:src/goose.rs',
+ url: 'https://github.com/openai/goose/blob/main/src/goose.rs',
+ snippet: 'agent',
+ }],
+ }),
+ };
+ },
+ });
+ assert.equal(requested.url, 'http://deep-search.local:20100/v1/providers/github/search');
+ assert.equal(requested.options.headers['x-secret-key'], 'gateway-secret');
+ assert.equal(requested.options.headers.authorization, undefined);
+ assert.equal(result[0].source, 'github');
+});
+
+test('Research service adapter uses the stable HTTP contract', async () => {
+ const requests = [];
+ const fetchImpl = async (url, options) => {
+ requests.push({ url: String(url), options });
+ if (String(url).endsWith('/v1/research')) {
+ return { ok: true, json: async () => ({ task_id: 'task-1', status: 'running' }) };
+ }
+ if (String(url).endsWith('/v1/research/task-1')) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({
+ id: 'task-1',
+ status: options?.method === 'DELETE' ? 'cancelling' : 'researching',
+ progress: 55,
+ }),
+ };
+ }
+ return { ok: true, json: async () => ({ results: [{ title: 'Report', url: 'https://example.com/report', snippet: 'evidence' }] }) };
+ };
+ const results = await searchResearchService('AI Agent', { endpoint: 'http://research.local', limit: 5, fetchImpl });
+ const task = await startResearchTask('Analyze AI Agent', {
+ endpoint: 'http://research.local',
+ depth: 'deep',
+ userId: 'user-1',
+ llmProviderKeyId: 'provider-key-1',
+ llmModel: 'research-model',
+ secret: 'secret-1',
+ fetchImpl,
+ });
+ const status = await getResearchTask('task-1', { endpoint: 'http://research.local', fetchImpl });
+ const cancelled = await cancelResearchTask('task-1', { endpoint: 'http://research.local', fetchImpl });
+ assert.equal(results[0].source, 'research-service');
+ assert.deepEqual(task, { taskId: 'task-1', status: 'running', service: 'research-service' });
+ assert.equal(status.status, 'researching');
+ assert.equal(cancelled.status, 'cancelling');
+ assert.equal(JSON.parse(requests[0].options.body).query, 'AI Agent');
+ assert.deepEqual(JSON.parse(requests[1].options.body), {
+ question: 'Analyze AI Agent',
+ depth: 'deep',
+ userId: 'user-1',
+ llm_provider_key_id: 'provider-key-1',
+ llm_model: 'research-model',
+ });
+ assert.equal(requests[1].options.headers['x-user-id'], 'user-1');
+ assert.equal(requests[1].options.headers['x-secret-key'], 'secret-1');
+ assert.equal(requests[3].options.method, 'DELETE');
+});
diff --git a/package.json b/package.json
index 9733ec8..a91fff2 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"dev:vite": "vite",
"dev:server": "node server.mjs",
"dev:adm": "node admin-server.mjs",
+ "dev:deep-search": "node deep-search-server.mjs",
"dev:plaza-express": "node plaza-server.mjs",
"dev:ops": "cd ops && npm run dev",
"launch:executor": "node scripts/launch-executor.mjs",
@@ -35,6 +36,7 @@
"backfill:plaza-public-urls": "node scripts/backfill-plaza-public-urls.mjs",
"enrich:plaza": "node scripts/enrich-plaza-posts.mjs",
"build": "vite build",
+ "build:deep-search-runtime": "node scripts/build-deep-search-runtime.mjs",
"build:portal-runtime": "node scripts/build-portal-runtime.mjs",
"build:imgproxy-runtime": "bash scripts/build-imgproxy-runtime-image.sh",
"build:mindspace-service-runtime": "node scripts/build-mindspace-service-runtime.mjs",
@@ -61,6 +63,7 @@
"test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
"test:episodic-memory": "node --test episodic-memory.test.mjs direct-chat-service.test.mjs chat-intent-router.test.mjs",
+ "test:deep-search": "node --test deep-search.test.mjs mindsearch.test.mjs",
"test:image-review": "node --test mindspace-image-review.test.mjs mindspace-image-generation.test.mjs",
"test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs",
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
diff --git a/scripts/build-deep-search-runtime.mjs b/scripts/build-deep-search-runtime.mjs
new file mode 100644
index 0000000..829b953
--- /dev/null
+++ b/scripts/build-deep-search-runtime.mjs
@@ -0,0 +1,75 @@
+import { spawn } from 'node:child_process';
+import {
+ chmod,
+ copyFile,
+ mkdir,
+ rm,
+ writeFile,
+} from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const runtimeRoot = path.join(root, '.runtime', 'deep-search');
+const esbuildBin = path.join(root, 'node_modules', '.bin', 'esbuild');
+const runtimeNodeTarget = process.env.DEEP_SEARCH_RUNTIME_NODE_TARGET || 'node24';
+
+function run(command, args) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, {
+ cwd: root,
+ stdio: 'inherit',
+ env: process.env,
+ });
+ child.once('error', reject);
+ child.once('exit', (code, signal) => {
+ if (code === 0) return resolve();
+ reject(new Error(`${command} exited with ${code ?? signal}`));
+ });
+ });
+}
+
+await rm(runtimeRoot, { recursive: true, force: true });
+await mkdir(path.join(runtimeRoot, 'scripts'), { recursive: true });
+
+await run(esbuildBin, [
+ 'deep-search-server.mjs',
+ '--bundle',
+ '--platform=node',
+ '--format=esm',
+ `--target=${runtimeNodeTarget}`,
+ '--outfile=.runtime/deep-search/deep-search-server.mjs',
+ '--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
+]);
+
+for (const script of [
+ 'run-deep-search-prod.sh',
+ 'install-deep-search-runtime-prod.sh',
+]) {
+ const source = path.join(root, 'scripts', script);
+ const target = path.join(runtimeRoot, 'scripts', script);
+ await copyFile(source, target);
+ await chmod(target, 0o755);
+}
+
+await writeFile(
+ path.join(runtimeRoot, 'RUNBOOK.txt'),
+ [
+ 'TKMind Deep Search standalone runtime',
+ '',
+ 'Production base: /Users/john/Project/deep-search-runtime',
+ 'Persistent env: shared/.env',
+ 'Persistent data: data/research.sqlite',
+ 'Release code: releases//',
+ 'Active release: current -> releases//',
+ 'LaunchAgent: cn.tkmind.memind-deep-search',
+ 'Health: http://127.0.0.1:20100/health',
+ 'SearXNG: http://127.0.0.1:20080/search',
+ '',
+ 'The GitHub token stays in shared/.env and is never passed to goosed MCP processes.',
+ 'Portal and memindadm releases are intentionally separate.',
+ '',
+ ].join('\n'),
+);
+
+console.log(`Deep Search runtime built at ${runtimeRoot}`);
diff --git a/scripts/deep-search-runtime-package.test.mjs b/scripts/deep-search-runtime-package.test.mjs
new file mode 100644
index 0000000..fbd50ad
--- /dev/null
+++ b/scripts/deep-search-runtime-package.test.mjs
@@ -0,0 +1,31 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const read = (relativePath) => readFileSync(path.join(root, relativePath), 'utf8');
+
+test('Deep Search production runtime is isolated from Portal releases', () => {
+ const builder = read('scripts/build-deep-search-runtime.mjs');
+ assert.match(builder, /deep-search-server\.mjs/);
+ assert.match(builder, /\.runtime', 'deep-search/);
+
+ const runner = read('scripts/run-deep-search-prod.sh');
+ assert.match(runner, /shared\/\.env/);
+ assert.match(runner, /data\/research\.sqlite/);
+ assert.match(runner, /127\.0\.0\.1:20080\/search/);
+
+ const installer = read('scripts/install-deep-search-runtime-prod.sh');
+ assert.match(installer, /cn\.tkmind\.memind-deep-search/);
+ assert.match(installer, /chmod 600/);
+ assert.match(installer, /openssl rand -hex 32/);
+
+ const release = read('scripts/release-deep-search-runtime-prod.sh');
+ assert.match(release, /branch.*main/);
+ assert.match(release, /CONFIRMED_CI_SHA/);
+ assert.match(release, /shasum -a 256/);
+ assert.match(release, /rollback/);
+ assert.doesNotMatch(release, /release-portal-runtime-prod/);
+});
diff --git a/scripts/install-deep-search-runtime-prod.sh b/scripts/install-deep-search-runtime-prod.sh
new file mode 100644
index 0000000..9368483
--- /dev/null
+++ b/scripts/install-deep-search-runtime-prod.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+# Runs on 103 from a verified Deep Search release directory.
+set -euo pipefail
+
+RUNTIME_BASE="${DEEP_SEARCH_RUNTIME_BASE:-/Users/john/Project/deep-search-runtime}"
+RELEASE_DIR="${DEEP_SEARCH_RELEASE_DIR:?DEEP_SEARCH_RELEASE_DIR is required}"
+ENV_DIR="${RUNTIME_BASE}/shared"
+ENV_FILE="${ENV_DIR}/.env"
+DATA_DIR="${RUNTIME_BASE}/data"
+LOG_DIR="${RUNTIME_BASE}/logs"
+PLIST="${HOME}/Library/LaunchAgents/cn.tkmind.memind-deep-search.plist"
+LABEL="cn.tkmind.memind-deep-search"
+GUI="gui/$(id -u)"
+NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
+
+[[ -x "${NODE_BIN}" ]] || {
+ echo "missing Node.js runtime: ${NODE_BIN}" >&2
+ exit 1
+}
+[[ -f "${RELEASE_DIR}/deep-search-server.mjs" ]] || {
+ echo "missing Deep Search server in ${RELEASE_DIR}" >&2
+ exit 1
+}
+
+mkdir -p "${ENV_DIR}" "${DATA_DIR}" "${LOG_DIR}" "$(dirname "${PLIST}")"
+touch "${ENV_FILE}"
+chmod 600 "${ENV_FILE}"
+
+ensure_env() {
+ local key="$1"
+ local value="$2"
+ if ! grep -qE "^${key}=" "${ENV_FILE}"; then
+ printf '%s=%s\n' "${key}" "${value}" >> "${ENV_FILE}"
+ fi
+}
+
+if ! grep -qE '^TKMIND_DEEP_SEARCH_SECRET=.+$' "${ENV_FILE}"; then
+ secret="$(openssl rand -hex 32)"
+ ensure_env TKMIND_DEEP_SEARCH_SECRET "${secret}"
+fi
+ensure_env TKMIND_DEEP_SEARCH_HOST "127.0.0.1"
+ensure_env TKMIND_DEEP_SEARCH_PORT "20100"
+ensure_env TKMIND_DEEP_SEARCH_DB "${DATA_DIR}/research.sqlite"
+ensure_env TKMIND_DEEP_SEARCH_SEARXNG_URL "http://127.0.0.1:20080/search"
+ensure_env TKMIND_DEEP_SEARCH_LLM_GATEWAY_URL "http://127.0.0.1:8081/api/internal/deep-search/llm"
+ensure_env TKMIND_DEEP_SEARCH_LLM_GATEWAY_SECRET '${TKMIND_DEEP_SEARCH_SECRET}'
+
+ln -sfn "${RELEASE_DIR}" "${RUNTIME_BASE}/current"
+
+cat > "${PLIST}" <
+
+
+Label${LABEL}
+ProgramArguments${RUNTIME_BASE}/current/scripts/run-deep-search-prod.sh
+WorkingDirectory${RUNTIME_BASE}/current
+EnvironmentVariables
+ DEEP_SEARCH_RUNTIME_BASE${RUNTIME_BASE}
+ NODE_BIN${NODE_BIN}
+
+RunAtLoad
+KeepAlive
+ProcessTypeBackground
+ThrottleInterval10
+StandardOutPath${LOG_DIR}/deep-search.log
+StandardErrorPath${LOG_DIR}/deep-search.log
+
+EOF
+
+plutil -lint "${PLIST}" >/dev/null
+launchctl bootout "${GUI}/${LABEL}" >/dev/null 2>&1 || true
+launchctl bootstrap "${GUI}" "${PLIST}"
+launchctl enable "${GUI}/${LABEL}"
+launchctl kickstart -k "${GUI}/${LABEL}"
+
+for _ in $(seq 1 30); do
+ if curl -fsS --max-time 2 "http://127.0.0.1:20100/health" >/dev/null; then
+ exit 0
+ fi
+ sleep 1
+done
+
+echo "Deep Search health check timed out" >&2
+exit 1
diff --git a/scripts/release-deep-search-runtime-prod.sh b/scripts/release-deep-search-runtime-prod.sh
new file mode 100644
index 0000000..142d2cb
--- /dev/null
+++ b/scripts/release-deep-search-runtime-prod.sh
@@ -0,0 +1,196 @@
+#!/usr/bin/env bash
+# Publishes only the independently versioned Deep Search runtime to 103.
+# Portal and memindadm are not modified by this workflow.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+HOST="${STUDIO_HOST:-58.38.22.103}"
+REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}"
+RUNTIME_BASE="${DEEP_SEARCH_RUNTIME_BASE:-${REMOTE_ROOT}/deep-search-runtime}"
+INCOMING="${REMOTE_ROOT}/incoming/deep-search-runtime"
+RELEASE_ID="$(date +%Y%m%d-%H%M%S)-$(git -C "${ROOT}" rev-parse --short HEAD)"
+TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/deep-search-runtime-release.XXXXXX")"
+DRY_RUN=0
+AUTO_YES=0
+
+cleanup() {
+ rm -rf "${TMP_DIR}"
+}
+trap cleanup EXIT
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --dry-run) DRY_RUN=1 ;;
+ --yes) AUTO_YES=1 ;;
+ -h|--help)
+ cat <<'EOF'
+Usage: bash scripts/release-deep-search-runtime-prod.sh [--dry-run] [--yes]
+
+Required production gates:
+ - current branch is main
+ - HEAD exactly matches origin/main
+ - worktree is clean
+ - CONFIRMED_CI_SHA equals the full current HEAD
+
+This installs only Deep Search on 103. It does not release Portal or memindadm.
+EOF
+ exit 0
+ ;;
+ *)
+ echo "unknown argument: $1" >&2
+ exit 1
+ ;;
+ esac
+ shift
+done
+
+git -C "${ROOT}" fetch origin main --quiet
+branch="$(git -C "${ROOT}" branch --show-current)"
+head_sha="$(git -C "${ROOT}" rev-parse HEAD)"
+origin_sha="$(git -C "${ROOT}" rev-parse origin/main)"
+[[ "${branch}" == "main" ]] || {
+ echo "Deep Search production release must run from main" >&2
+ exit 1
+}
+[[ "${head_sha}" == "${origin_sha}" ]] || {
+ echo "main must exactly match origin/main" >&2
+ exit 1
+}
+[[ -z "$(git -C "${ROOT}" status --porcelain=v1 --untracked-files=all)" ]] || {
+ echo "worktree must be clean" >&2
+ exit 1
+}
+[[ "${CONFIRMED_CI_SHA:-}" == "${head_sha}" ]] || {
+ echo "CONFIRMED_CI_SHA must equal the verified main HEAD: ${head_sha}" >&2
+ exit 1
+}
+ALLOW_MAIN_RELEASE=1 bash "${ROOT}/scripts/check-release-ready.sh"
+
+(
+ cd "${ROOT}"
+ npm run test:deep-search
+ node --test capabilities.test.mjs scripts/deep-search-runtime-package.test.mjs
+ npm run build:deep-search-runtime
+)
+
+RUNTIME_DIR="${ROOT}/.runtime/deep-search"
+for file in \
+ deep-search-server.mjs \
+ scripts/run-deep-search-prod.sh \
+ scripts/install-deep-search-runtime-prod.sh \
+ RUNBOOK.txt; do
+ [[ -f "${RUNTIME_DIR}/${file}" ]] || {
+ echo "missing runtime artifact: ${file}" >&2
+ exit 1
+ }
+done
+
+MANIFEST="${RUNTIME_DIR}/release-manifest.txt"
+{
+ echo "release_id=${RELEASE_ID}"
+ echo "git_head=${head_sha}"
+ echo "git_branch=${branch}"
+ echo "created_at=$(date '+%Y-%m-%d %H:%M:%S %z')"
+ echo "artifact=.runtime/deep-search"
+ echo "persistent_env=${RUNTIME_BASE}/shared/.env"
+ echo "persistent_db=${RUNTIME_BASE}/data/research.sqlite"
+} > "${MANIFEST}"
+
+ARCHIVE="${TMP_DIR}/deep-search-runtime-${RELEASE_ID}.tar.gz"
+SHA_FILE="${ARCHIVE}.sha256"
+tar -C "${RUNTIME_DIR}" -czf "${ARCHIVE}" .
+(cd "${TMP_DIR}" && shasum -a 256 "$(basename "${ARCHIVE}")" > "$(basename "${SHA_FILE}")")
+
+if [[ "${DRY_RUN}" -eq 1 ]]; then
+ shasum -a 256 -c "${SHA_FILE}"
+ echo "Deep Search dry-run artifact verified: ${ARCHIVE}"
+ exit 0
+fi
+
+if [[ "${AUTO_YES}" -ne 1 ]]; then
+ echo "Target: ${HOST}:${RUNTIME_BASE}"
+ echo "Release: ${RELEASE_ID}"
+ read -r -p "Install standalone Deep Search on 103? [y/N] " confirm /dev/null || true)"
+database_path="${RUNTIME_BASE}/data/research.sqlite"
+
+mkdir -p "${release_dir}" "${backup_dir}"
+cp "${plist}" "${backup_dir}/launchagent.plist" 2>/dev/null || true
+if [[ -f "${database_path}" ]]; then
+ sqlite3 "${database_path}" ".timeout 10000" \
+ ".backup '${backup_dir}/research.sqlite'" >/dev/null
+fi
+cd "${INCOMING}"
+shasum -a 256 -c "$(basename "${sha_file}")"
+tar -xzf "${archive}" -C "${release_dir}"
+
+rollback() {
+ if [[ -n "${previous_target}" && -d "${previous_target}" ]]; then
+ ln -sfn "${previous_target}" "${current_link}"
+ else
+ rm -f "${current_link}"
+ fi
+ if [[ -f "${backup_dir}/launchagent.plist" ]]; then
+ cp "${backup_dir}/launchagent.plist" "${plist}"
+ launchctl bootout "${gui}/${label}" >/dev/null 2>&1 || true
+ launchctl bootstrap "${gui}" "${plist}" >/dev/null 2>&1 || true
+ launchctl kickstart -k "${gui}/${label}" >/dev/null 2>&1 || true
+ fi
+}
+trap rollback ERR
+
+DEEP_SEARCH_RUNTIME_BASE="${RUNTIME_BASE}" \
+DEEP_SEARCH_RELEASE_DIR="${release_dir}" \
+ bash "${release_dir}/scripts/install-deep-search-runtime-prod.sh"
+
+set -a
+# shellcheck disable=SC1090
+source "${RUNTIME_BASE}/shared/.env"
+set +a
+
+curl -fsS --max-time 5 "http://127.0.0.1:20100/health" >/dev/null
+curl -fsS --max-time 20 \
+ -H "X-Secret-Key: ${TKMIND_DEEP_SEARCH_SECRET}" \
+ -H "Content-Type: application/json" \
+ -d '{"query":"OpenAI","limit":3}' \
+ "http://127.0.0.1:20100/v1/search" \
+ | grep -q '"results"'
+
+docker_bin=""
+for candidate in /usr/local/bin/docker /opt/homebrew/bin/docker; do
+ if [[ -x "${candidate}" ]]; then
+ docker_bin="${candidate}"
+ break
+ fi
+done
+if [[ -n "${docker_bin}" ]]; then
+ goosed_container="$("${docker_bin}" ps --format '{{.Names}}' | grep -E '^goosed-prod-' | head -1)"
+ if [[ -n "${goosed_container}" ]]; then
+ "${docker_bin}" exec "${goosed_container}" sh -lc \
+ 'curl -fsS --max-time 5 http://host.docker.internal:20100/health >/dev/null'
+ fi
+fi
+
+trap - ERR
+printf '%s\n' "${RELEASE_ID}" > "${RUNTIME_BASE}/current-release.txt"
+REMOTE
+
+echo "Deep Search installed and verified on 103: ${RELEASE_ID}"
diff --git a/scripts/run-deep-search-prod.sh b/scripts/run-deep-search-prod.sh
new file mode 100644
index 0000000..842538e
--- /dev/null
+++ b/scripts/run-deep-search-prod.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+RUNTIME_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+RUNTIME_BASE="${DEEP_SEARCH_RUNTIME_BASE:-/Users/john/Project/deep-search-runtime}"
+ENV_FILE="${DEEP_SEARCH_ENV_FILE:-${RUNTIME_BASE}/shared/.env}"
+
+if [[ ! -f "${ENV_FILE}" ]]; then
+ echo "[deep-search-start] missing environment file: ${ENV_FILE}" >&2
+ exit 1
+fi
+
+set -a
+# shellcheck disable=SC1090
+source "${ENV_FILE}"
+set +a
+
+: "${TKMIND_DEEP_SEARCH_SECRET:?missing TKMIND_DEEP_SEARCH_SECRET}"
+
+export NODE_ENV=production
+export TKMIND_DEEP_SEARCH_HOST="${TKMIND_DEEP_SEARCH_HOST:-127.0.0.1}"
+export TKMIND_DEEP_SEARCH_PORT="${TKMIND_DEEP_SEARCH_PORT:-20100}"
+export TKMIND_DEEP_SEARCH_DB="${TKMIND_DEEP_SEARCH_DB:-${RUNTIME_BASE}/data/research.sqlite}"
+export TKMIND_DEEP_SEARCH_SEARXNG_URL="${TKMIND_DEEP_SEARCH_SEARXNG_URL:-http://127.0.0.1:20080/search}"
+export TKMIND_DEEP_SEARCH_LLM_GATEWAY_URL="${TKMIND_DEEP_SEARCH_LLM_GATEWAY_URL:-http://127.0.0.1:8081/api/internal/deep-search/llm}"
+export TKMIND_DEEP_SEARCH_LLM_GATEWAY_SECRET="${TKMIND_DEEP_SEARCH_LLM_GATEWAY_SECRET:-${TKMIND_DEEP_SEARCH_SECRET}}"
+
+mkdir -p "$(dirname "${TKMIND_DEEP_SEARCH_DB}")" "${RUNTIME_BASE}/logs"
+
+NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
+if [[ ! -x "${NODE_BIN}" ]]; then
+ NODE_BIN="$(command -v node)"
+fi
+
+exec "${NODE_BIN}" "${RUNTIME_ROOT}/deep-search-server.mjs"
diff --git a/server.mjs b/server.mjs
index 489224e..83b32d2 100644
--- a/server.mjs
+++ b/server.mjs
@@ -198,6 +198,7 @@ import { createScheduleService } from './schedule-service.mjs';
import { createFeedbackService } from './user-feedback.mjs';
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
+import { executeDeepSearchLlmGateway } from './deep-search-llm-gateway.mjs';
import { createDirectChatService, isDirectChatSessionId, isPortalDirectChatSnapshot, sendDirectChatSessionEvents, shouldExpirePortalDirectChatSnapshot } from './direct-chat-service.mjs';
import {
filterUserVisibleConversation,
@@ -254,6 +255,7 @@ const API_TARGETS = parseApiTargets();
const API_TARGET = API_TARGETS[0] ?? 'https://127.0.0.1:18006';
const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET;
+const DEEP_SEARCH_INTERNAL_SECRET = process.env.TKMIND_DEEP_SEARCH_SECRET ?? INTERNAL_AGENT_SECRET;
const mindSpaceServerRuntime = resolveMindSpaceServerRuntimeOptions(__dirname, process.env);
const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD;
const WECHAT_MP_CONFIG = loadWechatMpConfig();
@@ -2127,6 +2129,7 @@ api.use(async (req, res, next) => {
await userAuthReady;
if (req.path === '/status' || req.path === '/runtime/status') return next();
if (req.path.startsWith('/internal/agent/')) return next();
+ if (req.path === '/internal/deep-search/llm') return next();
if (req.path === '/agent/mindspace_page_patch') return next();
if (req.path === '/agent/mindspace_asset_delete') return next();
if (req.path === '/agent/mindspace_asset_download') return next();
@@ -2741,6 +2744,24 @@ function requireInternalAgentSecret(req, res) {
return false;
}
+api.post('/internal/deep-search/llm', async (req, res) => {
+ try {
+ const result = await executeDeepSearchLlmGateway({
+ llmProviderService,
+ expectedSecret: DEEP_SEARCH_INTERNAL_SECRET,
+ providedSecret: bearerToken(req) ?? req.get('x-secret-key'),
+ input: req.body ?? {},
+ });
+ return res.status(result.status).json(result.body);
+ } catch (error) {
+ console.warn('[deep-search] LLM gateway failed:', error);
+ return res.status(502).json({
+ ok: false,
+ message: error instanceof Error ? error.message : 'Deep Search LLM gateway failed',
+ });
+ }
+});
+
api.post('/mindspace/v1/agent/jobs', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
diff --git a/tkmind-search-mcp.mjs b/tkmind-search-mcp.mjs
index 4c01b16..52af9ad 100644
--- a/tkmind-search-mcp.mjs
+++ b/tkmind-search-mcp.mjs
@@ -1,7 +1,21 @@
import readline from 'node:readline';
-import { normalizeMindSearchConfig } from './mindsearch-config.mjs';
+import { normalizeMindSearchConfig, resolveMindSearchService } from './mindsearch-config.mjs';
import { SEARCH_ERROR_CODES, validateSearchRequest } from './search-capability.mjs';
-import { readSafeUrl, searchGithubCode, searchSearxng } from './mindsearch-providers.mjs';
+import {
+ cancelResearchTask,
+ getResearchTask,
+ readSafeUrl,
+ searchGithubCode,
+ searchGithubGateway,
+ searchResearchService,
+ searchSearxng,
+ startResearchTask,
+} from './mindsearch-providers.mjs';
+
+function parseJson(value, fallback) {
+ if (!value) return fallback;
+ try { return JSON.parse(value); } catch { return fallback; }
+}
const config = normalizeMindSearchConfig({
enabled: process.env.TKMIND_SEARCH_ENABLED,
@@ -11,12 +25,51 @@ const config = normalizeMindSearchConfig({
github: process.env.TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED,
reader: process.env.TKMIND_SEARCH_READER_ENABLED,
},
+ settings: {
+ searxngEndpoint: process.env.TKMIND_SEARCH_SEARXNG_URL,
+ maxResults: process.env.TKMIND_SEARCH_MAX_RESULTS,
+ timeoutMs: process.env.TKMIND_SEARCH_TIMEOUT_MS,
+ readerMaxChars: process.env.TKMIND_SEARCH_READER_MAX_CHARS,
+ },
+ services: parseJson(process.env.TKMIND_SEARCH_SERVICES_JSON, undefined),
+ routes: parseJson(process.env.TKMIND_SEARCH_ROUTES_JSON, undefined),
});
const tools = [
{ name: 'tkmind_search', description: 'Optional external search enhancement; existing web search remains primary.', inputSchema: { type: 'object', properties: { query: { type: 'string' }, type: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] } },
{ name: 'tkmind_read', description: 'Optional safe URL reader for search citations.', inputSchema: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } },
];
+if (resolveMindSearchService(config, 'research')) {
+ tools.push({
+ name: 'tkmind_research',
+ description: 'Start an asynchronous deep-research task and return its task identifier.',
+ inputSchema: {
+ type: 'object',
+ properties: { question: { type: 'string' }, depth: { type: 'string', enum: ['quick', 'standard', 'deep'] } },
+ required: ['question'],
+ },
+ });
+ tools.push(
+ {
+ name: 'tkmind_research_status',
+ description: 'Get Deep Search progress, evidence sources, and the completed report.',
+ inputSchema: {
+ type: 'object',
+ properties: { task_id: { type: 'string' } },
+ required: ['task_id'],
+ },
+ },
+ {
+ name: 'tkmind_research_cancel',
+ description: 'Cancel a queued or running Deep Search task.',
+ inputSchema: {
+ type: 'object',
+ properties: { task_id: { type: 'string' } },
+ required: ['task_id'],
+ },
+ },
+ );
+}
function response(id, result) { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); }
function error(id, code, message) { response(id, { isError: true, content: [{ type: 'text', text: `${code}: ${message}` }], structuredContent: { code, message } }); }
@@ -31,15 +84,60 @@ function handle(message) {
if (name === 'tkmind_search') {
const checked = validateSearchRequest(params.arguments ?? {});
if (!checked.ok) return error(id, checked.code, checked.message);
- if (checked.value.type === 'code' && config.providers.github) {
- return searchGithubCode(checked.value.query, { limit: checked.value.limit }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: 'github' }) }], structuredContent: { results, query: checked.value.query, provider: 'github' } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
+ const route = checked.value.type === 'code' ? 'code' : checked.value.type === 'news' ? 'news' : 'web';
+ const service = resolveMindSearchService(config, route);
+ if (service?.adapter === 'github') {
+ const operation = process.env.TKMIND_SEARCH_GITHUB_GATEWAY_URL
+ ? searchGithubGateway
+ : searchGithubCode;
+ return operation(checked.value.query, {
+ limit: checked.value.limit,
+ endpoint: process.env.TKMIND_SEARCH_GITHUB_GATEWAY_URL || service.endpoint || undefined,
+ }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: service.id }) }], structuredContent: { results, query: checked.value.query, provider: service.id } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
}
- if (config.providers.searxng) {
- return searchSearxng(checked.value.query, { limit: checked.value.limit }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: 'searxng' }) }], structuredContent: { results, query: checked.value.query, provider: 'searxng' } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
+ if (service?.adapter === 'searxng') {
+ return searchSearxng(checked.value.query, { limit: checked.value.limit, endpoint: service.endpoint }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: service.id }) }], structuredContent: { results, query: checked.value.query, provider: service.id } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
+ }
+ if (service?.adapter === 'research-http') {
+ return searchResearchService(checked.value.query, { endpoint: service.endpoint, type: checked.value.type, limit: checked.value.limit, timeoutMs: service.timeoutMs }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: service.id }) }], structuredContent: { results, query: checked.value.query, provider: service.id } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
}
return error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, 'No search provider is configured.');
}
- if (name === 'tkmind_read' && config.providers.reader) return readSafeUrl(params.arguments?.url).then((result) => response(id, { content: [{ type: 'text', text: result.content }], structuredContent: result })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
+ if (name === 'tkmind_read' && resolveMindSearchService(config, 'read')?.adapter === 'reader') return readSafeUrl(params.arguments?.url).then((result) => response(id, { content: [{ type: 'text', text: result.content }], structuredContent: result })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
+ if (name === 'tkmind_research') {
+ const question = String(params.arguments?.question ?? '').trim();
+ if (!question || question.length > 2000) return error(id, SEARCH_ERROR_CODES.INVALID_REQUEST, 'question must be 1-2000 characters');
+ const depth = ['quick', 'standard', 'deep'].includes(params.arguments?.depth) ? params.arguments.depth : 'standard';
+ const service = resolveMindSearchService(config, 'research');
+ if (service?.adapter !== 'research-http') return error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, 'No research service is configured.');
+ return startResearchTask(question, {
+ endpoint: service.endpoint,
+ depth,
+ userId: process.env.TKMIND_SEARCH_USER_ID || null,
+ llmProviderKeyId: service.llmProviderKeyId || '',
+ llmModel: service.llmModel || '',
+ timeoutMs: service.timeoutMs,
+ })
+ .then((result) => response(id, { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result }))
+ .catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
+ }
+ if (name === 'tkmind_research_status' || name === 'tkmind_research_cancel') {
+ const taskId = String(params.arguments?.task_id ?? '').trim();
+ if (!taskId || taskId.length > 200) return error(id, SEARCH_ERROR_CODES.INVALID_REQUEST, 'task_id must be 1-200 characters');
+ const service = resolveMindSearchService(config, 'research');
+ if (service?.adapter !== 'research-http') return error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, 'No research service is configured.');
+ const operation = name === 'tkmind_research_cancel' ? cancelResearchTask : getResearchTask;
+ return operation(taskId, {
+ endpoint: service.endpoint,
+ userId: process.env.TKMIND_SEARCH_USER_ID || null,
+ timeoutMs: service.timeoutMs,
+ })
+ .then((result) => response(id, {
+ content: [{ type: 'text', text: result.report || JSON.stringify(result) }],
+ structuredContent: result,
+ }))
+ .catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message));
+ }
return error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, 'No reader provider is configured.');
}
diff --git a/user-auth.mjs b/user-auth.mjs
index a241815..e5022d2 100644
--- a/user-auth.mjs
+++ b/user-auth.mjs
@@ -1868,7 +1868,12 @@ export function createUserAuth(pool, options = {}) {
await syncUserSkillsForUser(user);
if (capabilityState.unrestricted) {
return {
- ...buildAgentExtensionPolicy(capabilityState.capabilities, { unrestricted: true, toolMode, mindSearchConfig }),
+ ...buildAgentExtensionPolicy(capabilityState.capabilities, {
+ unrestricted: true,
+ toolMode,
+ mindSearchConfig,
+ userId: user.id,
+ }),
policies: {},
unrestricted: true,
toolMode,
@@ -1933,6 +1938,7 @@ export function createUserAuth(pool, options = {}) {
sandboxMcp,
toolMode,
mindSearchConfig,
+ userId: user.id,
}),
capabilities: effectiveCapabilities,
policies: policyState.policies,