@descix/cli 1.0.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/README.md +95 -0
- package/SERVICE_README_sdk.md +399 -0
- package/agent-assets/guides/README.md +61 -0
- package/agent-assets/guides/dynamic-site-pattern.md +125 -0
- package/agent-assets/guides/knowledge-only-pattern.md +93 -0
- package/agent-assets/guides/microservice-pattern.md +152 -0
- package/agent-assets/guides/static-site-pattern.md +111 -0
- package/agent-assets/instructions/app-asset-validation.md +477 -0
- package/agent-assets/instructions/onboarding-flow.md +408 -0
- package/agent-assets/instructions/standard-folder-structure.md +125 -0
- package/agent-assets/reference/app-creation.md +381 -0
- package/agent-assets/reference/architecture.md +406 -0
- package/agent-assets/reference/local-dev.md +138 -0
- package/agent-assets/reference/mcp-integration.md +424 -0
- package/agent-assets/reference/workspace-config.md +415 -0
- package/agent-assets/rules/descix_mcp.template.mdc +243 -0
- package/bin/descix.js +3930 -0
- package/bin/mcp-server.js +382 -0
- package/descix-cli-1.0.0.tgz +0 -0
- package/lib/agent-files.js +162 -0
- package/lib/api-client.js +458 -0
- package/lib/auth-guard.js +67 -0
- package/lib/commands/app-wizard.js +627 -0
- package/lib/commands/auth.js +407 -0
- package/lib/commands/buy.js +207 -0
- package/lib/commands/clone.js +91 -0
- package/lib/commands/config.js +155 -0
- package/lib/commands/doctor.js +277 -0
- package/lib/commands/folder.js +291 -0
- package/lib/commands/init.js +257 -0
- package/lib/commands/kb.js +654 -0
- package/lib/commands/mcp.js +273 -0
- package/lib/commands/serve.js +12 -0
- package/lib/commands/status.js +239 -0
- package/lib/commands/update.js +653 -0
- package/lib/core/Chunker.js +610 -0
- package/lib/core/Hydrator.js +801 -0
- package/lib/core/PathContext.js +335 -0
- package/lib/core/Syncer.js +310 -0
- package/lib/core/index.js +32 -0
- package/lib/global-config.js +86 -0
- package/lib/google-storage-adc.js +511 -0
- package/lib/wallet-file.js +232 -0
- package/lib/wizard/setup.js +502 -0
- package/lib/workspace-config.js +712 -0
- package/lib/workspace-utils.js +126 -0
- package/package.json +41 -0
- package/templates/agent-claude.md +226 -0
- package/templates/agent-cline.md +133 -0
- package/templates/agent-copilot.md +133 -0
- package/templates/agent-cursor.md +133 -0
- package/templates/descix_mcp.mdc +122 -0
- package/templates/drive/README.md +42 -0
- package/templates/drive/app/assets/app_description.md +13 -0
- package/templates/drive/app/assets/icon.png +0 -0
- package/templates/drive/app/assets/system_instructions.md +17 -0
- package/templates/drive/app/kb/General/README.md +25 -0
- package/templates/drive/community/Apps/.gitkeep +4 -0
- package/templates/drive/community/community_assets/community_description.md +15 -0
- package/templates/drive/community/community_assets/icon.png +0 -0
- package/templates/drive_templates/README.md +104 -0
- package/templates/drive_templates/agent_app_template/assets/app_description.txt +13 -0
- package/templates/drive_templates/agent_app_template/assets/system_instructions.txt +17 -0
- package/templates/drive_templates/agent_app_template/kb/General/README.md +25 -0
- package/templates/drive_templates/agent_app_template/microservice/.gitkeep +10 -0
- package/templates/drive_templates/agent_app_template/site/.gitkeep +4 -0
- package/templates/drive_templates/community_template/Apps/.gitkeep +4 -0
- package/templates/drive_templates/community_template/community_assets/community_description.txt +15 -0
- package/templates/scaffolds/README.md +62 -0
- package/templates/scaffolds/microservice/Dockerfile +19 -0
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +399 -0
- package/templates/scaffolds/microservice/app.js +50 -0
- package/templates/scaffolds/microservice/app.yaml +8 -0
- package/templates/scaffolds/microservice/defaults-config.json +5 -0
- package/templates/scaffolds/microservice/dev-overrides.json.example +5 -0
- package/templates/scaffolds/microservice/manifest.json +49 -0
- package/templates/scaffolds/microservice/package-lock.json +2645 -0
- package/templates/scaffolds/microservice/package.json +29 -0
- package/templates/scaffolds/microservice/scripts/register.js +55 -0
- package/templates/scaffolds/microservice/services/apiFront.js +75 -0
- package/templates/scaffolds/microservice/services/commandHandlers/sampleCommands.js +57 -0
- package/templates/scaffolds/microservice/services/mcpClient.js +78 -0
- package/templates/scaffolds/microservice/services/utils.js +247 -0
- package/templates/scaffolds/microservice/setup-schema.json +18 -0
- package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +143 -0
- package/templates/scaffolds/site/DeSciXAppSDK.js +62 -0
- package/templates/scaffolds/site/README.md +51 -0
- package/templates/scaffolds/site/app.js +22 -0
- package/templates/scaffolds/site/index.html +42 -0
- package/templates/scaffolds/site/styles.css +123 -0
- package/test-hydration.js +150 -0
- package/vendor/mcp/transports/http-sse-transport.js +196 -0
- package/vendor/mcp/transports/index.js +15 -0
- package/vendor/mcp/transports/stdio-transport.js +118 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PathContext - Unified filesystem context for DeSciX CLI
|
|
3
|
+
*
|
|
4
|
+
* Design principle: No "finding" after setup.
|
|
5
|
+
* - Setup time: Detect IDE markers, persist absolute paths to workspace.json
|
|
6
|
+
* - Runtime: Load workspace.json, all paths are simple lookups
|
|
7
|
+
*
|
|
8
|
+
* The workspace.json is the single source of truth for all paths.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from 'fs/promises';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Find IDE workspace root by searching for .cursor/ or .vscode/ markers
|
|
16
|
+
* This is the only "searching" allowed - IDE markers are guaranteed in Cursor/VSCode
|
|
17
|
+
*
|
|
18
|
+
* @param {string} startDir - Directory to start searching from
|
|
19
|
+
* @returns {Promise<string|null>} Workspace root path, or null if not in IDE workspace
|
|
20
|
+
*/
|
|
21
|
+
export async function findIDEMarker(startDir = process.cwd()) {
|
|
22
|
+
let currentDir = path.resolve(startDir);
|
|
23
|
+
const root = path.parse(currentDir).root;
|
|
24
|
+
|
|
25
|
+
// Only look for IDE markers (not .descix - that's created by setup)
|
|
26
|
+
const ideMarkers = ['.cursor', '.vscode'];
|
|
27
|
+
|
|
28
|
+
while (currentDir !== root) {
|
|
29
|
+
for (const marker of ideMarkers) {
|
|
30
|
+
const markerDir = path.join(currentDir, marker);
|
|
31
|
+
try {
|
|
32
|
+
const stat = await fs.stat(markerDir);
|
|
33
|
+
if (stat.isDirectory()) {
|
|
34
|
+
return currentDir;
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// Marker not found, continue
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const parentDir = path.dirname(currentDir);
|
|
42
|
+
if (parentDir === currentDir) break;
|
|
43
|
+
currentDir = parentDir;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* PathContext - Unified path resolution from workspace.json
|
|
51
|
+
*
|
|
52
|
+
* All paths come from workspace.json - no runtime "finding" of app contexts.
|
|
53
|
+
*/
|
|
54
|
+
export class PathContext {
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} workspaceRoot - Absolute path to workspace root
|
|
57
|
+
* @param {Object} config - Parsed workspace.json contents
|
|
58
|
+
*/
|
|
59
|
+
constructor(workspaceRoot, config) {
|
|
60
|
+
this.workspaceRoot = workspaceRoot;
|
|
61
|
+
this.config = config;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Load PathContext from workspace.json
|
|
66
|
+
*
|
|
67
|
+
* @param {string} startDir - Directory to start searching from (default: cwd)
|
|
68
|
+
* @returns {Promise<PathContext>} Loaded context
|
|
69
|
+
* @throws {Error} If not in IDE workspace or workspace not configured
|
|
70
|
+
*/
|
|
71
|
+
static async load(startDir = process.cwd()) {
|
|
72
|
+
// Find IDE marker (only .cursor/ or .vscode/)
|
|
73
|
+
const wsRoot = await findIDEMarker(startDir);
|
|
74
|
+
if (!wsRoot) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'Not in a Cursor/VSCode workspace.\n' +
|
|
77
|
+
'Open this folder in Cursor or VSCode first.'
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Load workspace.json
|
|
82
|
+
const configPath = path.join(wsRoot, '.descix', 'workspace.json');
|
|
83
|
+
let configData;
|
|
84
|
+
try {
|
|
85
|
+
const content = await fs.readFile(configPath, 'utf-8');
|
|
86
|
+
configData = JSON.parse(content);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
'Workspace not configured.\n' +
|
|
90
|
+
'Run "npx descix init" first to configure your workspace.'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return new PathContext(wsRoot, configData);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Try to load PathContext, return null if not configured
|
|
99
|
+
* Useful for commands that need to check if workspace exists
|
|
100
|
+
*
|
|
101
|
+
* @param {string} startDir - Directory to start searching from
|
|
102
|
+
* @returns {Promise<PathContext|null>}
|
|
103
|
+
*/
|
|
104
|
+
static async tryLoad(startDir = process.cwd()) {
|
|
105
|
+
try {
|
|
106
|
+
return await PathContext.load(startDir);
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ============ Path Getters (all from workspace.json) ============
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get workspace root path
|
|
116
|
+
* @returns {string} Absolute path to workspace root
|
|
117
|
+
*/
|
|
118
|
+
getWorkspaceRoot() {
|
|
119
|
+
return this.config.workspaceRoot || this.workspaceRoot;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get community path
|
|
124
|
+
* @param {string} communityId - Community identifier
|
|
125
|
+
* @returns {string|null} Absolute path to community folder, or null if not found
|
|
126
|
+
*/
|
|
127
|
+
getCommunityPath(communityId) {
|
|
128
|
+
const community = this.config.communities?.[communityId];
|
|
129
|
+
if (!community) return null;
|
|
130
|
+
|
|
131
|
+
// Use absolutePath if available, otherwise compute from localPath
|
|
132
|
+
if (community.absolutePath) {
|
|
133
|
+
return community.absolutePath;
|
|
134
|
+
}
|
|
135
|
+
if (community.localPath) {
|
|
136
|
+
return path.join(this.getWorkspaceRoot(), community.localPath);
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get app path
|
|
143
|
+
* @param {string} communityId - Community identifier
|
|
144
|
+
* @param {string} appId - App identifier
|
|
145
|
+
* @returns {string|null} Absolute path to app folder, or null if not found
|
|
146
|
+
*/
|
|
147
|
+
getAppPath(communityId, appId) {
|
|
148
|
+
const app = this.config.communities?.[communityId]?.apps?.[appId];
|
|
149
|
+
if (!app) return null;
|
|
150
|
+
|
|
151
|
+
// Use absolutePath if available, otherwise compute from localPath
|
|
152
|
+
if (app.absolutePath) {
|
|
153
|
+
return app.absolutePath;
|
|
154
|
+
}
|
|
155
|
+
if (app.localPath) {
|
|
156
|
+
return path.join(this.getWorkspaceRoot(), app.localPath);
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get KB path
|
|
163
|
+
* @param {string} communityId - Community identifier
|
|
164
|
+
* @param {string} appId - App identifier
|
|
165
|
+
* @param {string} kbId - KB identifier (default: 'General')
|
|
166
|
+
* @returns {string|null} Absolute path to KB folder, or null if app not found
|
|
167
|
+
*/
|
|
168
|
+
getKbPath(communityId, appId, kbId = 'General') {
|
|
169
|
+
const appPath = this.getAppPath(communityId, appId);
|
|
170
|
+
return appPath ? path.join(appPath, 'kb', kbId) : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Get site path
|
|
175
|
+
* @param {string} communityId - Community identifier
|
|
176
|
+
* @param {string} appId - App identifier
|
|
177
|
+
* @returns {string|null} Absolute path to site folder, or null if app not found
|
|
178
|
+
*/
|
|
179
|
+
getSitePath(communityId, appId) {
|
|
180
|
+
const appPath = this.getAppPath(communityId, appId);
|
|
181
|
+
return appPath ? path.join(appPath, 'site') : null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get microservice path
|
|
186
|
+
* @param {string} communityId - Community identifier
|
|
187
|
+
* @param {string} appId - App identifier
|
|
188
|
+
* @returns {string|null} Absolute path to microservice folder, or null if app not found
|
|
189
|
+
*/
|
|
190
|
+
getMicroservicePath(communityId, appId) {
|
|
191
|
+
const appPath = this.getAppPath(communityId, appId);
|
|
192
|
+
return appPath ? path.join(appPath, 'microservice') : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ============ Context Detection (match cwd against known paths) ============
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Detect community/app context from current working directory
|
|
199
|
+
* Matches cwd against known absolutePaths in workspace.json
|
|
200
|
+
*
|
|
201
|
+
* @returns {{ communityId: string, appId: string, kbId: string }|null}
|
|
202
|
+
*/
|
|
203
|
+
detectContext() {
|
|
204
|
+
const cwd = process.cwd();
|
|
205
|
+
|
|
206
|
+
for (const [commId, comm] of Object.entries(this.config.communities || {})) {
|
|
207
|
+
for (const [appId, app] of Object.entries(comm.apps || {})) {
|
|
208
|
+
const appPath = app.absolutePath ||
|
|
209
|
+
(app.localPath ? path.join(this.getWorkspaceRoot(), app.localPath) : null);
|
|
210
|
+
|
|
211
|
+
if (appPath && cwd.startsWith(appPath)) {
|
|
212
|
+
return {
|
|
213
|
+
communityId: commId,
|
|
214
|
+
appId,
|
|
215
|
+
kbId: app.kbId || 'General'
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Resolve context by merging CLI options with detected context
|
|
226
|
+
* CLI options take priority over detected context
|
|
227
|
+
*
|
|
228
|
+
* @param {Object} options - CLI options { community, app, kb }
|
|
229
|
+
* @returns {{ communityId: string|null, appId: string|null, kbId: string }}
|
|
230
|
+
*/
|
|
231
|
+
resolveContext(options = {}) {
|
|
232
|
+
const detected = this.detectContext();
|
|
233
|
+
|
|
234
|
+
const resolved = {
|
|
235
|
+
communityId: options.community || detected?.communityId || null,
|
|
236
|
+
appId: options.app || detected?.appId || null,
|
|
237
|
+
kbId: options.kb || detected?.kbId || 'General'
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
return resolved;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Resolve context and throw if community/app not determined
|
|
245
|
+
*
|
|
246
|
+
* @param {Object} options - CLI options { community, app, kb }
|
|
247
|
+
* @returns {{ communityId: string, appId: string, kbId: string }}
|
|
248
|
+
* @throws {Error} If community or app cannot be determined
|
|
249
|
+
*/
|
|
250
|
+
requireContext(options = {}) {
|
|
251
|
+
const ctx = this.resolveContext(options);
|
|
252
|
+
|
|
253
|
+
if (!ctx.communityId || !ctx.appId) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
'Could not determine app context.\n\n' +
|
|
256
|
+
'Options:\n' +
|
|
257
|
+
' 1. cd into an app directory\n' +
|
|
258
|
+
' 2. Use flags: -c <community> -a <app>\n\n' +
|
|
259
|
+
'Example: npx descix kb build -c descix -a appsdk'
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return ctx;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ============ Config Access ============
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Get raw workspace.json config
|
|
270
|
+
* @returns {Object}
|
|
271
|
+
*/
|
|
272
|
+
getWorkspaceConfig() {
|
|
273
|
+
return this.config;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Get Drive configuration
|
|
278
|
+
* @returns {{ base_folder_id: string }|null}
|
|
279
|
+
*/
|
|
280
|
+
getDriveConfig() {
|
|
281
|
+
return this.config.driveConfig || null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Get API URL
|
|
286
|
+
* Derives from env.platform.microservice.port in DEV mode (v2.1 workspace format).
|
|
287
|
+
* @returns {string}
|
|
288
|
+
*/
|
|
289
|
+
getApiUrl() {
|
|
290
|
+
if (this.config.apiUrl) return this.config.apiUrl;
|
|
291
|
+
const env = this.config.env || {};
|
|
292
|
+
const platformPort = env.platform?.microservice?.port;
|
|
293
|
+
if (platformPort && env.environment === 'DEV') return `https://localhost:${platformPort}`;
|
|
294
|
+
return 'https://descix.net';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Get environment
|
|
299
|
+
* @returns {string}
|
|
300
|
+
*/
|
|
301
|
+
getEnvironment() {
|
|
302
|
+
return this.config.environment || 'production';
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ============ Listing ============
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* List all community IDs
|
|
309
|
+
* @returns {string[]}
|
|
310
|
+
*/
|
|
311
|
+
listCommunities() {
|
|
312
|
+
return Object.keys(this.config.communities || {});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* List all app IDs (optionally filtered by community)
|
|
317
|
+
* @param {string} communityId - Optional community filter
|
|
318
|
+
* @returns {Array<{ communityId: string, appId: string }>}
|
|
319
|
+
*/
|
|
320
|
+
listApps(communityId = null) {
|
|
321
|
+
const apps = [];
|
|
322
|
+
|
|
323
|
+
for (const [commId, comm] of Object.entries(this.config.communities || {})) {
|
|
324
|
+
if (communityId && commId !== communityId) continue;
|
|
325
|
+
|
|
326
|
+
for (const appId of Object.keys(comm.apps || {})) {
|
|
327
|
+
apps.push({ communityId: commId, appId });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return apps;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export default PathContext;
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syncer - Canonical module for upstream sync to Pinecone
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Sync local chunks to Pinecone via backend API
|
|
6
|
+
* - Compute delta between local and remote chunks using content_hash
|
|
7
|
+
* - Manage chunk lifecycle (upsert/delete)
|
|
8
|
+
*
|
|
9
|
+
* Architecture:
|
|
10
|
+
* - Chunks are processed locally by Chunker (with content_hash)
|
|
11
|
+
* - Syncer compares hashes to determine what needs upserting
|
|
12
|
+
* - Only changed/new chunks are sent to backend
|
|
13
|
+
* - Backend handles Pinecone authentication and embedding
|
|
14
|
+
* - Pinecone performs integrated embeddings (server-side)
|
|
15
|
+
*
|
|
16
|
+
* Security:
|
|
17
|
+
* - Pinecone API keys never leave the backend
|
|
18
|
+
* - CLI only sends text + metadata; backend handles embedding
|
|
19
|
+
*
|
|
20
|
+
* Chunk ID Format (v2.1):
|
|
21
|
+
* - Composite: ${community_id}:${app_id}:${kb_id}:${file_id}:${chunk_idx}
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as fs from 'fs/promises';
|
|
25
|
+
import * as path from 'path';
|
|
26
|
+
import { loadChunks } from './Chunker.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compute delta between local chunks and remote Pinecone records
|
|
30
|
+
* Uses content_hash for efficient change detection when available.
|
|
31
|
+
*
|
|
32
|
+
* @param {Array} localChunks - Chunk records from local JSON files (with content_hash)
|
|
33
|
+
* @param {Array<Object>} remoteChunks - Remote chunk metadata { id, content_hash } or just IDs
|
|
34
|
+
* @returns {Object} { toUpsert: Array, toDelete: Array<string>, unchanged: number }
|
|
35
|
+
*/
|
|
36
|
+
export function computeChunkDelta(localChunks, remoteChunks) {
|
|
37
|
+
// Handle both old format (array of IDs) and new format (array of {id, content_hash})
|
|
38
|
+
const isNewFormat = remoteChunks.length > 0 && typeof remoteChunks[0] === 'object';
|
|
39
|
+
|
|
40
|
+
// Build maps for efficient lookup
|
|
41
|
+
const localById = new Map(localChunks.map(c => [c.id, c]));
|
|
42
|
+
const remoteById = isNewFormat
|
|
43
|
+
? new Map(remoteChunks.map(c => [c.id, c.content_hash]))
|
|
44
|
+
: new Map(remoteChunks.map(id => [id, null])); // No hash available
|
|
45
|
+
|
|
46
|
+
const toUpsert = [];
|
|
47
|
+
let unchanged = 0;
|
|
48
|
+
|
|
49
|
+
for (const chunk of localChunks) {
|
|
50
|
+
const remoteHash = remoteById.get(chunk.id);
|
|
51
|
+
|
|
52
|
+
if (remoteHash === undefined) {
|
|
53
|
+
// Chunk doesn't exist in remote - need to upsert
|
|
54
|
+
toUpsert.push(chunk);
|
|
55
|
+
} else if (remoteHash === null) {
|
|
56
|
+
// No hash comparison available (old backend) - always upsert
|
|
57
|
+
toUpsert.push(chunk);
|
|
58
|
+
} else if (chunk.content_hash !== remoteHash) {
|
|
59
|
+
// Hash differs - content changed, need to upsert
|
|
60
|
+
toUpsert.push(chunk);
|
|
61
|
+
} else {
|
|
62
|
+
// Hash matches - skip (already in sync)
|
|
63
|
+
unchanged++;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Find chunks to delete (exist in remote but not locally)
|
|
68
|
+
const localIds = new Set(localChunks.map(c => c.id));
|
|
69
|
+
const remoteIds = isNewFormat ? remoteChunks.map(c => c.id) : remoteChunks;
|
|
70
|
+
const toDelete = remoteIds.filter(id => !localIds.has(id));
|
|
71
|
+
|
|
72
|
+
return { toUpsert, toDelete, unchanged };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get existing chunk metadata from Pinecone (with content_hash for delta)
|
|
77
|
+
* Falls back to IDs-only if metadata endpoint not available.
|
|
78
|
+
*
|
|
79
|
+
* @param {Object} apiClient - DeSciXApiClient instance
|
|
80
|
+
* @param {string} communityId - Community ID
|
|
81
|
+
* @param {string} appId - App ID
|
|
82
|
+
* @param {string} kbId - Knowledge base ID
|
|
83
|
+
* @returns {Promise<Array<{id: string, content_hash: string}>>} Chunk metadata
|
|
84
|
+
*/
|
|
85
|
+
export async function getRemoteChunkMetadata(apiClient, communityId, appId, kbId) {
|
|
86
|
+
try {
|
|
87
|
+
// Try new metadata endpoint first
|
|
88
|
+
const result = await apiClient.invoke('kb_get_chunk_metadata', {
|
|
89
|
+
app_id: appId,
|
|
90
|
+
kb_id: kbId
|
|
91
|
+
});
|
|
92
|
+
return result.chunks || [];
|
|
93
|
+
} catch (error) {
|
|
94
|
+
// Fall back to IDs-only endpoint
|
|
95
|
+
try {
|
|
96
|
+
const result = await apiClient.invoke('kb_get_chunk_ids', {
|
|
97
|
+
app_id: appId,
|
|
98
|
+
kb_id: kbId
|
|
99
|
+
});
|
|
100
|
+
// Return as array of IDs (old format) for backward compatibility
|
|
101
|
+
return result.chunk_ids || [];
|
|
102
|
+
} catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @deprecated Use getRemoteChunkMetadata instead
|
|
110
|
+
* Get existing chunk IDs from Pinecone
|
|
111
|
+
*/
|
|
112
|
+
export async function getRemoteChunkIds(apiClient, communityId, appId, kbId) {
|
|
113
|
+
const metadata = await getRemoteChunkMetadata(apiClient, communityId, appId, kbId);
|
|
114
|
+
// Handle both formats
|
|
115
|
+
if (metadata.length > 0 && typeof metadata[0] === 'object') {
|
|
116
|
+
return metadata.map(c => c.id);
|
|
117
|
+
}
|
|
118
|
+
return metadata;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Upsert chunks to Pinecone via backend API
|
|
123
|
+
*
|
|
124
|
+
* @param {Object} apiClient - DeSciXApiClient instance
|
|
125
|
+
* @param {string} communityId - Community ID
|
|
126
|
+
* @param {string} appId - App ID
|
|
127
|
+
* @param {string} kbId - Knowledge base ID
|
|
128
|
+
* @param {Array} chunks - Chunk records to upsert
|
|
129
|
+
* @returns {Promise<{upserted: number}>}
|
|
130
|
+
*/
|
|
131
|
+
export async function upsertChunks(apiClient, communityId, appId, kbId, chunks) {
|
|
132
|
+
if (chunks.length === 0) {
|
|
133
|
+
return { upserted: 0 };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Pinecone limits upserts to 96 vectors per batch
|
|
137
|
+
const BATCH_SIZE = 90;
|
|
138
|
+
let totalUpserted = 0;
|
|
139
|
+
|
|
140
|
+
for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
|
|
141
|
+
const batch = chunks.slice(i, i + BATCH_SIZE);
|
|
142
|
+
const result = await apiClient.invoke('kb_sync_chunks', {
|
|
143
|
+
app_id: appId,
|
|
144
|
+
kb_id: kbId,
|
|
145
|
+
chunks: batch
|
|
146
|
+
});
|
|
147
|
+
totalUpserted += result.upserted_count || batch.length;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { upserted: totalUpserted };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Delete stale chunks from Pinecone
|
|
155
|
+
*
|
|
156
|
+
* @param {Object} apiClient - DeSciXApiClient instance
|
|
157
|
+
* @param {string} communityId - Community ID
|
|
158
|
+
* @param {string} appId - App ID
|
|
159
|
+
* @param {string} kbId - Knowledge base ID
|
|
160
|
+
* @param {Array<string>} chunkIds - Chunk IDs to delete
|
|
161
|
+
* @returns {Promise<{deleted: number}>}
|
|
162
|
+
*/
|
|
163
|
+
export async function deleteStaleChunks(apiClient, communityId, appId, kbId, chunkIds) {
|
|
164
|
+
if (chunkIds.length === 0) {
|
|
165
|
+
return { deleted: 0 };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await apiClient.invoke('kb_delete_chunks', {
|
|
169
|
+
app_id: appId,
|
|
170
|
+
kb_id: kbId,
|
|
171
|
+
chunk_ids: chunkIds
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return { deleted: chunkIds.length };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Sync a KB to Pinecone - full workflow with content-hash based delta
|
|
179
|
+
*
|
|
180
|
+
* Workflow:
|
|
181
|
+
* 1. Load local chunks from kb/chunks/ (with content_hash)
|
|
182
|
+
* 2. Get existing chunk metadata from Pinecone (with content_hash)
|
|
183
|
+
* 3. Compute delta using hash comparison (only upsert changed/new)
|
|
184
|
+
* 4. Delete stale chunks (exist in Pinecone but not locally)
|
|
185
|
+
* 5. Upsert new/modified chunks
|
|
186
|
+
*
|
|
187
|
+
* @param {Object} apiClient - DeSciXApiClient instance (required)
|
|
188
|
+
* @param {Object} config - { workspaceRoot, communityId, appId, kbId, localPath }
|
|
189
|
+
* @param {Object} options - { verbose, onProgress }
|
|
190
|
+
* @returns {Promise<{synced: number, deleted: number, unchanged: number}>}
|
|
191
|
+
*/
|
|
192
|
+
export async function syncKb(apiClient, config, options = {}) {
|
|
193
|
+
const { workspaceRoot, communityId, appId, kbId = 'General', localPath } = config;
|
|
194
|
+
const { verbose = false, onProgress } = options;
|
|
195
|
+
|
|
196
|
+
if (!apiClient) {
|
|
197
|
+
throw new Error('Authentication required. Run "descix login" first.');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 1. Load local chunks
|
|
201
|
+
const localChunks = await loadChunks({
|
|
202
|
+
workspaceRoot,
|
|
203
|
+
communityId,
|
|
204
|
+
appId,
|
|
205
|
+
kbId,
|
|
206
|
+
localPath
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (localChunks.length === 0) {
|
|
210
|
+
if (verbose) console.log('No chunks to sync');
|
|
211
|
+
return { synced: 0, deleted: 0, unchanged: 0 };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (onProgress) onProgress(`Found ${localChunks.length} local chunks`);
|
|
215
|
+
|
|
216
|
+
// 2. Get existing chunk metadata from Pinecone (with content_hash if available)
|
|
217
|
+
if (onProgress) onProgress('Fetching existing chunks from Pinecone...');
|
|
218
|
+
const remoteChunks = await getRemoteChunkMetadata(apiClient, communityId, appId, kbId);
|
|
219
|
+
|
|
220
|
+
const remoteCount = Array.isArray(remoteChunks)
|
|
221
|
+
? remoteChunks.length
|
|
222
|
+
: 0;
|
|
223
|
+
|
|
224
|
+
if (verbose) {
|
|
225
|
+
console.log(` Local chunks: ${localChunks.length}`);
|
|
226
|
+
console.log(` Remote chunks: ${remoteCount}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 3. Compute delta using content_hash
|
|
230
|
+
const { toUpsert, toDelete, unchanged } = computeChunkDelta(localChunks, remoteChunks);
|
|
231
|
+
|
|
232
|
+
if (verbose && unchanged > 0) {
|
|
233
|
+
console.log(` Unchanged (skipped): ${unchanged}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 4. Delete stale chunks
|
|
237
|
+
let deleted = 0;
|
|
238
|
+
if (toDelete.length > 0) {
|
|
239
|
+
if (onProgress) onProgress(`Deleting ${toDelete.length} stale chunks...`);
|
|
240
|
+
try {
|
|
241
|
+
const deleteResult = await deleteStaleChunks(apiClient, communityId, appId, kbId, toDelete);
|
|
242
|
+
deleted = deleteResult.deleted;
|
|
243
|
+
if (verbose) console.log(` Deleted ${deleted} stale chunks`);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (verbose) console.log(` Delete failed: ${error.message}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 5. Upsert only changed/new chunks
|
|
250
|
+
let synced = 0;
|
|
251
|
+
if (toUpsert.length > 0) {
|
|
252
|
+
if (onProgress) onProgress(`Upserting ${toUpsert.length} chunks...`);
|
|
253
|
+
const upsertResult = await upsertChunks(apiClient, communityId, appId, kbId, toUpsert);
|
|
254
|
+
synced = upsertResult.upserted;
|
|
255
|
+
} else {
|
|
256
|
+
if (onProgress) onProgress('All chunks already in sync');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
synced,
|
|
261
|
+
deleted,
|
|
262
|
+
unchanged
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Get sync status - compare local chunks with Pinecone using content_hash
|
|
268
|
+
*
|
|
269
|
+
* @param {Object} apiClient - DeSciXApiClient instance
|
|
270
|
+
* @param {Object} config - { workspaceRoot, communityId, appId, kbId, localPath }
|
|
271
|
+
* @returns {Promise<{local: number, remote: number, toUpsert: number, toDelete: number, unchanged: number, inSync: boolean}>}
|
|
272
|
+
*/
|
|
273
|
+
export async function getSyncStatus(apiClient, config) {
|
|
274
|
+
const { workspaceRoot, communityId, appId, kbId = 'General', localPath } = config;
|
|
275
|
+
|
|
276
|
+
// Load local chunks
|
|
277
|
+
const localChunks = await loadChunks({
|
|
278
|
+
workspaceRoot,
|
|
279
|
+
communityId,
|
|
280
|
+
appId,
|
|
281
|
+
kbId,
|
|
282
|
+
localPath
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Get remote chunk metadata
|
|
286
|
+
const remoteChunks = await getRemoteChunkMetadata(apiClient, communityId, appId, kbId);
|
|
287
|
+
const remoteCount = Array.isArray(remoteChunks) ? remoteChunks.length : 0;
|
|
288
|
+
|
|
289
|
+
// Compute delta using content_hash
|
|
290
|
+
const { toUpsert, toDelete, unchanged } = computeChunkDelta(localChunks, remoteChunks);
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
local: localChunks.length,
|
|
294
|
+
remote: remoteCount,
|
|
295
|
+
toUpsert: toUpsert.length,
|
|
296
|
+
toDelete: toDelete.length,
|
|
297
|
+
unchanged,
|
|
298
|
+
inSync: toUpsert.length === 0 && toDelete.length === 0
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export default {
|
|
303
|
+
syncKb,
|
|
304
|
+
computeChunkDelta,
|
|
305
|
+
getRemoteChunkIds,
|
|
306
|
+
getRemoteChunkMetadata,
|
|
307
|
+
upsertChunks,
|
|
308
|
+
deleteStaleChunks,
|
|
309
|
+
getSyncStatus
|
|
310
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Library - Canonical modules for SDK operations
|
|
3
|
+
*
|
|
4
|
+
* This barrel export provides the three core modules:
|
|
5
|
+
*
|
|
6
|
+
* - Hydrator: Drive sync operations (pull/push/convert)
|
|
7
|
+
* - Chunker: Document chunking for RAG
|
|
8
|
+
* - Syncer: Upstream sync to Pinecone
|
|
9
|
+
*
|
|
10
|
+
* Architecture:
|
|
11
|
+
* - All CLI commands delegate to these core modules
|
|
12
|
+
* - Ensures DRY - no duplicate code paths
|
|
13
|
+
* - Consistent error handling and progress reporting
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* import { Hydrator, Chunker, Syncer } from '../core/index.js';
|
|
17
|
+
*
|
|
18
|
+
* // Or individual imports:
|
|
19
|
+
* import { hydrateKb, pushStaging } from '../core/Hydrator.js';
|
|
20
|
+
* import { processKb, loadChunks } from '../core/Chunker.js';
|
|
21
|
+
* import { syncKb, getSyncStatus } from '../core/Syncer.js';
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Re-export all named exports from each module
|
|
25
|
+
export * from './Hydrator.js';
|
|
26
|
+
export * from './Chunker.js';
|
|
27
|
+
export * from './Syncer.js';
|
|
28
|
+
|
|
29
|
+
// Re-export default exports as named modules
|
|
30
|
+
export { default as Hydrator } from './Hydrator.js';
|
|
31
|
+
export { default as Chunker } from './Chunker.js';
|
|
32
|
+
export { default as Syncer } from './Syncer.js';
|