@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.
Files changed (94) hide show
  1. package/README.md +95 -0
  2. package/SERVICE_README_sdk.md +399 -0
  3. package/agent-assets/guides/README.md +61 -0
  4. package/agent-assets/guides/dynamic-site-pattern.md +125 -0
  5. package/agent-assets/guides/knowledge-only-pattern.md +93 -0
  6. package/agent-assets/guides/microservice-pattern.md +152 -0
  7. package/agent-assets/guides/static-site-pattern.md +111 -0
  8. package/agent-assets/instructions/app-asset-validation.md +477 -0
  9. package/agent-assets/instructions/onboarding-flow.md +408 -0
  10. package/agent-assets/instructions/standard-folder-structure.md +125 -0
  11. package/agent-assets/reference/app-creation.md +381 -0
  12. package/agent-assets/reference/architecture.md +406 -0
  13. package/agent-assets/reference/local-dev.md +138 -0
  14. package/agent-assets/reference/mcp-integration.md +424 -0
  15. package/agent-assets/reference/workspace-config.md +415 -0
  16. package/agent-assets/rules/descix_mcp.template.mdc +243 -0
  17. package/bin/descix.js +3930 -0
  18. package/bin/mcp-server.js +382 -0
  19. package/descix-cli-1.0.0.tgz +0 -0
  20. package/lib/agent-files.js +162 -0
  21. package/lib/api-client.js +458 -0
  22. package/lib/auth-guard.js +67 -0
  23. package/lib/commands/app-wizard.js +627 -0
  24. package/lib/commands/auth.js +407 -0
  25. package/lib/commands/buy.js +207 -0
  26. package/lib/commands/clone.js +91 -0
  27. package/lib/commands/config.js +155 -0
  28. package/lib/commands/doctor.js +277 -0
  29. package/lib/commands/folder.js +291 -0
  30. package/lib/commands/init.js +257 -0
  31. package/lib/commands/kb.js +654 -0
  32. package/lib/commands/mcp.js +273 -0
  33. package/lib/commands/serve.js +12 -0
  34. package/lib/commands/status.js +239 -0
  35. package/lib/commands/update.js +653 -0
  36. package/lib/core/Chunker.js +610 -0
  37. package/lib/core/Hydrator.js +801 -0
  38. package/lib/core/PathContext.js +335 -0
  39. package/lib/core/Syncer.js +310 -0
  40. package/lib/core/index.js +32 -0
  41. package/lib/global-config.js +86 -0
  42. package/lib/google-storage-adc.js +511 -0
  43. package/lib/wallet-file.js +232 -0
  44. package/lib/wizard/setup.js +502 -0
  45. package/lib/workspace-config.js +712 -0
  46. package/lib/workspace-utils.js +126 -0
  47. package/package.json +41 -0
  48. package/templates/agent-claude.md +226 -0
  49. package/templates/agent-cline.md +133 -0
  50. package/templates/agent-copilot.md +133 -0
  51. package/templates/agent-cursor.md +133 -0
  52. package/templates/descix_mcp.mdc +122 -0
  53. package/templates/drive/README.md +42 -0
  54. package/templates/drive/app/assets/app_description.md +13 -0
  55. package/templates/drive/app/assets/icon.png +0 -0
  56. package/templates/drive/app/assets/system_instructions.md +17 -0
  57. package/templates/drive/app/kb/General/README.md +25 -0
  58. package/templates/drive/community/Apps/.gitkeep +4 -0
  59. package/templates/drive/community/community_assets/community_description.md +15 -0
  60. package/templates/drive/community/community_assets/icon.png +0 -0
  61. package/templates/drive_templates/README.md +104 -0
  62. package/templates/drive_templates/agent_app_template/assets/app_description.txt +13 -0
  63. package/templates/drive_templates/agent_app_template/assets/system_instructions.txt +17 -0
  64. package/templates/drive_templates/agent_app_template/kb/General/README.md +25 -0
  65. package/templates/drive_templates/agent_app_template/microservice/.gitkeep +10 -0
  66. package/templates/drive_templates/agent_app_template/site/.gitkeep +4 -0
  67. package/templates/drive_templates/community_template/Apps/.gitkeep +4 -0
  68. package/templates/drive_templates/community_template/community_assets/community_description.txt +15 -0
  69. package/templates/scaffolds/README.md +62 -0
  70. package/templates/scaffolds/microservice/Dockerfile +19 -0
  71. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +399 -0
  72. package/templates/scaffolds/microservice/app.js +50 -0
  73. package/templates/scaffolds/microservice/app.yaml +8 -0
  74. package/templates/scaffolds/microservice/defaults-config.json +5 -0
  75. package/templates/scaffolds/microservice/dev-overrides.json.example +5 -0
  76. package/templates/scaffolds/microservice/manifest.json +49 -0
  77. package/templates/scaffolds/microservice/package-lock.json +2645 -0
  78. package/templates/scaffolds/microservice/package.json +29 -0
  79. package/templates/scaffolds/microservice/scripts/register.js +55 -0
  80. package/templates/scaffolds/microservice/services/apiFront.js +75 -0
  81. package/templates/scaffolds/microservice/services/commandHandlers/sampleCommands.js +57 -0
  82. package/templates/scaffolds/microservice/services/mcpClient.js +78 -0
  83. package/templates/scaffolds/microservice/services/utils.js +247 -0
  84. package/templates/scaffolds/microservice/setup-schema.json +18 -0
  85. package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +143 -0
  86. package/templates/scaffolds/site/DeSciXAppSDK.js +62 -0
  87. package/templates/scaffolds/site/README.md +51 -0
  88. package/templates/scaffolds/site/app.js +22 -0
  89. package/templates/scaffolds/site/index.html +42 -0
  90. package/templates/scaffolds/site/styles.css +123 -0
  91. package/test-hydration.js +150 -0
  92. package/vendor/mcp/transports/http-sse-transport.js +196 -0
  93. package/vendor/mcp/transports/index.js +15 -0
  94. 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';