@zleap-ai/dsh-sag 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +58 -0
  4. package/README.zh.md +58 -0
  5. package/THIRD_PARTY_NOTICES +671 -0
  6. package/cordis.patch.yml +5 -0
  7. package/docs/embedded.md +61 -0
  8. package/lib/brand.d.ts +13 -0
  9. package/lib/brand.js +15 -0
  10. package/lib/cli/runtime.d.ts +40 -0
  11. package/lib/cli/runtime.js +121 -0
  12. package/lib/cli.d.ts +21 -0
  13. package/lib/cli.js +265 -0
  14. package/lib/config.d.ts +60 -0
  15. package/lib/config.js +120 -0
  16. package/lib/connection/descriptor.d.ts +4 -0
  17. package/lib/connection/descriptor.js +66 -0
  18. package/lib/connection/discovery.d.ts +40 -0
  19. package/lib/connection/discovery.js +139 -0
  20. package/lib/connection/guidance.d.ts +7 -0
  21. package/lib/connection/guidance.js +7 -0
  22. package/lib/connection/manager.d.ts +67 -0
  23. package/lib/connection/manager.js +273 -0
  24. package/lib/connection/store.d.ts +42 -0
  25. package/lib/connection/store.js +164 -0
  26. package/lib/connection/types.d.ts +35 -0
  27. package/lib/connection/types.js +2 -0
  28. package/lib/dsh-sag-cli.js +32202 -0
  29. package/lib/index.d.ts +11 -0
  30. package/lib/index.js +68 -0
  31. package/lib/local/api-client.d.ts +157 -0
  32. package/lib/local/api-client.js +338 -0
  33. package/lib/local/gateway.d.ts +43 -0
  34. package/lib/local/gateway.js +34 -0
  35. package/lib/local/mcp-probe.d.ts +27 -0
  36. package/lib/local/mcp-probe.js +92 -0
  37. package/lib/presentation.d.ts +8 -0
  38. package/lib/presentation.js +7 -0
  39. package/lib/runtime/client.d.ts +22 -0
  40. package/lib/runtime/client.js +117 -0
  41. package/lib/runtime/protocol.d.ts +113 -0
  42. package/lib/runtime/protocol.js +118 -0
  43. package/lib/runtime/supervisor.d.ts +30 -0
  44. package/lib/runtime/supervisor.js +107 -0
  45. package/lib/tools/documents.d.ts +8 -0
  46. package/lib/tools/documents.js +134 -0
  47. package/lib/tools/ingest.d.ts +5 -0
  48. package/lib/tools/ingest.js +35 -0
  49. package/lib/tools/local.d.ts +40 -0
  50. package/lib/tools/local.js +111 -0
  51. package/lib/tools/output.d.ts +96 -0
  52. package/lib/tools/output.js +67 -0
  53. package/lib/tools/read.d.ts +6 -0
  54. package/lib/tools/read.js +84 -0
  55. package/lib/tools/search.d.ts +6 -0
  56. package/lib/tools/search.js +107 -0
  57. package/lib/tools/sources.d.ts +5 -0
  58. package/lib/tools/sources.js +61 -0
  59. package/lib/tools/status.d.ts +5 -0
  60. package/lib/tools/status.js +28 -0
  61. package/lib/tools/upload.d.ts +6 -0
  62. package/lib/tools/upload.js +68 -0
  63. package/package.json +96 -0
  64. package/runtime/pyproject.toml +29 -0
  65. package/runtime/src/dsh_sag_runtime/__init__.py +3 -0
  66. package/runtime/src/dsh_sag_runtime/__main__.py +80 -0
  67. package/runtime/src/dsh_sag_runtime/engines.py +139 -0
  68. package/runtime/src/dsh_sag_runtime/errors.py +47 -0
  69. package/runtime/src/dsh_sag_runtime/evidence.py +53 -0
  70. package/runtime/src/dsh_sag_runtime/protocol.py +161 -0
  71. package/runtime/src/dsh_sag_runtime/read.py +61 -0
  72. package/runtime/src/dsh_sag_runtime/search.py +76 -0
  73. package/runtime/src/dsh_sag_runtime/server.py +136 -0
  74. package/runtime/uv.lock +2310 -0
  75. package/scripts/setup-runtime.mjs +47 -0
@@ -0,0 +1,107 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools';
2
+ import { presentSearchCall } from '../presentation.js';
3
+ import { localInputError, localOperation, requireToolCapability } from './local.js';
4
+ import { SEARCH_OUTPUT_SCHEMA, renderSearch } from './output.js';
5
+ const LOCAL_SEARCH_OUTPUT_SCHEMA = {
6
+ type: 'object', additionalProperties: false, properties: {
7
+ query: { type: 'string', required: true },
8
+ evidences: { type: 'array', required: true, items: {
9
+ type: 'object', additionalProperties: false, properties: {
10
+ evidenceRef: { type: 'string', required: true }, sourceId: { type: 'string', required: true },
11
+ sourceName: { oneOf: [{ type: 'string' }, { type: 'null' }], required: true },
12
+ chunkId: { type: 'string', required: true }, title: { type: 'string', required: true },
13
+ excerpt: { type: 'string', required: true }, score: { type: 'number', required: true }, rank: { type: 'integer', required: true },
14
+ },
15
+ } },
16
+ },
17
+ };
18
+ function renderLocalSearch(value) {
19
+ const result = value;
20
+ if (result.evidences.length === 0)
21
+ return 'No SAG evidence matched the query.';
22
+ return result.evidences.map((evidence, index) => `${index + 1}. [${evidence.sourceName ?? evidence.sourceId}] ${evidence.title} (score ${evidence.score.toFixed(3)})\n ${evidence.excerpt}\n evidence_ref: ${evidence.evidenceRef}`).join('\n\n');
23
+ }
24
+ function searchResult(value) {
25
+ if (value === null || typeof value !== 'object' || !('evidences' in value))
26
+ throw new Error('SAG runtime returned the wrong search result type');
27
+ return value;
28
+ }
29
+ function createEmbeddedSearchTool(client, config) {
30
+ const allowed = new Set(config.namespaces.map(namespace => namespace.id));
31
+ const labels = new Map(config.namespaces.map(namespace => [namespace.id, namespace.label]));
32
+ return defineTool({
33
+ name: 'sag_search',
34
+ description: 'Search configured embedded SAG namespaces and return evidence_ref values for follow-up sag_read calls.',
35
+ parameters: {
36
+ query: { type: 'string', required: true, description: 'Natural-language or identifier search query.' },
37
+ namespaces: { type: 'array', items: { type: 'string' }, description: 'Optional configured namespace ids.' },
38
+ mode: { type: 'string', enum: ['fast', 'precise'], description: 'Embedded retrieval expansion mode.' },
39
+ limit: { type: 'integer', description: 'Maximum evidence items requested.' },
40
+ },
41
+ output: { schema: SEARCH_OUTPUT_SCHEMA, render: (_args, value) => [{ type: 'text', text: renderSearch(value, labels) }] },
42
+ timeoutMs: config.requestTimeoutMs,
43
+ isConcurrencySafe: () => true,
44
+ async execute(args, exec) {
45
+ const query = args.query.trim();
46
+ if (!query)
47
+ throw new Error('sag_search query must not be empty');
48
+ const namespaces = args.namespaces ?? config.namespaces.map(namespace => namespace.id);
49
+ if (namespaces.length === 0 || namespaces.some(namespace => !allowed.has(namespace)))
50
+ throw new Error('sag_search namespaces must be configured');
51
+ const requestedLimit = args.limit ?? config.maxResults;
52
+ if (!Number.isInteger(requestedLimit) || requestedLimit < 1)
53
+ throw new Error('sag_search limit must be positive');
54
+ return searchResult(await client.request('search', {
55
+ query, namespaces: [...new Set(namespaces)], mode: args.mode ?? config.defaultMode,
56
+ limit: Math.min(requestedLimit, config.maxResults),
57
+ }, exec.signal));
58
+ },
59
+ presentCall: args => presentSearchCall(args),
60
+ });
61
+ }
62
+ function createLocalSearchTool(manager, config) {
63
+ return defineTool({
64
+ name: 'sag_search',
65
+ description: 'Search one or more local SAG knowledge bases and return opaque evidence_ref values for sag_read.',
66
+ parameters: {
67
+ query: { type: 'string', required: true, description: 'Natural-language or identifier search query.' },
68
+ source_ids: { type: 'array', items: { type: 'string' }, description: 'Optional local SAG knowledge-base ids.' },
69
+ strategy: { type: 'string', enum: ['vector', 'multi', 'multi_es_fast'], description: 'SAG search strategy.' },
70
+ limit: { type: 'integer', description: 'Maximum evidence items, from 1 through 50.' },
71
+ },
72
+ output: { schema: LOCAL_SEARCH_OUTPUT_SCHEMA, render: (_args, value) => [{ type: 'text', text: renderLocalSearch(value) }] },
73
+ timeoutMs: config.requestTimeoutMs,
74
+ isConcurrencySafe: () => true,
75
+ execute(args, exec) {
76
+ return localOperation(manager, exec.signal, async (connection) => {
77
+ requireToolCapability(connection, 'sag_search');
78
+ const query = args.query.trim();
79
+ if (!query)
80
+ localInputError('sag_search query must not be empty');
81
+ if (args.limit !== undefined && (!Number.isInteger(args.limit) || args.limit < 1 || args.limit > 50))
82
+ localInputError('sag_search limit must be an integer from 1 to 50');
83
+ const sourceIds = args.source_ids?.map(value => value.trim()).filter(Boolean);
84
+ if (args.source_ids !== undefined && sourceIds?.length === 0)
85
+ localInputError('sag_search source_ids must contain a knowledge-base id');
86
+ const result = await connection.gateway.search({
87
+ query,
88
+ ...(sourceIds === undefined ? {} : { sourceIds: [...new Set(sourceIds)] }),
89
+ ...(args.limit === undefined ? {} : { topK: args.limit }),
90
+ ...(args.strategy === undefined ? {} : { strategy: args.strategy }),
91
+ }, exec.signal);
92
+ return { query: result.query, evidences: result.evidences.map(evidence => ({
93
+ evidenceRef: evidence.evidenceRef, sourceId: evidence.sourceId, sourceName: evidence.sourceName, chunkId: evidence.chunkId,
94
+ title: evidence.heading || evidence.sourceName || evidence.chunkId, excerpt: evidence.content, score: evidence.score, rank: evidence.rank,
95
+ })) };
96
+ });
97
+ },
98
+ presentCall: args => presentSearchCall(args),
99
+ });
100
+ }
101
+ /** Build the mode-specific model-facing SAG search tool. */
102
+ export function createSearchTool(client, config) {
103
+ return config.mode === 'embedded'
104
+ ? createEmbeddedSearchTool(client, config)
105
+ : createLocalSearchTool(client, config);
106
+ }
107
+ //# sourceMappingURL=search.js.map
@@ -0,0 +1,5 @@
1
+ import type { ResolvedLocalConfig } from '../config.js';
2
+ import { type ConnectionManager } from './local.js';
3
+ /** Build source-list and source-creation tools for local SAG. */
4
+ export declare function createSourceTools(manager: ConnectionManager, config: ResolvedLocalConfig): readonly [import("@deepseek-ai/dsh-tools").ToolDefinition, import("@deepseek-ai/dsh-tools").ToolDefinition];
5
+ //# sourceMappingURL=sources.d.ts.map
@@ -0,0 +1,61 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools';
2
+ import { jsonRender, localInputError, localOperation, requireToolCapability } from './local.js';
3
+ const SOURCE = {
4
+ type: 'object', additionalProperties: false, properties: {
5
+ id: { type: 'string', required: true }, name: { type: 'string', required: true },
6
+ description: { type: 'string' }, status: { type: 'string' },
7
+ documentCount: { type: 'integer' }, chunkCount: { type: 'integer' },
8
+ },
9
+ };
10
+ function sourceValue(source) {
11
+ return {
12
+ id: source.id, name: source.name,
13
+ ...(typeof source.description === 'string' ? { description: source.description } : {}),
14
+ ...(typeof source.status === 'string' ? { status: source.status } : {}),
15
+ ...(typeof source.documentCount === 'number' ? { documentCount: source.documentCount } : {}),
16
+ ...(typeof source.chunkCount === 'number' ? { chunkCount: source.chunkCount } : {}),
17
+ };
18
+ }
19
+ /** Build source-list and source-creation tools for local SAG. */
20
+ export function createSourceTools(manager, config) {
21
+ const list = defineTool({
22
+ name: 'sag_list_sources',
23
+ description: 'List local SAG knowledge bases with stable ids for search, upload, and document operations.',
24
+ parameters: {},
25
+ output: {
26
+ schema: { type: 'object', additionalProperties: false, properties: { sources: { type: 'array', required: true, items: SOURCE } } },
27
+ render: (_args, value) => jsonRender(value),
28
+ },
29
+ timeoutMs: config.requestTimeoutMs,
30
+ isConcurrencySafe: () => true,
31
+ async execute(_args, exec) {
32
+ return localOperation(manager, exec.signal, async (connection) => {
33
+ requireToolCapability(connection, 'sag_list_sources');
34
+ return { sources: (await connection.gateway.listSources(exec.signal)).map(sourceValue) };
35
+ });
36
+ },
37
+ presentCall: () => ({ card: 'generic', title: 'List SAG knowledge bases', kind: 'read' }),
38
+ });
39
+ const create = defineTool({
40
+ name: 'sag_create_source',
41
+ description: 'Create a local SAG knowledge base that can receive uploaded files or directly ingested text.',
42
+ parameters: {
43
+ name: { type: 'string', required: true, description: 'Knowledge-base name.' },
44
+ description: { type: 'string', description: 'Optional description.' },
45
+ },
46
+ output: { schema: SOURCE, render: (_args, value) => jsonRender(value) },
47
+ timeoutMs: config.requestTimeoutMs,
48
+ async execute(args, exec) {
49
+ return localOperation(manager, exec.signal, async (connection) => {
50
+ requireToolCapability(connection, 'sag_create_source');
51
+ const name = args.name.trim();
52
+ if (!name)
53
+ localInputError('sag_create_source name must not be empty');
54
+ return sourceValue(await connection.gateway.createSource({ name, ...(args.description === undefined ? {} : { description: args.description }) }, exec.signal));
55
+ });
56
+ },
57
+ presentCall: args => ({ card: 'generic', title: 'Create SAG knowledge base', kind: 'edit', rawInput: args.name }),
58
+ });
59
+ return [list, create];
60
+ }
61
+ //# sourceMappingURL=sources.js.map
@@ -0,0 +1,5 @@
1
+ import type { ResolvedLocalConfig } from '../config.js';
2
+ import { type ConnectionManager } from './local.js';
3
+ /** Build a read-only summary of the current local SAG connection. */
4
+ export declare function createStatusTool(manager: ConnectionManager, config: ResolvedLocalConfig): import("@deepseek-ai/dsh-tools").ToolDefinition;
5
+ //# sourceMappingURL=status.d.ts.map
@@ -0,0 +1,28 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools';
2
+ import { jsonRender, localOperation } from './local.js';
3
+ const STATUS_OUTPUT = {
4
+ type: 'object', additionalProperties: false, properties: {
5
+ status: { type: 'string', required: true },
6
+ name: { type: 'string', required: true },
7
+ sourceCount: { type: 'integer', required: true },
8
+ capabilities: { type: 'array', required: true, items: { type: 'string' } },
9
+ },
10
+ };
11
+ /** Build a read-only summary of the current local SAG connection. */
12
+ export function createStatusTool(manager, config) {
13
+ return defineTool({
14
+ name: 'sag_status',
15
+ description: 'Check the local SAG connection and report its public capabilities without exposing credentials.',
16
+ parameters: {},
17
+ output: { schema: STATUS_OUTPUT, render: (_args, value) => jsonRender(value) },
18
+ timeoutMs: config.requestTimeoutMs,
19
+ isConcurrencySafe: () => true,
20
+ async execute(_args, exec) {
21
+ return localOperation(manager, exec.signal, async (connection) => {
22
+ return { status: 'ready', name: connection.descriptor.name, sourceCount: connection.sourceCount, capabilities: [...connection.capabilities.capabilities] };
23
+ });
24
+ },
25
+ presentCall: () => ({ card: 'generic', title: 'Check SAG status', kind: 'fetch' }),
26
+ });
27
+ }
28
+ //# sourceMappingURL=status.js.map
@@ -0,0 +1,6 @@
1
+ import type { FileSystem } from '@deepseek-ai/dsh-fs';
2
+ import type { ResolvedLocalConfig } from '../config.js';
3
+ import { type ConnectionManager } from './local.js';
4
+ /** Build a bounded file-upload tool that reads exclusively through dsh FS. */
5
+ export declare function createUploadTool(manager: ConnectionManager, config: ResolvedLocalConfig, fs: FileSystem): import("@deepseek-ai/dsh-tools").ToolDefinition;
6
+ //# sourceMappingURL=upload.d.ts.map
@@ -0,0 +1,68 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools';
2
+ import { jsonRender, localInputError, localOperation, requireToolCapability, selectSource } from './local.js';
3
+ const ACCEPTED_OUTPUT = {
4
+ type: 'object', additionalProperties: false, properties: {
5
+ accepted: { type: 'boolean', required: true }, documentId: { type: 'string', required: true },
6
+ sourceId: { type: 'string', required: true }, status: { type: 'string', required: true },
7
+ },
8
+ };
9
+ function basename(path) {
10
+ return path.split(/[\\/]/).at(-1) ?? path;
11
+ }
12
+ function extension(filename) {
13
+ const index = filename.lastIndexOf('.');
14
+ return index < 0 ? '' : filename.slice(index + 1).toLowerCase();
15
+ }
16
+ function contentType(ext) {
17
+ if (ext === 'md' || ext === 'markdown')
18
+ return 'text/markdown';
19
+ if (ext === 'txt')
20
+ return 'text/plain';
21
+ if (ext === 'json')
22
+ return 'application/json';
23
+ if (ext === 'pdf')
24
+ return 'application/pdf';
25
+ return 'application/octet-stream';
26
+ }
27
+ /** Build a bounded file-upload tool that reads exclusively through dsh FS. */
28
+ export function createUploadTool(manager, config, fs) {
29
+ return defineTool({
30
+ name: 'sag_upload_file',
31
+ description: 'Upload one admitted local file to a SAG knowledge base and return its asynchronous processing status.',
32
+ parameters: {
33
+ path: { type: 'string', required: true, description: 'File path resolved through the dsh filesystem.' },
34
+ source_id: { type: 'string', description: 'Target knowledge-base id; omitted only when a default or single source exists.' },
35
+ },
36
+ output: { schema: ACCEPTED_OUTPUT, render: (_args, value) => jsonRender(value) },
37
+ timeoutMs: config.requestTimeoutMs,
38
+ async execute(args, exec) {
39
+ return localOperation(manager, exec.signal, async (connection) => {
40
+ requireToolCapability(connection, 'sag_upload_file');
41
+ const upload = connection.capabilities.upload;
42
+ if (upload === undefined)
43
+ localInputError('This SAG version does not provide documents.upload limits. Upgrade SAG and retry.');
44
+ const sourceId = await selectSource(connection, args.source_id, exec.signal);
45
+ const target = await fs.resolve(args.path, { signal: exec.signal });
46
+ const info = await fs.stat(target, exec.signal);
47
+ if (info?.type !== 'file')
48
+ localInputError('sag_upload_file 只能上传普通文件。');
49
+ const filename = basename(target.displayPath);
50
+ const ext = extension(filename);
51
+ const allowed = upload.extensions.map(value => value.toLowerCase());
52
+ if (!allowed.includes(ext))
53
+ localInputError(`SAG 不允许 .${ext || '(无扩展名)'} 文件;可用扩展名:${allowed.join('、')}`);
54
+ const sagLimit = upload.maxMb * 1024 * 1024;
55
+ const limit = Math.min(sagLimit, config.maxUploadBytes);
56
+ if (info.size !== undefined && info.size > limit)
57
+ localInputError(`文件超过上传上限 ${Math.floor(limit / 1024 / 1024)} MiB。`);
58
+ const bytes = await fs.readBytes(target, exec.signal, limit);
59
+ if (bytes.length > limit)
60
+ localInputError(`文件超过上传上限 ${Math.floor(limit / 1024 / 1024)} MiB。`);
61
+ const document = await connection.gateway.uploadFile({ sourceId, filename, contentType: contentType(ext), bytes }, exec.signal);
62
+ return { accepted: true, documentId: document.id, sourceId, status: document.status ?? 'pending' };
63
+ });
64
+ },
65
+ presentCall: args => ({ card: 'generic', title: 'Upload file to SAG', kind: 'edit', rawInput: args.path, locations: [{ path: args.path }] }),
66
+ });
67
+ }
68
+ //# sourceMappingURL=upload.js.map
package/package.json ADDED
@@ -0,0 +1,96 @@
1
+ {
2
+ "name": "@zleap-ai/dsh-sag",
3
+ "description": "Local SAG personal knowledge base plugin for DeepSeek Harness",
4
+ "version": "0.1.0",
5
+ "keywords": [
6
+ "deepseek-harness",
7
+ "dsh",
8
+ "dsh-plugin",
9
+ "sag",
10
+ "knowledge-base"
11
+ ],
12
+ "homepage": "https://github.com/Zleap-AI/dsh-sag#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/Zleap-AI/dsh-sag/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Zleap-AI/dsh-sag.git"
19
+ },
20
+ "type": "module",
21
+ "main": "lib/index.js",
22
+ "types": "lib/index.d.ts",
23
+ "bin": {
24
+ "dsh-sag": "./lib/dsh-sag-cli.js"
25
+ },
26
+ "exports": {
27
+ ".": {
28
+ "types": "./lib/index.d.ts",
29
+ "default": "./lib/index.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "files": [
34
+ "lib/**/*.js",
35
+ "lib/**/*.d.ts",
36
+ "cordis.patch.yml",
37
+ "runtime",
38
+ "scripts/setup-runtime.mjs",
39
+ "docs/embedded.md",
40
+ "THIRD_PARTY_NOTICES",
41
+ "CHANGELOG.md",
42
+ "README.md",
43
+ "README.zh.md",
44
+ "LICENSE"
45
+ ],
46
+ "dsh": {
47
+ "bundle": {
48
+ "patch": "./cordis.patch.yml"
49
+ }
50
+ },
51
+ "engines": {
52
+ "node": "^22.19.0 || >=24.0.0"
53
+ },
54
+ "dependencies": {
55
+ "@deepseek-ai/schemastery": "3.18.1",
56
+ "@modelcontextprotocol/sdk": "^1.12.0"
57
+ },
58
+ "peerDependencies": {
59
+ "@deepseek-ai/cordis": "^4.0.1",
60
+ "@deepseek-ai/dsh-credentials": "0.1.1-rc.2",
61
+ "@deepseek-ai/dsh-credentials-local": "0.1.1-rc.2",
62
+ "@deepseek-ai/dsh-fs": "0.1.1-rc.2",
63
+ "@deepseek-ai/dsh-llm": "0.1.1-rc.2",
64
+ "@deepseek-ai/dsh-session": "0.1.1-rc.2",
65
+ "@deepseek-ai/dsh-settings": "0.1.1-rc.2",
66
+ "@deepseek-ai/dsh-settings-file": "0.1.1-rc.2",
67
+ "@deepseek-ai/dsh-subprocess": "0.1.1-rc.2",
68
+ "@deepseek-ai/dsh-subprocess-local": "0.1.1-rc.2",
69
+ "@deepseek-ai/dsh-system-prompt": "0.1.1-rc.2",
70
+ "@deepseek-ai/dsh-tools": "0.1.1-rc.2"
71
+ },
72
+ "devDependencies": {
73
+ "@deepseek-ai/cordis": "4.0.1",
74
+ "@deepseek-ai/dsh-credentials": "0.1.1-rc.2",
75
+ "@deepseek-ai/dsh-credentials-local": "0.1.1-rc.2",
76
+ "@deepseek-ai/dsh-fs": "0.1.1-rc.2",
77
+ "@deepseek-ai/dsh-llm": "0.1.1-rc.2",
78
+ "@deepseek-ai/dsh-session": "0.1.1-rc.2",
79
+ "@deepseek-ai/dsh-settings": "0.1.1-rc.2",
80
+ "@deepseek-ai/dsh-settings-file": "0.1.1-rc.2",
81
+ "@deepseek-ai/dsh-subprocess": "0.1.1-rc.2",
82
+ "@deepseek-ai/dsh-subprocess-local": "0.1.1-rc.2",
83
+ "@deepseek-ai/dsh-system-prompt": "0.1.1-rc.2",
84
+ "@deepseek-ai/dsh-tools": "0.1.1-rc.2",
85
+ "@types/node": "^24.3.0",
86
+ "typescript": "^5.9.2"
87
+ },
88
+ "license": "MIT",
89
+ "publishConfig": {
90
+ "access": "public"
91
+ },
92
+ "scripts": {
93
+ "build": "tsc -p tsconfig.json && node ../../scripts/build-cli.mjs && node ../../scripts/sync-runtime.mjs",
94
+ "typecheck": "tsc -p tsconfig.json --noEmit"
95
+ }
96
+ }
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "dsh-sag-runtime"
7
+ version = "0.1.0"
8
+ description = "Managed zleap-sag sidecar for the dsh-sag plugin"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "pydantic>=2.5,<3",
12
+ "zleap-sag==0.10.0",
13
+ ]
14
+
15
+ [project.scripts]
16
+ dsh-sag-runtime = "dsh_sag_runtime.__main__:main"
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "pytest>=8.4,<9",
21
+ "pytest-asyncio>=1.1,<2",
22
+ ]
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/dsh_sag_runtime"]
26
+
27
+ [tool.pytest.ini_options]
28
+ asyncio_mode = "auto"
29
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """Managed zleap-sag runtime for the dsh-sag plugin."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,80 @@
1
+ """stdio entrypoint for the managed dsh-sag runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import importlib.metadata
8
+ import sys
9
+ from typing import BinaryIO, TextIO
10
+
11
+ from zleap.sag import EngineConfig
12
+
13
+ from .engines import EnginePool
14
+ from .evidence import EvidenceRefCodec
15
+ from .protocol import REQUIRED_ENGINE_VERSION, decode_request, encode_response
16
+ from .read import ReadAdapter
17
+ from .search import SearchAdapter
18
+ from .server import RuntimeServer
19
+
20
+
21
+ async def run_stdio(server: RuntimeServer, reader: BinaryIO, writer: BinaryIO, diagnostics: TextIO) -> None:
22
+ """Serve requests until stdin closes, keeping stdout protocol-only."""
23
+ write_lock = asyncio.Lock()
24
+ tasks: set[asyncio.Task[None]] = set()
25
+
26
+ async def process(frame: bytes) -> None:
27
+ try:
28
+ request = decode_request(frame.rstrip(b"\r\n"))
29
+ response = await server.accept(request)
30
+ if response is not None:
31
+ encoded = encode_response(response)
32
+ async with write_lock:
33
+ writer.write(encoded)
34
+ writer.flush()
35
+ except Exception as error:
36
+ print(f"dsh-sag-runtime rejected a frame: {type(error).__name__}", file=diagnostics, flush=True)
37
+
38
+ try:
39
+ while frame := await asyncio.to_thread(reader.readline):
40
+ task = asyncio.create_task(process(frame))
41
+ tasks.add(task)
42
+ task.add_done_callback(tasks.discard)
43
+ if tasks:
44
+ await asyncio.gather(*tasks, return_exceptions=True)
45
+ finally:
46
+ await server.aclose()
47
+
48
+
49
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
50
+ parser = argparse.ArgumentParser(prog="dsh-sag-runtime")
51
+ parser.add_argument("--env-file", required=True)
52
+ parser.add_argument("--namespace", action="append", required=True)
53
+ parser.add_argument("--max-read-engines", type=int, default=4)
54
+ parser.add_argument("--max-results", type=int, default=20)
55
+ parser.add_argument("--max-excerpt-chars", type=int, default=1200)
56
+ parser.add_argument("--max-read-chars", type=int, default=40_000)
57
+ return parser.parse_args(argv)
58
+
59
+
60
+ async def async_main(argv: list[str] | None = None) -> None:
61
+ args = parse_args(argv)
62
+ engine_version = importlib.metadata.version("zleap-sag")
63
+ if engine_version != REQUIRED_ENGINE_VERSION:
64
+ raise RuntimeError(f"zleap-sag {REQUIRED_ENGINE_VERSION} is required")
65
+ namespaces = tuple(dict.fromkeys(args.namespace))
66
+ config = EngineConfig.from_env(args.env_file)
67
+ pool = EnginePool(config, set(namespaces), max_read_engines=args.max_read_engines)
68
+ codec = EvidenceRefCodec(set(namespaces))
69
+ search = SearchAdapter(pool, codec, set(namespaces), max_results=args.max_results, max_excerpt_chars=args.max_excerpt_chars)
70
+ read = ReadAdapter(pool, codec, max_read_chars=args.max_read_chars, max_events=20)
71
+ server = RuntimeServer(pool, search, read, namespaces, engine_version=engine_version)
72
+ await run_stdio(server, sys.stdin.buffer, sys.stdout.buffer, sys.stderr)
73
+
74
+
75
+ def main() -> None:
76
+ asyncio.run(async_main())
77
+
78
+
79
+ if __name__ == "__main__":
80
+ main()
@@ -0,0 +1,139 @@
1
+ """Owned zleap-sag engine lifecycle with a bounded read-engine LRU."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections import OrderedDict
7
+ from collections.abc import AsyncIterator, Callable
8
+ from contextlib import asynccontextmanager
9
+ from dataclasses import dataclass
10
+ from typing import Any, Protocol
11
+
12
+
13
+ class Engine(Protocol):
14
+ async def start(self) -> None: ...
15
+ async def aclose(self) -> None: ...
16
+ async def health(self) -> Any: ...
17
+
18
+
19
+ EngineFactory = Callable[[object, str | None], Engine]
20
+
21
+
22
+ def _default_factory(config: object, namespace: str | None) -> Engine:
23
+ from zleap.sag import DataEngine
24
+
25
+ return DataEngine(config, data_source_id=namespace) # type: ignore[arg-type]
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class _ReadEntry:
30
+ engine: Engine
31
+ active: int = 0
32
+
33
+
34
+ class EnginePool:
35
+ """Own one search engine and at most `max_read_engines` namespace engines."""
36
+
37
+ def __init__(
38
+ self,
39
+ config: object,
40
+ namespaces: set[str] | frozenset[str],
41
+ *,
42
+ max_read_engines: int,
43
+ factory: EngineFactory = _default_factory,
44
+ ) -> None:
45
+ if max_read_engines < 1:
46
+ raise ValueError("max_read_engines must be positive")
47
+ self._config = config
48
+ self._namespaces = frozenset(namespaces)
49
+ self._max_read_engines = max_read_engines
50
+ self._factory = factory
51
+ self._condition = asyncio.Condition()
52
+ self._search: Engine | None = None
53
+ self._reads: OrderedDict[str, _ReadEntry] = OrderedDict()
54
+ self._closed = False
55
+
56
+ async def start(self) -> None:
57
+ async with self._condition:
58
+ if self._closed:
59
+ raise RuntimeError("engine pool is closed")
60
+ if self._search is not None:
61
+ return
62
+ engine = self._factory(self._config, None)
63
+ try:
64
+ await engine.start()
65
+ except BaseException:
66
+ await engine.aclose()
67
+ raise
68
+ self._search = engine
69
+
70
+ async def search_engine(self) -> Engine:
71
+ async with self._condition:
72
+ if self._closed:
73
+ raise RuntimeError("engine pool is closed")
74
+ if self._search is None:
75
+ raise RuntimeError("engine pool is not started")
76
+ return self._search
77
+
78
+ @asynccontextmanager
79
+ async def read_engine(self, namespace_id: str) -> AsyncIterator[Engine]:
80
+ if namespace_id not in self._namespaces:
81
+ raise ValueError("namespace is not configured")
82
+ entry: _ReadEntry
83
+ async with self._condition:
84
+ while True:
85
+ if self._closed:
86
+ raise RuntimeError("engine pool is closed")
87
+ existing = self._reads.get(namespace_id)
88
+ if existing is not None:
89
+ entry = existing
90
+ entry.active += 1
91
+ self._reads.move_to_end(namespace_id)
92
+ break
93
+ if len(self._reads) >= self._max_read_engines:
94
+ idle_namespace = next((name for name, candidate in self._reads.items() if candidate.active == 0), None)
95
+ if idle_namespace is None:
96
+ await self._condition.wait()
97
+ continue
98
+ idle = self._reads.pop(idle_namespace)
99
+ await idle.engine.aclose()
100
+ engine = self._factory(self._config, namespace_id)
101
+ try:
102
+ await engine.start()
103
+ except BaseException:
104
+ await engine.aclose()
105
+ raise
106
+ entry = _ReadEntry(engine=engine, active=1)
107
+ self._reads[namespace_id] = entry
108
+ break
109
+ try:
110
+ yield entry.engine
111
+ finally:
112
+ async with self._condition:
113
+ entry.active -= 1
114
+ self._condition.notify_all()
115
+
116
+ async def health(self) -> Any:
117
+ return await (await self.search_engine()).health()
118
+
119
+ async def capabilities(self) -> Any:
120
+ engine = await self.search_engine()
121
+ return engine.capabilities() # type: ignore[attr-defined]
122
+
123
+ async def aclose(self) -> None:
124
+ async with self._condition:
125
+ if self._closed:
126
+ return
127
+ self._closed = True
128
+ self._condition.notify_all()
129
+ while any(entry.active for entry in self._reads.values()):
130
+ await self._condition.wait()
131
+ engines = [entry.engine for entry in self._reads.values()]
132
+ if self._search is not None:
133
+ engines.insert(0, self._search)
134
+ self._reads.clear()
135
+ self._search = None
136
+ results = await asyncio.gather(*(engine.aclose() for engine in engines), return_exceptions=True)
137
+ failures = [result for result in results if isinstance(result, BaseException)]
138
+ if failures:
139
+ raise ExceptionGroup("failed to close SAG engines", failures)