diff --git a/capabilities.mjs b/capabilities.mjs
index 42ba737..c7576d6 100644
--- a/capabilities.mjs
+++ b/capabilities.mjs
@@ -401,7 +401,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' };
@@ -518,8 +525,16 @@ export function buildAgentExtensionPolicy(
TKMIND_SEARCH_READER_MAX_CHARS: String(mindSearchConfig.settings?.readerMaxChars ?? 12000),
TKMIND_SEARCH_SERVICES_JSON: JSON.stringify(mindSearchConfig.services ?? []),
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 ?? '',
},
- available_tools: ['tkmind_search', 'tkmind_read', ...(hasResearchService ? ['tkmind_research'] : [])],
+ available_tools: [
+ 'tkmind_search',
+ 'tkmind_read',
+ ...(hasResearchService
+ ? ['tkmind_research', 'tkmind_research_status', 'tkmind_research_cancel']
+ : []),
+ ],
});
}
if (capabilities.excel_analysis) {
diff --git a/deep-search-engine.mjs b/deep-search-engine.mjs
new file mode 100644
index 0000000..ee58bfe
--- /dev/null
+++ b/deep-search-engine.mjs
@@ -0,0 +1,698 @@
+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',
+ llm: {
+ 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',
+ });
+ const task = await waitFor(() => {
+ const current = engine.getTask('task-llm');
+ return current.status === 'completed' ? current : null;
+ });
+ assert.equal(task.report, llmReport);
+ 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 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' });
+ const server = createDeepSearchHttpServer({ engine, secret: 'local-secret', 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((await fetch(`${base}/v1/research`)).status, 401);
+ const headers = { 'content-type': 'application/json', 'x-secret-key': 'local-secret' };
+ 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' }),
+ }).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.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..376bdaa
--- /dev/null
+++ b/docs/deep-search-service.md
@@ -0,0 +1,107 @@
+# 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 OpenAI-compatible model when configured. If the 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 \
+npm run dev:deep-search
+```
+
+The server listens on `127.0.0.1:20100` by default.
+
+## 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_URL` | empty | OpenAI-compatible chat-completions endpoint |
+| `TKMIND_DEEP_SEARCH_LLM_API_KEY` | empty | Optional model API key |
+| `TKMIND_DEEP_SEARCH_LLM_MODEL` | empty | Planner and report model |
+
+## Isolation and safety
+
+- The default bind address is loopback only.
+- An optional service secret protects all non-health endpoints.
+- 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.
+
+## 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
+}
+```
+
+After local verification, enable the service and MindSearch in `memindadm`, 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 841c1d2..ba598b9 100644
--- a/mindsearch-config.mjs
+++ b/mindsearch-config.mjs
@@ -42,6 +42,18 @@ const BUILTIN_SERVICES = Object.freeze([
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,
+ },
]);
export const MINDSEARCH_DEFAULT_CONFIG = Object.freeze({
@@ -50,7 +62,7 @@ export const MINDSEARCH_DEFAULT_CONFIG = Object.freeze({
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: '' },
+ routes: { web: 'searxng', news: 'searxng', code: 'github', read: 'reader', research: 'deep-search' },
});
const clone = (value) => JSON.parse(JSON.stringify(value));
@@ -115,7 +127,9 @@ function builtinsFromLegacy(input = {}) {
endpoint: service.adapter === 'searxng'
? String(settings.searxngEndpoint ?? service.endpoint)
: service.endpoint,
- timeoutMs: settings.timeoutMs ?? service.timeoutMs,
+ timeoutMs: service.adapter === 'research-http'
+ ? service.timeoutMs
+ : (settings.timeoutMs ?? service.timeoutMs),
}, service));
}
@@ -185,7 +199,12 @@ export async function probeMindSearchService(service, { fetchImpl = fetch } = {}
try {
const response = await fetchImpl(url, {
signal: AbortSignal.timeout(normalized.timeoutMs),
- headers: { accept: 'application/json,text/plain' },
+ 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) {
diff --git a/mindsearch-providers.mjs b/mindsearch-providers.mjs
index dfa53ef..e27bc6c 100644
--- a/mindsearch-providers.mjs
+++ b/mindsearch-providers.mjs
@@ -1,11 +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));
@@ -37,11 +47,21 @@ function serviceUrl(endpoint, path) {
return new URL(path.replace(/^\//, ''), base);
}
-async function postServiceJson(endpoint, path, payload, { timeoutMs = 30000, fetchImpl = fetch } = {}) {
+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' },
+ 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}`);
@@ -54,8 +74,13 @@ export async function searchResearchService(query, {
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 });
+ 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));
@@ -64,10 +89,16 @@ export async function searchResearchService(query, {
export async function startResearchTask(question, {
endpoint,
depth = 'standard',
+ userId = null,
timeoutMs = 30000,
fetchImpl = fetch,
+ secret = process.env.TKMIND_DEEP_SEARCH_SECRET,
} = {}) {
- const body = await postServiceJson(endpoint, '/v1/research', { question, depth }, { timeoutMs, fetchImpl });
+ const body = await postServiceJson(endpoint, '/v1/research', {
+ question,
+ depth,
+ ...(userId ? { userId } : {}),
+ }, { 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),
@@ -75,3 +106,44 @@ export async function startResearchTask(question, {
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 41301c3..7980bc8 100644
--- a/mindsearch.test.mjs
+++ b/mindsearch.test.mjs
@@ -9,6 +9,8 @@ import {
} from './mindsearch-config.mjs';
import { buildAgentExtensionPolicy, DEFAULT_USER_CAPABILITIES } from './capabilities.mjs';
import {
+ cancelResearchTask,
+ getResearchTask,
searchGithubCode,
searchResearchService,
searchSearxng,
@@ -22,7 +24,8 @@ test('MindSearch defaults to disabled and normalizes unsafe modes', () => {
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: '' });
+ 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', () => {
@@ -89,6 +92,20 @@ 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: {},
+ services: [{ id: 'deep-search', enabled: true, adapter: 'research-http' }],
+ 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.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 () => {
@@ -111,12 +128,40 @@ test('Research service adapter uses the stable HTTP contract', async () => {
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', fetchImpl });
+ const task = await startResearchTask('Analyze AI Agent', {
+ endpoint: 'http://research.local',
+ depth: 'deep',
+ userId: 'user-1',
+ 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' });
+ assert.deepEqual(JSON.parse(requests[1].options.body), {
+ question: 'Analyze AI Agent',
+ depth: 'deep',
+ userId: 'user-1',
+ });
+ 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..738eb46 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",
@@ -61,6 +62,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/tkmind-search-mcp.mjs b/tkmind-search-mcp.mjs
index 553a4bd..20a6bfb 100644
--- a/tkmind-search-mcp.mjs
+++ b/tkmind-search-mcp.mjs
@@ -2,6 +2,8 @@ import readline from 'node:readline';
import { normalizeMindSearchConfig, resolveMindSearchService } from './mindsearch-config.mjs';
import { SEARCH_ERROR_CODES, validateSearchRequest } from './search-capability.mjs';
import {
+ cancelResearchTask,
+ getResearchTask,
readSafeUrl,
searchGithubCode,
searchResearchService,
@@ -46,6 +48,26 @@ if (resolveMindSearchService(config, 'research')) {
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`); }
@@ -81,10 +103,32 @@ function handle(message) {
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, timeoutMs: service.timeoutMs })
+ return startResearchTask(question, {
+ endpoint: service.endpoint,
+ depth,
+ userId: process.env.TKMIND_SEARCH_USER_ID || null,
+ 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,