threadshelf 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +185 -0
- package/LICENSE +21 -0
- package/README.md +763 -0
- package/SECURITY.md +75 -0
- package/bin/threadshelf-mcp.js +12 -0
- package/bin/threadshelf.js +87 -0
- package/dist/mcp/server.js +388 -0
- package/dist/src/chunking.js +72 -0
- package/dist/src/cli.js +24 -0
- package/dist/src/embedding.js +59 -0
- package/dist/src/env.js +2 -0
- package/dist/src/generation/config.js +344 -0
- package/dist/src/generation/downloader.js +172 -0
- package/dist/src/generation/error-log.js +34 -0
- package/dist/src/generation/filesystem-browser.js +83 -0
- package/dist/src/generation/gguf-metadata.js +179 -0
- package/dist/src/generation/hardware.js +87 -0
- package/dist/src/generation/llama-install.js +563 -0
- package/dist/src/generation/llama-process.js +576 -0
- package/dist/src/generation/llama-profile.js +136 -0
- package/dist/src/generation/master-prompts.js +155 -0
- package/dist/src/generation/model-catalog.js +276 -0
- package/dist/src/generation/model-discovery.js +60 -0
- package/dist/src/generation/model-download.js +151 -0
- package/dist/src/generation/openai-compatible.js +231 -0
- package/dist/src/generation/providers/llama-cpp.js +97 -0
- package/dist/src/generation/providers/openrouter.js +106 -0
- package/dist/src/generation/quick-setup.js +215 -0
- package/dist/src/generation/registry.js +23 -0
- package/dist/src/generation/service.js +100 -0
- package/dist/src/generation/threads.js +311 -0
- package/dist/src/generation/types.js +1 -0
- package/dist/src/ingest-cli.js +95 -0
- package/dist/src/ingest.js +257 -0
- package/dist/src/load-env.js +17 -0
- package/dist/src/model-label.js +15 -0
- package/dist/src/parser.js +811 -0
- package/dist/src/paths.js +79 -0
- package/dist/src/routes/collections.js +97 -0
- package/dist/src/routes/files.js +136 -0
- package/dist/src/routes/generation.js +536 -0
- package/dist/src/routes/health.js +6 -0
- package/dist/src/routes/index.js +21 -0
- package/dist/src/routes/ingest.js +300 -0
- package/dist/src/routes/insights.js +24 -0
- package/dist/src/routes/loopback.js +15 -0
- package/dist/src/routes/model-catalog.js +178 -0
- package/dist/src/routes/search.js +57 -0
- package/dist/src/routes/stream-abort.js +23 -0
- package/dist/src/routes/thread.js +43 -0
- package/dist/src/search-cli.js +93 -0
- package/dist/src/server.js +78 -0
- package/dist/src/services/collections.js +58 -0
- package/dist/src/services/insights.js +111 -0
- package/dist/src/services/search.js +68 -0
- package/dist/src/services/stats.js +35 -0
- package/dist/src/services/thread.js +140 -0
- package/dist/src/store.js +1138 -0
- package/dist/src/validation.js +250 -0
- package/dist/src/watch.js +83 -0
- package/package.json +103 -0
- package/public/assets/index-CIm_Idqi.js +38 -0
- package/public/assets/index-Dv09K2vS.css +1 -0
- package/public/favicon.svg +6 -0
- package/public/index.html +28 -0
- package/scripts/openrouter-export-all.js +228 -0
- package/scripts/openrouter-export-browser.js +153 -0
package/SECURITY.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Security And Privacy
|
|
2
|
+
|
|
3
|
+
ThreadShelf is designed for local private archives. It does not require a cloud vector database or external embedding API for the main search path.
|
|
4
|
+
|
|
5
|
+
## Private Data
|
|
6
|
+
|
|
7
|
+
Do not commit:
|
|
8
|
+
|
|
9
|
+
- Real chat exports.
|
|
10
|
+
- Uploaded source files.
|
|
11
|
+
- LanceDB databases.
|
|
12
|
+
- `.collections.json`.
|
|
13
|
+
- `.threadshelf/` (generation settings and optional llama.cpp tools).
|
|
14
|
+
- Logs.
|
|
15
|
+
- Temp folders.
|
|
16
|
+
- Screenshots containing private conversations.
|
|
17
|
+
|
|
18
|
+
The repo's committed fixtures should be synthetic and anonymized.
|
|
19
|
+
|
|
20
|
+
## Local Server Exposure
|
|
21
|
+
|
|
22
|
+
By default the server binds only to `127.0.0.1`. Keep that default for private
|
|
23
|
+
single-machine use:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
$env:HOST='127.0.0.1'
|
|
27
|
+
npm start
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Setting `HOST=0.0.0.0` exposes the unauthenticated API to the local network. Do
|
|
31
|
+
not do this on an untrusted network.
|
|
32
|
+
|
|
33
|
+
The browser UI uses same-origin API requests and does not enable cross-origin
|
|
34
|
+
access by default.
|
|
35
|
+
|
|
36
|
+
## Experimental Generation Boundary
|
|
37
|
+
|
|
38
|
+
Conversation generation is **Experimental Beta**. Managed `llama-server`
|
|
39
|
+
processes bind to an ephemeral `127.0.0.1` port, configured llama.cpp URLs must
|
|
40
|
+
be loopback-only, and selected GGUF files must be under configured model roots.
|
|
41
|
+
|
|
42
|
+
OpenRouter is an explicit external exception to the local data path. Sending a
|
|
43
|
+
continuation transmits the selected archive's user/assistant turns and prompt to
|
|
44
|
+
OpenRouter and its routed provider; archived thinking is excluded. The UI
|
|
45
|
+
marks the provider as **OpenRouter · external**, adds an `off-device` chip to the
|
|
46
|
+
model button, and repeats the boundary in the composer. ZDR-only routing and
|
|
47
|
+
provider data-collection denial are optional controls, but users must still treat
|
|
48
|
+
every OpenRouter request as disclosure to an external service.
|
|
49
|
+
|
|
50
|
+
Prefer `OPENROUTER_API_KEY`. Keys entered in Settings remain in process memory
|
|
51
|
+
only and are never returned by the API or persisted to generation config.
|
|
52
|
+
|
|
53
|
+
## MCP Exposure
|
|
54
|
+
|
|
55
|
+
The MCP server gives connected MCP clients access to indexed local chat data. Only configure it in clients you trust.
|
|
56
|
+
|
|
57
|
+
## Reporting Issues
|
|
58
|
+
|
|
59
|
+
If you find a vulnerability or privacy leak:
|
|
60
|
+
|
|
61
|
+
- Do not include real private exports in the report.
|
|
62
|
+
- Describe the issue with synthetic examples.
|
|
63
|
+
- Include affected routes/tools and reproduction steps.
|
|
64
|
+
|
|
65
|
+
## Dependency Notes
|
|
66
|
+
|
|
67
|
+
The app uses:
|
|
68
|
+
|
|
69
|
+
- Express for the local HTTP server.
|
|
70
|
+
- Multer for uploads.
|
|
71
|
+
- LanceDB for local vector storage.
|
|
72
|
+
- Xenova Transformers for local embeddings.
|
|
73
|
+
- Playwright for browser tests.
|
|
74
|
+
|
|
75
|
+
Review dependency updates before publishing public releases.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `threadshelf-mcp` stdio entrypoint for MCP clients (Claude Desktop, etc.).
|
|
4
|
+
* Kept separate from the compiled module so the server's own "am I the
|
|
5
|
+
* entrypoint?" check is not needed here - we start it explicitly.
|
|
6
|
+
*/
|
|
7
|
+
import { runServer } from '../dist/mcp/server.js';
|
|
8
|
+
import { startIndexRecovery } from '../dist/src/store.js';
|
|
9
|
+
|
|
10
|
+
const stopRecovery = startIndexRecovery();
|
|
11
|
+
process.stdin.once('end', stopRecovery);
|
|
12
|
+
runServer();
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `npx threadshelf` entrypoint.
|
|
4
|
+
*
|
|
5
|
+
* Plain JavaScript on purpose: the published package must not need tsx or a
|
|
6
|
+
* TypeScript toolchain at runtime. It parses a couple of flags, then hands over
|
|
7
|
+
* to the compiled server in dist/.
|
|
8
|
+
*/
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const pkg = require('../package.json');
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
|
|
16
|
+
const usage = `ThreadShelf ${pkg.version} - local semantic search for your AI chats
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
npx threadshelf [port] Start the web UI and API (default port 3000)
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
-p, --port <port> Port to listen on (default 3000, or $PORT)
|
|
23
|
+
--host <host> Interface to bind (default 127.0.0.1, loopback only)
|
|
24
|
+
--data-dir <d> Directory for persistent data
|
|
25
|
+
(default: %LOCALAPPDATA%\ThreadShelf on Windows, ~/.threadshelf elsewhere)
|
|
26
|
+
--where Print the resolved data and package directories, then exit
|
|
27
|
+
-v, --version Print the version
|
|
28
|
+
-h, --help Show this help
|
|
29
|
+
|
|
30
|
+
Environment:
|
|
31
|
+
PORT, HOST, THREADSHELF_DATA_DIR, LANCEDB_PATH and the other documented
|
|
32
|
+
overrides keep working and take precedence over the defaults.
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
let port = '';
|
|
36
|
+
let showWhere = false;
|
|
37
|
+
|
|
38
|
+
const takeValue = (flag, index) => {
|
|
39
|
+
const value = args[index + 1];
|
|
40
|
+
if (!value || value.startsWith('-')) {
|
|
41
|
+
console.error(`Missing value for ${flag}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
48
|
+
const arg = args[i];
|
|
49
|
+
if (arg === '-h' || arg === '--help') {
|
|
50
|
+
console.log(usage);
|
|
51
|
+
process.exit(0);
|
|
52
|
+
} else if (arg === '-v' || arg === '--version') {
|
|
53
|
+
console.log(pkg.version);
|
|
54
|
+
process.exit(0);
|
|
55
|
+
} else if (arg === '--where') {
|
|
56
|
+
showWhere = true;
|
|
57
|
+
} else if (arg === '-p' || arg === '--port') {
|
|
58
|
+
port = takeValue(arg, i);
|
|
59
|
+
i += 1;
|
|
60
|
+
} else if (arg === '--host') {
|
|
61
|
+
process.env.HOST = takeValue(arg, i);
|
|
62
|
+
i += 1;
|
|
63
|
+
} else if (arg === '--data-dir') {
|
|
64
|
+
process.env.THREADSHELF_DATA_DIR = takeValue(arg, i);
|
|
65
|
+
i += 1;
|
|
66
|
+
} else if (/^\d+$/.test(arg)) {
|
|
67
|
+
port = arg;
|
|
68
|
+
} else {
|
|
69
|
+
console.error(`Unknown argument: ${arg}\n\n${usage}`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (port) process.env.PORT = port;
|
|
75
|
+
|
|
76
|
+
if (showWhere) {
|
|
77
|
+
const { dataDir, packageRoot } = await import('../dist/src/paths.js');
|
|
78
|
+
console.log('package :', packageRoot());
|
|
79
|
+
console.log('data :', dataDir());
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// server.ts reads process.argv[2] as a port; it is already normalised into
|
|
84
|
+
// process.env.PORT above, so hide the raw argv from it.
|
|
85
|
+
process.argv = [process.argv[0], process.argv[1]];
|
|
86
|
+
|
|
87
|
+
await import('../dist/src/server.js');
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { createInterface } from 'readline';
|
|
3
|
+
import { searchAcrossCollections } from '../src/services/search.js';
|
|
4
|
+
import { loadThread } from '../src/services/thread.js';
|
|
5
|
+
import { getAllCollections } from '../src/services/collections.js';
|
|
6
|
+
import { getStatsForCollection } from '../src/services/stats.js';
|
|
7
|
+
import { listSourceFilesInCollection } from '../src/store.js';
|
|
8
|
+
import { recoverPendingIndexes, startIndexRecovery } from '../src/store.js';
|
|
9
|
+
import { ValidationError, normalizeCollectionSelector, normalizeCollectionName, normalizeQuery, normalizeCount, normalizeRoles, normalizeBoolean, normalizeOptionalString, normalizeDateRange, normalizeSearchMode, } from '../src/validation.js';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import { packagePath } from '../src/paths.js';
|
|
12
|
+
// Read at runtime rather than `import ... with { type: 'json' }`: a JSON import
|
|
13
|
+
// makes tsc copy package.json into dist/, which would shadow the real package
|
|
14
|
+
// root when resolving bundled assets.
|
|
15
|
+
const pkg = createRequire(import.meta.url)(packagePath('package.json'));
|
|
16
|
+
const DEFAULT_PROTOCOL_VERSION = '2024-11-05';
|
|
17
|
+
const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', DEFAULT_PROTOCOL_VERSION];
|
|
18
|
+
const SERVER_INFO = { name: 'threadshelf-mcp', version: pkg.version };
|
|
19
|
+
const TOOL_DEFINITIONS = [
|
|
20
|
+
{
|
|
21
|
+
name: 'list_collections',
|
|
22
|
+
description: 'List all LanceDB collections discovered locally.',
|
|
23
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'list_files',
|
|
27
|
+
description: 'List unique source files indexed inside a collection.',
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
collection: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: 'Collection name. Use "all" to list across every collection. Defaults to "all".',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'get_stats',
|
|
41
|
+
description: 'Return chunk, file, role, and per-collection stats for a collection or all collections.',
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
collection: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'Collection name. Use "all" to aggregate every collection. Defaults to "all".',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'search',
|
|
55
|
+
description: 'Semantic search over the chosen collection. Returns ranked text snippets with source metadata.',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: {
|
|
59
|
+
query: { type: 'string', description: 'Natural-language query (any language).' },
|
|
60
|
+
collection: { type: 'string', description: 'Collection name or "all". Defaults to "all".' },
|
|
61
|
+
n: { type: 'integer', description: 'Max results (1-50). Default 15.' },
|
|
62
|
+
roles: {
|
|
63
|
+
type: 'array',
|
|
64
|
+
items: { type: 'string', enum: ['user', 'thinking', 'ai'] },
|
|
65
|
+
description: 'Restrict to a subset of roles.',
|
|
66
|
+
},
|
|
67
|
+
keywordBoost: {
|
|
68
|
+
type: 'boolean',
|
|
69
|
+
description: 'When true, re-rank so chunks containing the exact query text appear first.',
|
|
70
|
+
},
|
|
71
|
+
mode: {
|
|
72
|
+
type: 'string',
|
|
73
|
+
enum: ['semantic', 'keyword'],
|
|
74
|
+
description: 'Search mode. "semantic" (default) ranks by embedding similarity; "keyword" returns exact case-insensitive substring matches (best for identifiers, error strings, code).',
|
|
75
|
+
},
|
|
76
|
+
model: { type: 'string', description: 'Restrict results to models containing this text.' },
|
|
77
|
+
from: { type: 'string', description: 'Inclusive ISO date or YYYY-MM-DD lower bound.' },
|
|
78
|
+
to: { type: 'string', description: 'Inclusive ISO date or YYYY-MM-DD upper bound.' },
|
|
79
|
+
},
|
|
80
|
+
required: ['query'],
|
|
81
|
+
additionalProperties: false,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'read_thread',
|
|
86
|
+
description: 'Load and parse a full export file, returning the normalized turn array.',
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
sourceFile: { type: 'string', description: 'Absolute path to an indexed export file.' },
|
|
91
|
+
collection: {
|
|
92
|
+
type: 'string',
|
|
93
|
+
description: 'Restrict the lookup to a specific collection (or "all"). Defaults to "all".',
|
|
94
|
+
},
|
|
95
|
+
conversationKey: {
|
|
96
|
+
type: 'string',
|
|
97
|
+
description: 'Conversation key returned by search for multi-conversation export files.',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
required: ['sourceFile'],
|
|
101
|
+
additionalProperties: false,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
const RESOURCE_TEMPLATES = [
|
|
106
|
+
{
|
|
107
|
+
uriTemplate: 'threadshelf://collections',
|
|
108
|
+
name: 'collections',
|
|
109
|
+
description: 'JSON list of all collections.',
|
|
110
|
+
mimeType: 'application/json',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
uriTemplate: 'threadshelf://collections/{collection}/files',
|
|
114
|
+
name: 'collection-files',
|
|
115
|
+
description: 'JSON list of indexed source files within a collection.',
|
|
116
|
+
mimeType: 'application/json',
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
uriTemplate: 'threadshelf://thread?path={absolutePath}',
|
|
120
|
+
name: 'thread',
|
|
121
|
+
description: 'Parsed turn-by-turn thread for an indexed source file.',
|
|
122
|
+
mimeType: 'application/json',
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
const ERROR_CODES = {
|
|
126
|
+
parseError: -32700,
|
|
127
|
+
invalidRequest: -32600,
|
|
128
|
+
methodNotFound: -32601,
|
|
129
|
+
invalidParams: -32602,
|
|
130
|
+
internalError: -32603,
|
|
131
|
+
};
|
|
132
|
+
const jsonRpcResult = (id, result) => {
|
|
133
|
+
return JSON.stringify({ jsonrpc: '2.0', id, result });
|
|
134
|
+
};
|
|
135
|
+
const jsonRpcError = (id, code, message, data) => {
|
|
136
|
+
const error = { code, message };
|
|
137
|
+
if (data !== undefined)
|
|
138
|
+
error.data = data;
|
|
139
|
+
return JSON.stringify({ jsonrpc: '2.0', id, error });
|
|
140
|
+
};
|
|
141
|
+
const writeMessage = (payload) => {
|
|
142
|
+
process.stdout.write(`${payload}\n`);
|
|
143
|
+
};
|
|
144
|
+
const asTextContent = (value) => {
|
|
145
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
146
|
+
return [{ type: 'text', text }];
|
|
147
|
+
};
|
|
148
|
+
const toolListFiles = async (args = {}) => {
|
|
149
|
+
const collection = normalizeCollectionSelector(args.collection, { defaultValue: 'all' });
|
|
150
|
+
if (collection === 'all') {
|
|
151
|
+
const collections = await getAllCollections();
|
|
152
|
+
return Promise.all(collections.map(async (name) => ({
|
|
153
|
+
collection: name,
|
|
154
|
+
files: await listSourceFilesInCollection(name),
|
|
155
|
+
})));
|
|
156
|
+
}
|
|
157
|
+
const files = await listSourceFilesInCollection(collection);
|
|
158
|
+
return { collection, files };
|
|
159
|
+
};
|
|
160
|
+
const toolGetStats = async (args = {}) => {
|
|
161
|
+
const collection = normalizeCollectionSelector(args.collection, { defaultValue: 'all' });
|
|
162
|
+
return getStatsForCollection(collection);
|
|
163
|
+
};
|
|
164
|
+
const toolSearch = async (args = {}) => {
|
|
165
|
+
const query = normalizeQuery(args.query, { field: 'query' });
|
|
166
|
+
const collection = normalizeCollectionSelector(args.collection, { defaultValue: 'all' });
|
|
167
|
+
const n = normalizeCount(args.n, { defaultValue: 15 });
|
|
168
|
+
const roles = normalizeRoles(args.roles);
|
|
169
|
+
const keywordBoost = normalizeBoolean(args.keywordBoost, { field: 'keywordBoost' });
|
|
170
|
+
const model = normalizeOptionalString(args.model, { field: 'model' });
|
|
171
|
+
const { from, to } = normalizeDateRange(args.from, args.to);
|
|
172
|
+
const mode = normalizeSearchMode(args.mode);
|
|
173
|
+
// Bounded catch-up; failed jobs keep their backoff and never re-embed per query.
|
|
174
|
+
await recoverPendingIndexes({ collection });
|
|
175
|
+
const results = await searchAcrossCollections(query, collection, {
|
|
176
|
+
n,
|
|
177
|
+
roles: roles ?? undefined,
|
|
178
|
+
keywordBoost,
|
|
179
|
+
model,
|
|
180
|
+
from,
|
|
181
|
+
to,
|
|
182
|
+
mode,
|
|
183
|
+
});
|
|
184
|
+
return { collection, results };
|
|
185
|
+
};
|
|
186
|
+
const toolReadThread = async (args = {}) => {
|
|
187
|
+
if (!args || typeof args.sourceFile !== 'string' || !args.sourceFile.trim()) {
|
|
188
|
+
throw new ValidationError('Missing sourceFile', { field: 'sourceFile' });
|
|
189
|
+
}
|
|
190
|
+
const sourceFile = args.sourceFile;
|
|
191
|
+
if (sourceFile.length > 4096 || sourceFile.includes('\0')) {
|
|
192
|
+
throw new ValidationError('Invalid sourceFile', { field: 'sourceFile' });
|
|
193
|
+
}
|
|
194
|
+
const collection = normalizeCollectionSelector(args.collection, { defaultValue: 'all' });
|
|
195
|
+
let conversationKey;
|
|
196
|
+
if (args.conversationKey !== undefined && args.conversationKey !== null) {
|
|
197
|
+
if (typeof args.conversationKey !== 'string' ||
|
|
198
|
+
args.conversationKey.length > 4096 ||
|
|
199
|
+
args.conversationKey.includes('\0')) {
|
|
200
|
+
throw new ValidationError('Invalid conversationKey', { field: 'conversationKey' });
|
|
201
|
+
}
|
|
202
|
+
conversationKey = args.conversationKey || undefined;
|
|
203
|
+
}
|
|
204
|
+
return loadThread(sourceFile, collection, conversationKey);
|
|
205
|
+
};
|
|
206
|
+
const parseResourceUri = (uri) => {
|
|
207
|
+
const match = /^threadshelf:\/\/(.+)$/.exec(uri || '');
|
|
208
|
+
if (!match)
|
|
209
|
+
return null;
|
|
210
|
+
const rest = match[1];
|
|
211
|
+
if (rest === 'collections')
|
|
212
|
+
return { kind: 'collections' };
|
|
213
|
+
const filesMatch = /^collections\/([^/]+)\/files$/.exec(rest);
|
|
214
|
+
if (filesMatch)
|
|
215
|
+
return { kind: 'files', collection: decodeURIComponent(filesMatch[1]) };
|
|
216
|
+
if (rest.startsWith('thread')) {
|
|
217
|
+
const query = rest.split('?')[1] || '';
|
|
218
|
+
const params = new URLSearchParams(query);
|
|
219
|
+
const path = params.get('path');
|
|
220
|
+
if (!path)
|
|
221
|
+
return null;
|
|
222
|
+
return { kind: 'thread', path };
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
};
|
|
226
|
+
const readResource = async (uri) => {
|
|
227
|
+
const parsed = parseResourceUri(uri);
|
|
228
|
+
if (!parsed)
|
|
229
|
+
throw new ValidationError(`Unknown resource URI: ${uri}`, { field: 'uri' });
|
|
230
|
+
if (parsed.kind === 'collections') {
|
|
231
|
+
const collections = await getAllCollections();
|
|
232
|
+
return { uri, mimeType: 'application/json', text: JSON.stringify(collections) };
|
|
233
|
+
}
|
|
234
|
+
if (parsed.kind === 'files') {
|
|
235
|
+
const collection = normalizeCollectionName(parsed.collection);
|
|
236
|
+
const files = await listSourceFilesInCollection(collection);
|
|
237
|
+
return { uri, mimeType: 'application/json', text: JSON.stringify({ collection, files }) };
|
|
238
|
+
}
|
|
239
|
+
if (parsed.kind === 'thread') {
|
|
240
|
+
const thread = await loadThread(parsed.path, 'all');
|
|
241
|
+
return { uri, mimeType: 'application/json', text: JSON.stringify(thread) };
|
|
242
|
+
}
|
|
243
|
+
throw new ValidationError('Unsupported resource', { field: 'uri' });
|
|
244
|
+
};
|
|
245
|
+
const HANDLERS = {
|
|
246
|
+
async initialize(params = {}) {
|
|
247
|
+
const requestedVersion = typeof params.protocolVersion === 'string' ? params.protocolVersion : undefined;
|
|
248
|
+
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
|
|
249
|
+
? requestedVersion
|
|
250
|
+
: DEFAULT_PROTOCOL_VERSION;
|
|
251
|
+
return {
|
|
252
|
+
protocolVersion,
|
|
253
|
+
capabilities: {
|
|
254
|
+
tools: { listChanged: false },
|
|
255
|
+
resources: { subscribe: false, listChanged: false },
|
|
256
|
+
},
|
|
257
|
+
serverInfo: SERVER_INFO,
|
|
258
|
+
};
|
|
259
|
+
},
|
|
260
|
+
async 'tools/list'() {
|
|
261
|
+
return { tools: TOOL_DEFINITIONS };
|
|
262
|
+
},
|
|
263
|
+
async 'tools/call'(params) {
|
|
264
|
+
const { name, arguments: args } = params;
|
|
265
|
+
try {
|
|
266
|
+
let payload;
|
|
267
|
+
switch (name) {
|
|
268
|
+
case 'list_collections':
|
|
269
|
+
payload = await getAllCollections();
|
|
270
|
+
break;
|
|
271
|
+
case 'list_files':
|
|
272
|
+
payload = await toolListFiles(args ?? {});
|
|
273
|
+
break;
|
|
274
|
+
case 'get_stats':
|
|
275
|
+
payload = await toolGetStats(args ?? {});
|
|
276
|
+
break;
|
|
277
|
+
case 'search':
|
|
278
|
+
payload = await toolSearch(args ?? {});
|
|
279
|
+
break;
|
|
280
|
+
case 'read_thread':
|
|
281
|
+
payload = await toolReadThread(args ?? {});
|
|
282
|
+
break;
|
|
283
|
+
default:
|
|
284
|
+
throw new ValidationError(`Unknown tool: ${name}`, { field: 'name' });
|
|
285
|
+
}
|
|
286
|
+
return { content: asTextContent(payload), isError: false };
|
|
287
|
+
}
|
|
288
|
+
catch (e) {
|
|
289
|
+
const message = e instanceof ValidationError ? e.message : `Tool failed: ${e.message}`;
|
|
290
|
+
return { content: asTextContent({ error: message }), isError: true };
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
async 'resources/list'() {
|
|
294
|
+
return { resources: [], resourceTemplates: RESOURCE_TEMPLATES };
|
|
295
|
+
},
|
|
296
|
+
async 'resources/read'(params) {
|
|
297
|
+
const { uri } = params;
|
|
298
|
+
try {
|
|
299
|
+
const contents = await readResource(uri);
|
|
300
|
+
return { contents: [contents] };
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
throw e instanceof ValidationError
|
|
304
|
+
? e
|
|
305
|
+
: new ValidationError(`Resource read failed: ${e.message}`, { field: 'uri' });
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
async 'notifications/initialized'() {
|
|
309
|
+
return null;
|
|
310
|
+
},
|
|
311
|
+
async ping() {
|
|
312
|
+
return {};
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
const handleMessage = async (message) => {
|
|
316
|
+
if (message.jsonrpc !== '2.0') {
|
|
317
|
+
if (message.id !== undefined) {
|
|
318
|
+
writeMessage(jsonRpcError(message.id, ERROR_CODES.invalidRequest, 'Expected jsonrpc 2.0'));
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const handler = message.method ? HANDLERS[message.method] : undefined;
|
|
323
|
+
const isNotification = message.id === undefined;
|
|
324
|
+
if (!handler) {
|
|
325
|
+
if (!isNotification) {
|
|
326
|
+
writeMessage(jsonRpcError(message.id, ERROR_CODES.methodNotFound, `Method not found: ${message.method}`));
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const result = await handler(message.params ?? {});
|
|
332
|
+
if (!isNotification) {
|
|
333
|
+
writeMessage(jsonRpcResult(message.id, result ?? {}));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch (e) {
|
|
337
|
+
if (isNotification)
|
|
338
|
+
return;
|
|
339
|
+
const code = e instanceof ValidationError ? ERROR_CODES.invalidParams : ERROR_CODES.internalError;
|
|
340
|
+
writeMessage(jsonRpcError(message.id, code, e.message));
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
export const runServer = ({ input = process.stdin } = {}) => {
|
|
344
|
+
const rl = createInterface({ input, terminal: false });
|
|
345
|
+
rl.on('line', (raw) => {
|
|
346
|
+
const line = raw.trim();
|
|
347
|
+
if (!line)
|
|
348
|
+
return;
|
|
349
|
+
let message;
|
|
350
|
+
try {
|
|
351
|
+
message = JSON.parse(line);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
writeMessage(jsonRpcError(null, ERROR_CODES.parseError, 'Invalid JSON in request'));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
handleMessage(message).catch((e) => {
|
|
358
|
+
writeMessage(jsonRpcError(message.id ?? null, ERROR_CODES.internalError, e.message));
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
return { close: () => rl.close() };
|
|
362
|
+
};
|
|
363
|
+
export const __testing__ = {
|
|
364
|
+
HANDLERS,
|
|
365
|
+
TOOL_DEFINITIONS,
|
|
366
|
+
RESOURCE_TEMPLATES,
|
|
367
|
+
parseResourceUri,
|
|
368
|
+
toolListFiles,
|
|
369
|
+
toolGetStats,
|
|
370
|
+
toolSearch,
|
|
371
|
+
toolReadThread,
|
|
372
|
+
readResource,
|
|
373
|
+
handleMessage,
|
|
374
|
+
};
|
|
375
|
+
const isEntrypoint = (() => {
|
|
376
|
+
try {
|
|
377
|
+
const argvUrl = new URL(`file://${process.argv[1].replace(/\\/g, '/')}`).href;
|
|
378
|
+
return import.meta.url === argvUrl;
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
})();
|
|
384
|
+
if (isEntrypoint) {
|
|
385
|
+
const stopRecovery = startIndexRecovery();
|
|
386
|
+
process.stdin.once('end', stopRecovery);
|
|
387
|
+
runServer();
|
|
388
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const MAX_CHUNK_CHARS = Math.max(200, Number(process.env.CHUNK_MAX_CHARS) || 2000);
|
|
2
|
+
const OVERLAP_CHARS = Math.max(0, Number(process.env.CHUNK_OVERLAP_CHARS) || 100);
|
|
3
|
+
export const chunkTurns = (turns, meta) => {
|
|
4
|
+
const chunks = [];
|
|
5
|
+
let turnIndex = 0;
|
|
6
|
+
for (const turn of turns) {
|
|
7
|
+
const role = turn.user !== undefined ? 'user' : turn.thinking !== undefined ? 'thinking' : 'ai';
|
|
8
|
+
const text = turn.user ?? turn.thinking ?? turn.ai ?? '';
|
|
9
|
+
if (!isIndexableText(text)) {
|
|
10
|
+
turnIndex++;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const parts = splitText(text, MAX_CHUNK_CHARS, OVERLAP_CHARS);
|
|
14
|
+
for (const part of parts) {
|
|
15
|
+
chunks.push({
|
|
16
|
+
text: part,
|
|
17
|
+
role,
|
|
18
|
+
turnIndex,
|
|
19
|
+
sourceFile: meta.sourceFile,
|
|
20
|
+
provider: meta.provider,
|
|
21
|
+
conversationKey: meta.conversationKey,
|
|
22
|
+
title: meta.title,
|
|
23
|
+
model: turn.model,
|
|
24
|
+
createdAt: turn.createdAt,
|
|
25
|
+
createdInThreadShelf: turn.createdInThreadShelf,
|
|
26
|
+
generationProvider: turn.generationProvider,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
turnIndex++;
|
|
30
|
+
}
|
|
31
|
+
return chunks;
|
|
32
|
+
};
|
|
33
|
+
export const isIndexableText = (text) => {
|
|
34
|
+
if (!text || text === '[image]')
|
|
35
|
+
return false;
|
|
36
|
+
const trimmed = text.trim();
|
|
37
|
+
if (!trimmed)
|
|
38
|
+
return false;
|
|
39
|
+
if (trimmed === '{}' || trimmed === '[]' || trimmed === '<')
|
|
40
|
+
return false;
|
|
41
|
+
if (/^search\(/i.test(trimmed))
|
|
42
|
+
return false;
|
|
43
|
+
if (trimmed.length < 2)
|
|
44
|
+
return false;
|
|
45
|
+
return true;
|
|
46
|
+
};
|
|
47
|
+
const splitText = (text, maxChars, overlap) => {
|
|
48
|
+
if (text.length <= maxChars)
|
|
49
|
+
return [text];
|
|
50
|
+
const parts = [];
|
|
51
|
+
let start = 0;
|
|
52
|
+
while (start < text.length) {
|
|
53
|
+
let end = Math.min(start + maxChars, text.length);
|
|
54
|
+
if (end < text.length) {
|
|
55
|
+
const breakAt = text.lastIndexOf('\n\n', end);
|
|
56
|
+
if (breakAt > start) {
|
|
57
|
+
end = breakAt + 2;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const lineBreak = text.lastIndexOf('\n', end);
|
|
61
|
+
if (lineBreak > start)
|
|
62
|
+
end = lineBreak + 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const chunk = text.slice(start, end).trim();
|
|
66
|
+
if (chunk.length > 0)
|
|
67
|
+
parts.push(chunk);
|
|
68
|
+
const nextStart = end - (end < text.length ? overlap : 0);
|
|
69
|
+
start = nextStart > start ? nextStart : end;
|
|
70
|
+
}
|
|
71
|
+
return parts;
|
|
72
|
+
};
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { parseFile } from './parser.js';
|
|
3
|
+
const file = process.argv[2];
|
|
4
|
+
if (!file) {
|
|
5
|
+
console.error('Usage: npm run parse -- <file> -- [--no-user] [--no-thinking] [--no-ai]');
|
|
6
|
+
process.exit(1);
|
|
7
|
+
}
|
|
8
|
+
const options = {
|
|
9
|
+
includeUser: !process.argv.includes('--no-user'),
|
|
10
|
+
includeThinking: !process.argv.includes('--no-thinking'),
|
|
11
|
+
includeAi: !process.argv.includes('--no-ai'),
|
|
12
|
+
};
|
|
13
|
+
try {
|
|
14
|
+
const result = await parseFile(file, options);
|
|
15
|
+
if (result.error) {
|
|
16
|
+
console.error(result.error);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
console.log(JSON.stringify(result.turns, null, 2));
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
console.error(e.message);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|