@descix/cli 1.0.1 → 1.0.4

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 (100) hide show
  1. package/LICENSE.md +671 -0
  2. package/README.md +43 -4
  3. package/agent-assets/guides/README.md +2 -2
  4. package/agent-assets/guides/dynamic-site-pattern.md +29 -14
  5. package/agent-assets/guides/knowledge-only-pattern.md +5 -5
  6. package/agent-assets/guides/microservice-pattern.md +48 -4
  7. package/agent-assets/guides/static-site-pattern.md +8 -0
  8. package/agent-assets/instructions/app-asset-validation.md +5 -5
  9. package/agent-assets/instructions/onboarding-flow.md +4 -4
  10. package/agent-assets/instructions/standard-folder-structure.md +20 -11
  11. package/agent-assets/reference/app-creation.md +42 -20
  12. package/agent-assets/reference/architecture.md +24 -22
  13. package/agent-assets/reference/local-dev.md +230 -67
  14. package/agent-assets/reference/mcp-integration.md +21 -15
  15. package/agent-assets/reference/platform-api-consumer.md +244 -0
  16. package/agent-assets/reference/workspace-config.md +7 -7
  17. package/agent-assets/rules/descix_mcp.template.mdc +8 -8
  18. package/bin/descix.js +2559 -1435
  19. package/bin/kb-deny-lint.mjs +91 -0
  20. package/bin/mcp-server.js +175 -91
  21. package/lib/agent-files.js +37 -25
  22. package/lib/api-client.js +223 -56
  23. package/lib/auth-guard.js +13 -3
  24. package/lib/chat-session.js +187 -0
  25. package/lib/cli-version.js +59 -0
  26. package/lib/commands/airdrop.js +251 -0
  27. package/lib/commands/auth.js +254 -72
  28. package/lib/commands/briefer/errors.js +88 -0
  29. package/lib/commands/briefer/index.js +319 -0
  30. package/lib/commands/briefer/sources/canonical-sources.js +129 -0
  31. package/lib/commands/briefer/sources/entitlements.js +220 -0
  32. package/lib/commands/briefer/sources/environments.js +205 -0
  33. package/lib/commands/briefer/sources/identifiers.js +214 -0
  34. package/lib/commands/briefer/sources/microservice-deploy.js +267 -0
  35. package/lib/commands/briefer/sources/routing.js +275 -0
  36. package/lib/commands/briefer/sources/what-is-not.js +223 -0
  37. package/lib/commands/briefer/stitcher.js +134 -0
  38. package/lib/commands/briefer/util/source-reader.js +502 -0
  39. package/lib/commands/config.js +210 -90
  40. package/lib/commands/corpus.js +978 -0
  41. package/lib/commands/credits.js +122 -0
  42. package/lib/commands/health.js +627 -0
  43. package/lib/commands/kb-list-render.js +32 -0
  44. package/lib/commands/kb.js +190 -413
  45. package/lib/commands/mcp.js +5 -5
  46. package/lib/commands/microservice-restart.js +359 -0
  47. package/lib/commands/model-config.js +313 -0
  48. package/lib/commands/retired-kb-sync.js +134 -0
  49. package/lib/commands/serve.js +26 -1
  50. package/lib/commands/status.js +23 -51
  51. package/lib/commands/update.js +36 -107
  52. package/lib/core/Chunker.js +491 -17
  53. package/lib/core/CorpusDenyLint.js +326 -0
  54. package/lib/core/CorpusWalker.js +357 -0
  55. package/lib/core/Hydrator.js +75 -29
  56. package/lib/core/ManifestLoader.js +533 -0
  57. package/lib/core/SiteWalker.js +218 -0
  58. package/lib/core/Syncer.js +163 -11
  59. package/lib/diagnostic-log.js +41 -0
  60. package/lib/environment-report.js +100 -0
  61. package/lib/global-config.js +4 -1
  62. package/lib/oauth-client.js +164 -0
  63. package/lib/origin.js +143 -0
  64. package/lib/service-api-client.js +95 -0
  65. package/lib/wallet-environment.js +49 -0
  66. package/lib/wallet-file.js +16 -2
  67. package/lib/wizard/setup.js +10 -9
  68. package/lib/workspace-config.js +380 -235
  69. package/lib/workspace-identity.js +92 -0
  70. package/lib/workspace-utils.js +41 -61
  71. package/package.json +19 -6
  72. package/templates/agent-claude.md +157 -9
  73. package/templates/agent-cline.md +68 -8
  74. package/templates/agent-copilot.md +68 -8
  75. package/templates/agent-cursor.md +68 -8
  76. package/templates/alignment-schema.json +20 -0
  77. package/templates/cloud-brain-hook.sh +20 -0
  78. package/templates/drive/README.md +1 -1
  79. package/templates/drive_templates/README.md +2 -2
  80. package/templates/scaffolds/README.md +6 -10
  81. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +104 -149
  82. package/templates/scaffolds/microservice/app.js +51 -10
  83. package/templates/scaffolds/microservice/app.yaml +2 -2
  84. package/templates/scaffolds/microservice/defaults-config-dev.json +4 -0
  85. package/templates/scaffolds/microservice/dev-overrides.example.json +5 -0
  86. package/templates/scaffolds/microservice/manifest.json +0 -1
  87. package/templates/scaffolds/microservice/package.json +3 -3
  88. package/templates/scaffolds/microservice/scripts/register.js +56 -41
  89. package/templates/scaffolds/microservice/services/mcpClient.js +55 -65
  90. package/templates/scaffolds/microservice/services/utils.js +20 -238
  91. package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +24 -0
  92. package/lib/commands/app-wizard.js +0 -627
  93. package/lib/commands/folder.js +0 -291
  94. package/lib/core/PathContext.js +0 -335
  95. package/templates/scaffolds/microservice/dev-overrides.json.example +0 -5
  96. package/templates/scaffolds/site/DeSciXAppSDK.js +0 -62
  97. package/templates/scaffolds/site/README.md +0 -51
  98. package/templates/scaffolds/site/app.js +0 -22
  99. package/templates/scaffolds/site/index.html +0 -42
  100. package/templates/scaffolds/site/styles.css +0 -123
@@ -0,0 +1,978 @@
1
+ /**
2
+ * Corpus Commands - Git-aware RAG corpus sync
3
+ *
4
+ * Commands for syncing git-managed source files to Pinecone RAG via manifests.
5
+ * Manifests declare a *view* over files — files stay in place, git does versioning.
6
+ *
7
+ * Commands:
8
+ * - corpus sync: Walk manifest sources, chunk changed files, sync to Pinecone
9
+ * - corpus status: Show sync state (files, chunks, last sync)
10
+ *
11
+ * Architecture:
12
+ * - ManifestLoader reads {app_root}/.descix/manifests/{kb_name}.json
13
+ * - CorpusWalker walks dirs, applies syncignore, computes git blob SHAs
14
+ * - Chunker (existing) chunks files by type (code, docs, generic)
15
+ * - Syncer (existing) pushes chunks to Pinecone via kb_sync_chunks API
16
+ * - Chunk IDs are content-addressed: {app_id}:{kb_name}:{blob_sha}:{chunk_idx}
17
+ * - CEO-D12: Single Pinecone namespace, metadata filtering (kb_name: "Corpus")
18
+ * - CEO-D16: Manual sync trigger only — no CI hooks
19
+ */
20
+
21
+ import chalk from 'chalk';
22
+ import ora from 'ora';
23
+ import * as fs from 'fs/promises';
24
+ import * as path from 'path';
25
+ import { createRequire } from 'module';
26
+ import { WorkspaceConfig } from '../workspace-config.js';
27
+ import { loadManifests } from '../core/ManifestLoader.js';
28
+ import { walkCorpus } from '../core/CorpusWalker.js';
29
+ import { chunkFile, categorizeFile } from '../core/Chunker.js';
30
+ import { upsertChunks, deleteStaleChunks, deleteStaleChunksByFileId, listRemoteFileIds, purgeKbScope } from '../core/Syncer.js';
31
+ import { lintManifestForDenyClasses, assertNoViolations } from '../core/CorpusDenyLint.js';
32
+
33
+ /**
34
+ * Resolve community_id for corpus sync — same precedence as site upload / update kb:
35
+ * CLI -c > workspace.json > Products registry (get_product_context). No hardcoded defaults.
36
+ */
37
+ async function resolveCorpusCommunityId(apiClient, workspaceConfig, appId, options) {
38
+ const ctx = workspaceConfig.resolveContextWithOptions(options);
39
+ const appConfig = workspaceConfig.getAppByAppId(appId);
40
+ let communityId = ctx.communityId || appConfig?.communityId || null;
41
+
42
+ if (!communityId) {
43
+ const productCtx = await apiClient.invoke('get_product_context', { app_id: appId });
44
+ communityId = productCtx?.community_id ?? productCtx?.message?.community_id ?? null;
45
+ }
46
+
47
+ if (!communityId) {
48
+ throw new Error(
49
+ `Could not resolve community_id for app "${appId}".\n` +
50
+ `Pass -c <community_id> or ensure Products/${appId} is registered (bootstrap / descix app init).`
51
+ );
52
+ }
53
+
54
+ return communityId;
55
+ }
56
+
57
+ /**
58
+ * Read a file and chunk it using the existing Chunker.
59
+ * Returns chunk records with corpus-specific metadata and content-addressed IDs.
60
+ *
61
+ * Chunk ID format: {app_id}:{kb_name}:{blob_sha}:{chunk_idx}
62
+ * This is content-addressed — same file content = same blob SHA = same chunk IDs.
63
+ *
64
+ * @param {Object} fileEntry - From CorpusWalker { absolute_path, relative_path, blob_sha, source_entry, source_repo }
65
+ * @param {Object} context - { appId, communityId, kbName }
66
+ * @returns {Promise<Array>} Pinecone-ready chunk records
67
+ */
68
+ async function chunkCorpusFile(fileEntry, context) {
69
+ const { appId, communityId, kbName } = context;
70
+ const { absolute_path, relative_path, blob_sha, source_entry, source_repo } = fileEntry;
71
+
72
+ // Read file content
73
+ const content = await fs.readFile(absolute_path, 'utf-8');
74
+ const fileName = path.basename(absolute_path);
75
+
76
+ // Determine MIME type from extension
77
+ let mimeType = 'text/plain';
78
+ if (fileName.endsWith('.md')) mimeType = 'text/markdown';
79
+ else if (fileName.endsWith('.js') || fileName.endsWith('.mjs')) mimeType = 'application/javascript';
80
+ else if (fileName.endsWith('.json')) mimeType = 'application/json';
81
+ else if (fileName.endsWith('.jsonl')) mimeType = 'application/jsonl';
82
+
83
+ // Skip empty files — they produce chunks with no text which fails validation
84
+ if (!content || content.trim().length === 0) {
85
+ return [];
86
+ }
87
+
88
+ // M1 (2026-04-20): the 500KB silent-drop cap was removed after it was found
89
+ // to be the proximate cause of 77% of EGPT's concordance content missing from
90
+ // Pinecone. The Chunker now handles large files via content-aware splitting
91
+ // (structured-json / JS object-literal extraction / line-aware sliding window).
92
+ // A generous sanity cap protects against accidentally ingesting minified
93
+ // bundles or binary blobs that slipped past the manifest walker.
94
+ const MAX_SRC_BYTES = 20 * 1024 * 1024; // 20MB: anything bigger should be excluded in the manifest
95
+ if (content.length > MAX_SRC_BYTES) {
96
+ console.warn(` ⚠ Skipping ${fileName}: ${content.length} bytes exceeds ${MAX_SRC_BYTES} sanity cap. Exclude via manifest syncignore.`);
97
+ return [];
98
+ }
99
+
100
+ // Use existing Chunker
101
+ const rawChunks = chunkFile({ file_name: fileName, mime_type: mimeType, content });
102
+
103
+ // Pinecone metadata limit is 40KB per vector.
104
+ // text field is stored as metadata. Keep each chunk under 35KB text to leave room.
105
+ const MAX_CHUNK_TEXT_BYTES = 35000;
106
+
107
+ // Convert to Pinecone-ready records with corpus-specific metadata
108
+ // Filter out any chunks with empty text (safety net for edge cases)
109
+ return rawChunks.map((chunk, idx) => {
110
+ const chunkIdx = chunk.metadata.chunkIndex ?? idx;
111
+ // Content-addressed ID: same blob SHA = same IDs
112
+ const id = `${appId}:${kbName}:${blob_sha}:${chunkIdx}`;
113
+
114
+ let text = chunk.content.trim();
115
+ // Truncate if text exceeds Pinecone metadata budget
116
+ if (Buffer.byteLength(text, 'utf-8') > MAX_CHUNK_TEXT_BYTES) {
117
+ text = text.substring(0, MAX_CHUNK_TEXT_BYTES);
118
+ }
119
+
120
+ if (!text) return null; // Skip empty chunks
121
+
122
+ return {
123
+ id,
124
+ text,
125
+ entity_type: 'CHUNK',
126
+ // Multi-tenancy fields (required for filtering)
127
+ community_id: communityId,
128
+ app_id: appId,
129
+ knowledgebase_name: kbName,
130
+ // file_id required by pineconeService validator — use blob_sha as content-addressed ID
131
+ file_id: `corpus:${blob_sha}`,
132
+ // Corpus-specific metadata
133
+ blob_sha,
134
+ source_path: relative_path,
135
+ source_repo,
136
+ tier: source_entry.tier,
137
+ doc_type: source_entry.doc_type,
138
+ // KB-curation metadata — rides into Pinecone so query surfaces can
139
+ // default-filter to publishable doc_classes and surface provenance.
140
+ doc_class: source_entry.doc_class,
141
+ license_basis: source_entry.license_basis,
142
+ synced_from_edit: source_entry.synced_from_edit,
143
+ // Chunk metadata
144
+ file_name: fileName,
145
+ mime_type: mimeType,
146
+ chunk_idx: chunkIdx,
147
+ total_chunks: rawChunks.length,
148
+ chunk_type: chunk.metadata.type,
149
+ chunk_title: chunk.metadata.title || chunk.metadata.name || null
150
+ };
151
+ }).filter(Boolean);
152
+ }
153
+
154
+ /**
155
+ * Save sync state after a successful corpus sync.
156
+ *
157
+ * @param {string} appRoot - App root directory
158
+ * @param {string} kbName - Knowledge base name
159
+ * @param {Object} state - { last_sync_commit, sources_provenance, synced_files_count, total_chunks, timestamp }
160
+ */
161
+ async function saveSyncState(appRoot, kbName, state) {
162
+ const stateDir = path.join(appRoot, '.descix', 'sync-state');
163
+ await fs.mkdir(stateDir, { recursive: true });
164
+ const statePath = path.join(stateDir, `${kbName}.json`);
165
+ await fs.writeFile(statePath, JSON.stringify(state, null, 2));
166
+ }
167
+
168
+ /**
169
+ * Load sync state from a previous corpus sync.
170
+ *
171
+ * @param {string} appRoot - App root directory
172
+ * @param {string} kbName - Knowledge base name
173
+ * @returns {Promise<Object|null>} Sync state or null if no previous sync
174
+ */
175
+ async function loadSyncState(appRoot, kbName) {
176
+ const statePath = path.join(appRoot, '.descix', 'sync-state', `${kbName}.json`);
177
+ try {
178
+ const raw = await fs.readFile(statePath, 'utf-8');
179
+ return JSON.parse(raw);
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Detect the current git branch in workspaceRoot.
187
+ * Returns the branch name (e.g., "ws-admin-b1") or null if detached / not a repo.
188
+ *
189
+ * Used by `corpus sync` to emit a one-line advisory when the operator is on a
190
+ * non-main branch and --ref was not provided — flags the silent "synced main
191
+ * not your branch" gotcha that surfaced in WS-CLI-MANIFEST-REF-FEATURE-BRANCH.
192
+ */
193
+ function getCurrentBranch(workspaceRoot) {
194
+ // Lazy-require child_process via createRequire — corpus.js is ESM and lacks
195
+ // a synchronous import path, but execSync is itself sync and we want to
196
+ // avoid making the advisory async (it runs once before the manifest loop).
197
+ try {
198
+ // eslint-disable-next-line no-undef
199
+ const requireFn = createRequire(import.meta.url);
200
+ const { execSync } = requireFn('child_process');
201
+ const branch = execSync('git symbolic-ref --short HEAD', {
202
+ cwd: workspaceRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
203
+ }).trim();
204
+ return branch || null;
205
+ } catch {
206
+ // detached HEAD, no git repo, or git not installed — advisory is skipped
207
+ return null;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Resolve the ref each manifest source should be walked at.
213
+ *
214
+ * Precedence (highest first):
215
+ * 1. CLI --ref flag (applies to all sources globally — explicit operator override).
216
+ * 2. Per-source manifest "ref" field, if explicitly set in the JSON.
217
+ * 3. ManifestLoader default "main".
218
+ *
219
+ * Returns a NEW manifest object (does not mutate the input) with each
220
+ * _resolvedSources[i].ref rewritten when --ref is supplied.
221
+ *
222
+ * Also returns metadata about the resolution so the sync output can name the
223
+ * ref used and so the status command can report it.
224
+ *
225
+ * @param {Object} manifest - Validated manifest from ManifestLoader
226
+ * @param {string|null} cliRef - Operator's --ref override, or null
227
+ * @returns {{ manifest: Object, resolvedRef: string, source: 'cli'|'manifest'|'default', allDefault: boolean }}
228
+ */
229
+ function resolveRef(manifest, cliRef) {
230
+ // Detect whether every source uses the ManifestLoader default ("main") with
231
+ // no explicit override in the JSON. We can't see the raw JSON here, so we
232
+ // compare against the default. ManifestLoader rewrites src.ref to "main" if
233
+ // missing, so an explicit "main" in JSON is indistinguishable from default —
234
+ // intentional: the advisory triggers in both cases.
235
+ const allDefault = manifest._resolvedSources.every(s => s.ref === 'main');
236
+
237
+ if (cliRef) {
238
+ const rewritten = manifest._resolvedSources.map(s => ({ ...s, ref: cliRef }));
239
+ return {
240
+ manifest: { ...manifest, _resolvedSources: rewritten },
241
+ resolvedRef: cliRef,
242
+ source: 'cli',
243
+ allDefault
244
+ };
245
+ }
246
+
247
+ // No CLI override — report the ref(s) the manifest itself declares.
248
+ // If all sources share one ref, that is the resolved ref; otherwise we
249
+ // surface "mixed" so the status output is honest.
250
+ const refs = [...new Set(manifest._resolvedSources.map(s => s.ref))];
251
+ const resolvedRef = refs.length === 1 ? refs[0] : `mixed(${refs.join(',')})`;
252
+ return {
253
+ manifest,
254
+ resolvedRef,
255
+ source: allDefault ? 'default' : 'manifest',
256
+ allDefault
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Prompt the operator for a y/N confirmation on stdin.
262
+ * Default-deny (returns false unless explicit y/yes).
263
+ *
264
+ * Bypassed when options.yes is set (for scripting). The caller is responsible
265
+ * for that bypass — this helper always actually prompts when called.
266
+ *
267
+ * @param {string} prompt - The question to display (no trailing space needed)
268
+ * @returns {Promise<boolean>}
269
+ */
270
+ async function confirmYesNo(prompt) {
271
+ const readline = await import('readline');
272
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
273
+ return new Promise(resolve => {
274
+ rl.question(`${prompt} [y/N] `, answer => {
275
+ rl.close();
276
+ const a = (answer || '').trim().toLowerCase();
277
+ resolve(a === 'y' || a === 'yes');
278
+ });
279
+ });
280
+ }
281
+
282
+ /**
283
+ * Run corpus sync for an app.
284
+ *
285
+ * Algorithm:
286
+ * 1. Load WorkspaceConfig, find app by app_id, get localPath
287
+ * 2. Load manifest(s) from {localPath}/.descix/manifests/
288
+ * 3. For each manifest:
289
+ * a. Run CorpusWalker to get file list with blob SHAs
290
+ * b. Query Pinecone for existing chunks with this app_id + kb_name
291
+ * c. Compute delta: new/changed files (different blob SHA), deleted files
292
+ * d. For changed/new files: chunk using existing Chunker
293
+ * e. Sync chunks via existing Syncer (upsertChunks / deleteStaleChunks)
294
+ * f. Store sync state
295
+ *
296
+ * @param {Object} apiClient - DeSciXApiClient instance
297
+ * @param {Object} options - { app, kb, verbose }
298
+ */
299
+ export async function runCorpusSync(apiClient, options) {
300
+ const spinner = ora('Loading workspace configuration...').start();
301
+
302
+ try {
303
+ if (!apiClient) {
304
+ spinner.fail('Authentication required');
305
+ throw new Error('Run "descix login" first.');
306
+ }
307
+
308
+ // 1. Load WorkspaceConfig
309
+ const workspaceConfig = await WorkspaceConfig.load();
310
+ const workspaceRoot = workspaceConfig.getWorkspaceRoot();
311
+
312
+ // Resolve app
313
+ const appId = options.app;
314
+ if (!appId) {
315
+ spinner.fail('App ID required');
316
+ throw new Error('Use -a <app_id> to specify the app.');
317
+ }
318
+
319
+ const appConfig = workspaceConfig.getAppByAppId(appId);
320
+ if (!appConfig) {
321
+ spinner.fail(`App not found: ${appId}`);
322
+ throw new Error(`App "${appId}" not found in workspace.json. Check env.products[].`);
323
+ }
324
+
325
+ const appRoot = appConfig.absolutePath;
326
+ const communityId = await resolveCorpusCommunityId(apiClient, workspaceConfig, appId, options);
327
+
328
+ // 2. Load manifests
329
+ spinner.text = 'Loading manifests...';
330
+ const manifests = await loadManifests(appRoot, workspaceRoot, options.kb || null);
331
+
332
+ if (manifests.length === 0) {
333
+ spinner.fail('No manifests found');
334
+ const manifestsDir = path.join(appRoot, '.descix', 'manifests');
335
+ throw new Error(`No manifests found at ${manifestsDir}/\nCreate a manifest file first.`);
336
+ }
337
+
338
+ spinner.succeed(`Found ${manifests.length} manifest(s)`);
339
+
340
+ // Deliverable B: branch-mismatch advisory. We only emit this when:
341
+ // - no --ref override was supplied, AND
342
+ // - every loaded manifest's every source uses the default "main", AND
343
+ // - the operator's git HEAD is on a non-main branch.
344
+ // The advisory is informational — it does NOT block the sync. The operator
345
+ // is free to keep going (e.g., they really do want to sync from main while
346
+ // working on a feature branch). This is the silent gotcha that the
347
+ // WS-CLI-MANIFEST-REF-FEATURE-BRANCH scope doc surfaced.
348
+ if (!options.ref) {
349
+ const allManifestsDefault = manifests.every(m =>
350
+ m._resolvedSources.every(s => s.ref === 'main')
351
+ );
352
+ if (allManifestsDefault) {
353
+ const currentBranch = getCurrentBranch(workspaceRoot);
354
+ if (currentBranch && currentBranch !== 'main') {
355
+ console.log(chalk.yellow(
356
+ ` Advisory: you are on branch "${currentBranch}" but every manifest is configured to walk "main". ` +
357
+ `Pass --ref ${currentBranch} to sync your branch instead.`
358
+ ));
359
+ }
360
+ }
361
+ }
362
+
363
+ // 3. Pre-check (fail-loud): verify each manifest's target KB document exists in Firestore.
364
+ // Without the KB doc, chat queries against this KB will hard-fail in prepare_chat_context.
365
+ // We surface this BEFORE wasting time syncing vectors that no chat path can reach.
366
+ //
367
+ // Uses list_knowledge_bases (returns the actual KBs registered for the app) — NOT get_app,
368
+ // which returns only the App doc and lacks any knowledgebases field. The prior pre-check
369
+ // was reading a non-existent field and printing a false-positive warning on every sync.
370
+ let registeredKbNames = null;
371
+ try {
372
+ const kbListResp = await apiClient.invoke('list_knowledge_bases', {
373
+ community_id: communityId,
374
+ app_id: appId
375
+ });
376
+ const kbListData = kbListResp?.message || kbListResp;
377
+ const kbs = kbListData?.knowledgebases || kbListData?.knowledge_bases || [];
378
+ registeredKbNames = new Set(
379
+ kbs.map(kb => kb.knowledgebase_name || kb.kb_name || kb.name).filter(Boolean)
380
+ );
381
+ } catch (kbErr) {
382
+ // If the call itself fails (network/auth), surface and bail — don't silently proceed.
383
+ spinner.fail(`Could not verify KB registry for app "${appId}": ${kbErr.message}`);
384
+ throw new Error(
385
+ `Failed to list KnowledgeBase docs for app "${appId}". ` +
386
+ `This is required so we can refuse to sync into an unregistered KB. ` +
387
+ `Underlying error: ${kbErr.message}`
388
+ );
389
+ }
390
+
391
+ const requestedKbNames = manifests.map(m => m.kb_name);
392
+ const missingKbNames = requestedKbNames.filter(name => !registeredKbNames.has(name));
393
+ if (missingKbNames.length > 0) {
394
+ // Hard-fail: every KB we are about to sync into must already be registered in Firestore.
395
+ // No hardcoded fallback. `app init` is the verb that actually creates a KB on a
396
+ // git-mode app (init_git_mode_kb, bin/descix.js:1163). `kb create` is Drive-mode
397
+ // wired (create_skeleton_kb, :2087) and fails here — filed as its own defect.
398
+ spinner.fail(`Unregistered KB(s) for app "${appId}"`);
399
+ throw new Error(
400
+ `KnowledgeBase document(s) missing in Firestore for app "${appId}": ${missingKbNames.join(', ')}.\n` +
401
+ `Registered KBs for this app: ${[...registeredKbNames].join(', ') || '(none)'}.\n` +
402
+ `Syncing vectors into Pinecone without a matching KB doc would orphan them (chat queries hard-fail).\n` +
403
+ `Fix: run \`descix app init -a ${appId}\` — it creates the KB via init_git_mode_kb and needs no Drive folder.\n` +
404
+ `(\`descix kb create\` currently routes through create_skeleton_kb and FAILS on a git-mode app.)`
405
+ );
406
+ }
407
+
408
+ // 4. Process each manifest
409
+ let totalFilesProcessed = 0;
410
+ let totalChunksCreated = 0;
411
+ let totalFilesSkipped = 0;
412
+ let totalFilesDeleted = 0;
413
+
414
+ for (const rawManifest of manifests) {
415
+ // ── Deliverable B: resolve --ref override (CLI > manifest > default) ──
416
+ const refResolution = resolveRef(rawManifest, options.ref || null);
417
+ const manifest = refResolution.manifest;
418
+ const kbName = manifest.kb_name;
419
+
420
+ console.log(chalk.cyan(`\nSyncing corpus: ${kbName}`));
421
+ // Always name the ref used so the operator can see exactly what was walked.
422
+ const refLabel = refResolution.source === 'cli'
423
+ ? `${refResolution.resolvedRef} (--ref override)`
424
+ : refResolution.source === 'manifest'
425
+ ? `${refResolution.resolvedRef} (manifest)`
426
+ : `${refResolution.resolvedRef} (default)`;
427
+ console.log(chalk.gray(` Ref: ${refLabel}`));
428
+
429
+ // ── K2: Tier-P deny-class lint (CEO-D-2026-07-11-KB-CURATION-RATIFIED) ──
430
+ // A publish_tier:"P" manifest is a PUBLISHING ACT. Before ANY walk or
431
+ // Pinecone read/write, run the canonical deny lint: print every exemption
432
+ // and warning (the review artifact, model §5.3), then FAIL LOUD on any
433
+ // violation. Non-Tier-P manifests (publish_tier "I"/absent) skip this
434
+ // entirely — current behavior unchanged.
435
+ if (manifest.publish_tier === 'P') {
436
+ const lintSpinner = ora(' [deny-lint] Linting Tier-P manifest for deny-classes...').start();
437
+ let denyReport;
438
+ try {
439
+ denyReport = await lintManifestForDenyClasses(manifest, workspaceRoot);
440
+ } catch (lintErr) {
441
+ lintSpinner.fail(` [deny-lint] Lint could not run: ${lintErr.message}`);
442
+ throw lintErr;
443
+ }
444
+ lintSpinner.stop();
445
+ // Print the review artifact — every exemption and warning, always.
446
+ for (const ex of denyReport.exemptions) console.log(chalk.yellow(` ${ex.line}`));
447
+ for (const w of denyReport.warnings) console.log(chalk.yellow(` ${w.line}`));
448
+ // Fail loud BEFORE the walk / any Pinecone op if there are violations.
449
+ assertNoViolations(denyReport);
450
+ console.log(chalk.green(
451
+ ` [deny-lint] OK — ${denyReport.exemptions.length} exemption(s), ` +
452
+ `${denyReport.warnings.length} warning(s), 0 violation(s).`
453
+ ));
454
+ }
455
+
456
+ // 3a. Walk corpus
457
+ const walkSpinner = ora(' Walking source directories...').start();
458
+ const { files, commitSha, provenance } = await walkCorpus(manifest, workspaceRoot);
459
+ walkSpinner.succeed(` Found ${files.length} files (commit: ${commitSha.substring(0, 8)})`);
460
+
461
+ // ── Deliverable A: --show-walk prints the resolved ref + first 50 files
462
+ // BEFORE any Pinecone read/write so the operator can sanity-check the
463
+ // walk results without trusting downstream output.
464
+ if (options.showWalk) {
465
+ console.log(chalk.cyan(` [show-walk] Resolved ref: ${refResolution.resolvedRef} (source=${refResolution.source})`));
466
+ console.log(chalk.cyan(` [show-walk] Walked ${files.length} file(s). First ${Math.min(50, files.length)}:`));
467
+ for (const f of files.slice(0, 50)) {
468
+ console.log(chalk.gray(` ${f.blob_sha.substring(0, 8)} ${f.relative_path}`));
469
+ }
470
+ if (files.length > 50) {
471
+ console.log(chalk.gray(` ... and ${files.length - 50} more`));
472
+ }
473
+ }
474
+
475
+ if (files.length === 0) {
476
+ console.log(chalk.yellow(' No files found in sources. Skipping.'));
477
+ continue;
478
+ }
479
+
480
+ // 3b-rebuild: When --rebuild is set, do a FULL metadata-scoped purge of the
481
+ // ENTIRE {community_id, app_id, knowledgebase_name} scope, then re-upsert the
482
+ // full local walk below. This is the WS-KB-CORPUS-SCOPEPURGE fix: the prior
483
+ // implementation enumerated `corpus:<blob_sha>` file_ids and purged only the
484
+ // ones absent from the current walk — but the BULK of orphan pollution came
485
+ // from the legacy `kb sync` path (chunk_ids, no `corpus:` file_id) which is
486
+ // INVISIBLE to that enumeration, so rebuild left those orphans live. The only
487
+ // primitive that clears them is a delete by Pinecone metadata filter
488
+ // {community_id, app_id, knowledgebase_name} — server-side that is
489
+ // kb_delete_chunks(purge_scope:true) → KnowledgeBase.deleteRAG(), which also
490
+ // resets the KB doc's rag_vector_count (the field `kb doctor` reads).
491
+ //
492
+ // We still enumerate file_ids first, but ONLY to report the live state for the
493
+ // dry-run preview / confirmation message. The actual purge is full-scope.
494
+ //
495
+ // Deliverable A preserved: --dry-run writes nothing, interactive confirmation
496
+ // guard intact, --yes skips it.
497
+ let rebuildPurged = 0;
498
+ let rebuildScopePurged = false;
499
+ let rebuildRemoteUniqueCount = 0;
500
+ let rebuildRemoteTotalChunks = 0;
501
+ if (options.rebuild) {
502
+ const rebuildSpinner = ora(' [rebuild] Enumerating Pinecone namespace...').start();
503
+ try {
504
+ // Report-only enumeration (corpus-scheme file_ids are a LOWER BOUND on the
505
+ // true vector count — legacy orphans may not appear here, which is exactly
506
+ // why we full-scope-purge rather than purge this list).
507
+ const remote = await listRemoteFileIds(apiClient, appId, kbName);
508
+ rebuildRemoteUniqueCount = remote.unique_count;
509
+ rebuildRemoteTotalChunks = remote.total_chunks;
510
+ rebuildSpinner.succeed(
511
+ ` [rebuild] Pinecone reports ${remote.unique_count} corpus-scheme file_id(s) / ` +
512
+ `${remote.total_chunks} enumerable chunk(s) (legacy orphans may exceed this)`
513
+ );
514
+
515
+ if (options.dryRun) {
516
+ // DRY-RUN: describe the full-scope purge. No writes.
517
+ console.log(chalk.yellow(
518
+ ` [rebuild][dry-run] WOULD purge the ENTIRE scope ` +
519
+ `${communityId}/${appId}/${kbName} (all vectors, any scheme), then re-upsert ` +
520
+ `the full local walk. No action taken.`
521
+ ));
522
+ } else {
523
+ // Interactive confirmation guard. --yes skips for scripting/CI.
524
+ console.log(chalk.yellow(
525
+ `\n About to FULL-SCOPE PURGE every vector in ${communityId}/${appId}/${kbName} ` +
526
+ `(≥${rebuildRemoteTotalChunks} enumerable chunk(s); legacy orphans included), ` +
527
+ `then re-upsert the full local walk (${files.length} file(s)).`
528
+ ));
529
+
530
+ let proceed = true;
531
+ if (!options.yes) {
532
+ const ok = await confirmYesNo(' Proceed with full-scope purge + rebuild?');
533
+ if (!ok) {
534
+ console.log(chalk.cyan(' [rebuild] Aborted by operator. No Pinecone changes made.'));
535
+ // Skip THIS manifest's rebuild + delta phases. Don't break the
536
+ // outer loop — operator might want to keep going for other KBs.
537
+ continue;
538
+ }
539
+ }
540
+
541
+ if (proceed) {
542
+ const purgeSpinner = ora(` [rebuild] Purging full scope ${communityId}/${appId}/${kbName}...`).start();
543
+ const purgeResult = await purgeKbScope(apiClient, communityId, appId, kbName);
544
+ rebuildScopePurged = purgeResult.purged_scope;
545
+ // deleteRAG via filter-based deleteMany returns -1 (count not knowable
546
+ // from the SDK). Surface honestly rather than fabricating a number.
547
+ rebuildPurged = purgeResult.deleted >= 0 ? purgeResult.deleted : 0;
548
+ purgeSpinner.succeed(
549
+ purgeResult.deleted >= 0
550
+ ? ` [rebuild] Purged ${purgeResult.deleted} vector(s) (full scope) and reset KB doc rag fields`
551
+ : ` [rebuild] Purged full scope (filter-based delete; SDK reports no count) and reset KB doc rag fields`
552
+ );
553
+ }
554
+ }
555
+ } catch (err) {
556
+ rebuildSpinner.fail(` [rebuild] Failed: ${err.message}`);
557
+ // Re-throw — rebuild is opt-in and explicit; silent partial failure would
558
+ // leave the namespace half-purged, exactly the silent-degradation we are closing.
559
+ throw err;
560
+ }
561
+ }
562
+
563
+ // 3b. Load previous sync state for delta computation
564
+ // We use local sync state (stored blob SHAs) instead of querying Pinecone,
565
+ // because Pinecone metadata queries don't scale (payload size limits).
566
+ // For --rebuild runs we INVALIDATE prior state so every file re-chunks + re-upserts
567
+ // (otherwise unchanged-by-blob-sha files would be skipped despite the purge above).
568
+ //
569
+ // Deliverable A note: in dry-run + rebuild we ALSO null previousState so
570
+ // the count below reflects what a real rebuild would upsert (all files),
571
+ // not what an incremental sync would upsert.
572
+ let previousState = await loadSyncState(appRoot, kbName);
573
+ if (options.rebuild) {
574
+ previousState = null;
575
+ }
576
+ const previousBlobShas = new Set(previousState?.synced_blob_shas || []);
577
+ const previousChunkCount = previousState?.total_chunks || 0;
578
+
579
+ if (previousBlobShas.size > 0) {
580
+ console.log(chalk.gray(` Previous sync: ${previousBlobShas.size} files, ${previousChunkCount} chunks`));
581
+ } else {
582
+ console.log(chalk.gray(' First sync (no previous state)'));
583
+ }
584
+
585
+ // 3c. Compute delta — compare by blob SHA against local sync state
586
+ const localBlobShas = new Set(files.map(f => f.blob_sha));
587
+ const newOrChangedFiles = files.filter(f => !previousBlobShas.has(f.blob_sha));
588
+
589
+ // Stale blob_shas: anything we synced previously that is no longer in the local
590
+ // walk. This covers BOTH cases:
591
+ // (a) File deleted from the source corpus entirely.
592
+ // (b) File modified in place — same source_path, new blob_sha → old blob_sha
593
+ // is no longer in localBlobShas.
594
+ // Each stale blob_sha maps to a file_id of `corpus:{blob_sha}` in Pinecone metadata
595
+ // and is purged via the deleteStaleChunksByFileId primitive (multi-tenancy filter).
596
+ const deletedBlobShas = [...previousBlobShas].filter(sha => !localBlobShas.has(sha));
597
+ const fileIdsToDelete = deletedBlobShas.map(sha => `corpus:${sha}`);
598
+
599
+ const unchangedCount = files.length - newOrChangedFiles.length;
600
+
601
+ if (options.verbose) {
602
+ console.log(chalk.gray(` New/changed files: ${newOrChangedFiles.length}`));
603
+ console.log(chalk.gray(` Unchanged files: ${unchangedCount}`));
604
+ console.log(chalk.gray(` Stale blob SHAs (delete): ${deletedBlobShas.length}`));
605
+ }
606
+
607
+ // 3d. Chunk new/changed files
608
+ const allChunks = [];
609
+ if (newOrChangedFiles.length > 0) {
610
+ const chunkSpinner = ora(` Chunking ${newOrChangedFiles.length} file(s)...`).start();
611
+
612
+ for (const fileEntry of newOrChangedFiles) {
613
+ try {
614
+ const chunks = await chunkCorpusFile(fileEntry, { appId, communityId, kbName });
615
+ allChunks.push(...chunks);
616
+
617
+ if (chunks.length === 0) {
618
+ // M1 (2026-04-20): always surface 0-chunk files even without -v.
619
+ // These are the silent failures that caused the 77% EGPT gap.
620
+ // 'kb doctor' scans sync logs for this exact string.
621
+ console.log(chalk.yellow(` ⚠ 0-chunk: ${fileEntry.relative_path} produced no chunks (check file type / content)`));
622
+ } else if (options.verbose) {
623
+ console.log(chalk.gray(` ${fileEntry.relative_path}: ${chunks.length} chunks`));
624
+ }
625
+ } catch (err) {
626
+ // Always surface errors — skipping silently is what produced the 2026-04-20 regression.
627
+ console.log(chalk.yellow(` ⚠ skipped: ${fileEntry.relative_path} (${err.message})`));
628
+ }
629
+ }
630
+
631
+ chunkSpinner.succeed(` Chunked: ${allChunks.length} chunks from ${newOrChangedFiles.length} files`);
632
+ }
633
+
634
+ // Deliverable A: dry-run summary + early continue. We do all the
635
+ // counting work above (walk + chunk + remote enumeration) so the operator
636
+ // sees the EXACT plan that --rebuild (without --dry-run) would execute,
637
+ // but we never call upsertChunks / deleteStaleChunksByFileId.
638
+ if (options.dryRun) {
639
+ console.log(chalk.cyan(`\n [dry-run] Plan for ${kbName}:`));
640
+ console.log(chalk.white(` Would upsert: ${allChunks.length} chunks from ${newOrChangedFiles.length} file(s)`));
641
+ console.log(chalk.white(` Would delete (stale-file): ${fileIdsToDelete.length} blob SHA(s)`));
642
+ if (options.rebuild) {
643
+ console.log(chalk.white(` Would purge (rebuild): FULL scope ${communityId}/${appId}/${kbName} (≥${rebuildRemoteTotalChunks} enumerable chunks; legacy orphans included)`));
644
+ }
645
+ // Track for the summary exit-code computation at the end of the loop.
646
+ totalFilesProcessed += newOrChangedFiles.length;
647
+ totalChunksCreated += allChunks.length;
648
+ totalFilesSkipped += unchangedCount;
649
+ // Full-scope rebuild would purge ≥ the enumerable chunk count. Use it as the
650
+ // would-delete tally so dry-run reports drift whenever a purge would run.
651
+ totalFilesDeleted += fileIdsToDelete.length + (options.rebuild ? rebuildRemoteTotalChunks : 0);
652
+ // Do NOT call upsertChunks, deleteStaleChunksByFileId, deleteStaleChunks,
653
+ // or saveSyncState in dry-run. Continue to next manifest.
654
+ continue;
655
+ }
656
+
657
+ // 3e. Sync to Pinecone with rate limiting
658
+ // Pinecone has a 1M token/min rate limit for embeddings.
659
+ // We batch in small groups with delays between to stay under the limit.
660
+ let upserted = 0;
661
+ let deleted = 0;
662
+ const syncFailures = [];
663
+
664
+ if (allChunks.length > 0) {
665
+ const syncSpinner = ora(` Upserting ${allChunks.length} chunks...`).start();
666
+ const BATCH_SIZE = 30; // Small batches to manage Pinecone embedding rate limits (1M tokens/min)
667
+ const BATCH_DELAY_MS = 5000; // 5 second delay between batches
668
+ const MAX_RETRIES = 5;
669
+
670
+ for (let i = 0; i < allChunks.length; i += BATCH_SIZE) {
671
+ const batch = allChunks.slice(i, i + BATCH_SIZE);
672
+ let retries = 0;
673
+ let success = false;
674
+
675
+ while (!success && retries < MAX_RETRIES) {
676
+ try {
677
+ const result = await upsertChunks(apiClient, communityId, appId, kbName, batch);
678
+ upserted += result.upserted;
679
+ success = true;
680
+ syncSpinner.text = ` Upserting chunks... ${i + batch.length}/${allChunks.length}`;
681
+ } catch (err) {
682
+ const msg = err.message || '';
683
+ const isRateLimit = msg.includes('429') || msg.includes('RESOURCE_EXHAUSTED');
684
+ // A per-batch upsert TIMEOUT (server raced out the wedged Pinecone call →
685
+ // UPSERT_TIMEOUT) or a transient network/gateway stall (ETIMEDOUT / 504 /
686
+ // 502 / 503 / ECONNRESET) is RESUMABLE — the upsert is id-keyed and
687
+ // idempotent, so retrying the same batch overwrites any partially-landed
688
+ // records. We must NOT skip it (that silently drops chunks). Retry with
689
+ // backoff like a rate-limit. This is what makes a re-run resume cleanly
690
+ // and a single rebuild never wedge for hours.
691
+ const isResumableStall =
692
+ msg.includes('UPSERT_TIMEOUT') ||
693
+ msg.includes('ETIMEDOUT') ||
694
+ msg.includes('timed out') ||
695
+ msg.includes('504') || msg.includes('502') || msg.includes('503') ||
696
+ msg.includes('ECONNRESET') || msg.includes('socket hang up');
697
+
698
+ if (isRateLimit || isResumableStall) {
699
+ retries++;
700
+ const backoff = BATCH_DELAY_MS * Math.pow(2, retries);
701
+ const reason = isRateLimit ? 'Rate limited' : 'Transient stall/timeout';
702
+ syncSpinner.text = ` ${reason}. Waiting ${backoff / 1000}s before retry ${retries}/${MAX_RETRIES} (batch ${i}-${i + batch.length})...`;
703
+ await new Promise(r => setTimeout(r, backoff));
704
+ } else {
705
+ // Genuine non-transient error (e.g. validation): log failed chunks,
706
+ // continue with remaining batches.
707
+ const failedIds = batch.map(c => c.id);
708
+ syncFailures.push({ error: err.message, chunks: failedIds });
709
+ syncSpinner.text = ` Batch failed (${failedIds.length} chunks): ${err.message.substring(0, 80)}. Continuing...`;
710
+ success = true; // Don't retry non-transient errors, move to next batch
711
+ }
712
+ }
713
+ }
714
+
715
+ if (!success) {
716
+ syncSpinner.warn(
717
+ ` Batch ${i}-${i + batch.length} did not complete after ${MAX_RETRIES} retries. ` +
718
+ `Synced ${upserted} chunks so far. This run is BOUNDED (it did not hang) — ` +
719
+ `re-run 'descix kb corpus sync -a ${appId} -k ${kbName}' to RESUME: id-keyed upserts ` +
720
+ `are idempotent so already-synced batches are cheap and the remainder continues.`
721
+ );
722
+ break;
723
+ }
724
+
725
+ // Delay between batches to stay under rate limit
726
+ if (i + BATCH_SIZE < allChunks.length) {
727
+ await new Promise(r => setTimeout(r, BATCH_DELAY_MS));
728
+ }
729
+ }
730
+
731
+ syncSpinner.succeed(` Upserted: ${upserted} chunks`);
732
+
733
+ // Write failure log to .descix if any batches failed
734
+ if (syncFailures.length > 0) {
735
+ const failLogDir = path.join(appRoot, '.descix', 'sync-state');
736
+ await fs.mkdir(failLogDir, { recursive: true });
737
+ const failLogPath = path.join(failLogDir, `${kbName}-failures.json`);
738
+ await fs.writeFile(failLogPath, JSON.stringify({
739
+ timestamp: new Date().toISOString(),
740
+ commit: commitSha,
741
+ failures: syncFailures,
742
+ }, null, 2));
743
+ console.log(chalk.yellow(` ⚠ ${syncFailures.length} batch(es) failed. Details: ${failLogPath}`));
744
+ }
745
+ }
746
+
747
+ if (fileIdsToDelete.length > 0) {
748
+ const deleteSpinner = ora(` Purging stale chunks for ${fileIdsToDelete.length} blob SHA(s)...`).start();
749
+ try {
750
+ // Use file_id-based delete: each stale blob_sha maps to file_id `corpus:{sha}`,
751
+ // and pineconeService::deleteVectorsByFileId scopes by full multi-tenancy filter
752
+ // (community_id, app_id, knowledgebase_name) — guarantees namespace isolation.
753
+ const result = await deleteStaleChunksByFileId(apiClient, communityId, appId, kbName, fileIdsToDelete);
754
+ deleted = result.deleted;
755
+ deleteSpinner.succeed(` Purged: ${deleted} chunk(s) across ${fileIdsToDelete.length} stale blob SHA(s)`);
756
+ } catch (err) {
757
+ deleteSpinner.warn(` Delete failed: ${err.message}`);
758
+ }
759
+ }
760
+
761
+ // 3f. Save sync state with blob SHAs for future delta computation
762
+ const allSyncedBlobShas = [...localBlobShas]; // All current files
763
+ await saveSyncState(appRoot, kbName, {
764
+ last_sync_commit: commitSha,
765
+ // Per-source provenance: WHICH repo, at WHICH ref, and the RESOLVED COMMIT SHA actually
766
+ // synced. `ref` may be a mutable branch, so the ref alone is not a record of what was
767
+ // synced — the resolved sha is, and it stays exact after the branch moves.
768
+ sources_provenance: provenance,
769
+ synced_files_count: files.length,
770
+ total_chunks: previousChunkCount + upserted - deleted,
771
+ timestamp: new Date().toISOString(),
772
+ files_upserted: newOrChangedFiles.length,
773
+ files_unchanged: unchangedCount,
774
+ chunks_upserted: upserted,
775
+ chunks_deleted: deleted,
776
+ synced_blob_shas: allSyncedBlobShas
777
+ });
778
+
779
+ // 3g. AUTO-RECONCILE the cached rag_vector_count to the TRUE live Pinecone
780
+ // count after a COMPLETED, clean sync (no batch failures). This keeps the
781
+ // doctor's fast cached read truthful after every clean run, so drift only
782
+ // ever appears mid-flight (on interruption). Skipped if any batch failed
783
+ // (the run is partial — reconciling would bake in the partial state and the
784
+ // re-run will reconcile once complete). Non-fatal: a reconcile hiccup must
785
+ // not fail an otherwise-successful sync.
786
+ if (syncFailures.length === 0) {
787
+ try {
788
+ const recRes = await apiClient.invoke('get_kb_rag_status', {
789
+ app_id: appId, kb_id: kbName, reconcile: true
790
+ }, { allowGuest: false });
791
+ const rec = recRes.message || recRes;
792
+ if (rec && typeof rec.reconcileAfter === 'number') {
793
+ const d = rec.reconcileAfter - rec.reconcileBefore;
794
+ console.log(chalk.gray(
795
+ ` ⟳ Reconciled live count: cached ${rec.reconcileBefore} → live ${rec.reconcileAfter}` +
796
+ (d === 0 ? ' (already truthful)' : ` (${d >= 0 ? '+' : ''}${d})`)
797
+ ));
798
+ }
799
+ } catch (recErr) {
800
+ console.log(chalk.yellow(` ⚠ Auto-reconcile skipped (non-fatal): ${recErr.message}`));
801
+ }
802
+ }
803
+
804
+ totalFilesProcessed += newOrChangedFiles.length;
805
+ totalChunksCreated += upserted;
806
+ totalFilesSkipped += unchangedCount;
807
+ totalFilesDeleted += deleted + rebuildPurged;
808
+ }
809
+
810
+ // Summary
811
+ if (options.dryRun) {
812
+ const driftCount = totalFilesProcessed + totalFilesDeleted;
813
+ console.log(chalk.cyan('\nCorpus sync dry-run complete:'));
814
+ console.log(chalk.white(` Would-upsert files: ${totalFilesProcessed}`));
815
+ console.log(chalk.white(` Would-upsert chunks: ${totalChunksCreated}`));
816
+ console.log(chalk.white(` Would-delete refs: ${totalFilesDeleted}`));
817
+ console.log(chalk.white(` Unchanged files: ${totalFilesSkipped}`));
818
+ if (driftCount === 0) {
819
+ console.log(chalk.green(' Drift: NONE. No changes would be made.\n'));
820
+ // Return value drives the bin/descix.js wrapper's process.exit code.
821
+ return { dryRun: true, drift: false, driftCount: 0 };
822
+ } else {
823
+ console.log(chalk.yellow(` Drift: ${driftCount} change(s) would be applied.\n`));
824
+ return { dryRun: true, drift: true, driftCount };
825
+ }
826
+ }
827
+
828
+ console.log(chalk.green('\nCorpus sync complete:'));
829
+ console.log(chalk.white(` Files synced: ${totalFilesProcessed}`));
830
+ console.log(chalk.white(` Chunks created: ${totalChunksCreated}`));
831
+ console.log(chalk.white(` Files unchanged: ${totalFilesSkipped}`));
832
+ if (totalFilesDeleted > 0) {
833
+ console.log(chalk.white(` Chunks deleted: ${totalFilesDeleted}`));
834
+ } else {
835
+ console.log(chalk.gray(` Chunks deleted: 0`));
836
+ }
837
+ console.log('');
838
+ return { dryRun: false };
839
+
840
+ } catch (error) {
841
+ if (spinner.isSpinning) spinner.fail('Corpus sync failed');
842
+ throw error;
843
+ }
844
+ }
845
+
846
+ /**
847
+ * Show corpus sync status for an app.
848
+ *
849
+ * Reads sync state from .descix/sync-state/{kb_name}.json and shows:
850
+ * - Last sync commit + timestamp
851
+ * - Files tracked / chunks in Pinecone
852
+ * - Files changed since last sync (quick git diff check)
853
+ *
854
+ * @param {Object} apiClient - DeSciXApiClient instance (optional for offline status)
855
+ * @param {Object} options - { app, kb, verbose }
856
+ */
857
+ export async function runCorpusStatus(apiClient, options) {
858
+ const spinner = ora('Loading workspace configuration...').start();
859
+
860
+ try {
861
+ // Load WorkspaceConfig
862
+ const workspaceConfig = await WorkspaceConfig.load();
863
+ const workspaceRoot = workspaceConfig.getWorkspaceRoot();
864
+
865
+ const appId = options.app;
866
+ if (!appId) {
867
+ spinner.fail('App ID required');
868
+ throw new Error('Use -a <app_id> to specify the app.');
869
+ }
870
+
871
+ const appConfig = workspaceConfig.getAppByAppId(appId);
872
+ if (!appConfig) {
873
+ spinner.fail(`App not found: ${appId}`);
874
+ throw new Error(`App "${appId}" not found in workspace.json.`);
875
+ }
876
+
877
+ const appRoot = appConfig.absolutePath;
878
+
879
+ // Load manifests
880
+ const manifests = await loadManifests(appRoot, workspaceRoot, options.kb || null);
881
+
882
+ if (manifests.length === 0) {
883
+ spinner.info('No manifests found');
884
+ return;
885
+ }
886
+
887
+ spinner.stop();
888
+
889
+ for (const rawManifest of manifests) {
890
+ // Apply the same ref resolution the sync command uses, so `status`
891
+ // reports the EXACT ref the next sync would walk.
892
+ const refResolution = resolveRef(rawManifest, options.ref || null);
893
+ const manifest = refResolution.manifest;
894
+ const kbName = manifest.kb_name;
895
+ const syncState = await loadSyncState(appRoot, kbName);
896
+
897
+ console.log(chalk.cyan(`\nCorpus Status: ${appId} / ${kbName}`));
898
+ console.log(chalk.gray('─'.repeat(50)));
899
+ console.log(chalk.white(` Resolved ref: ${refResolution.resolvedRef} (source=${refResolution.source})`));
900
+
901
+ // File count from a fresh walk so the operator sees the LIVE file count
902
+ // at the resolved ref, not the count from the last sync. This is the
903
+ // single most useful piece of information for diagnosing "why does this
904
+ // not match what I expect" and is cheap (no Pinecone read).
905
+ try {
906
+ const { files } = await walkCorpus(manifest, workspaceRoot);
907
+ console.log(chalk.white(` Live file count: ${files.length}`));
908
+ } catch (err) {
909
+ console.log(chalk.gray(` Live file count: (walk error: ${err.message})`));
910
+ }
911
+
912
+ if (!syncState) {
913
+ console.log(chalk.yellow(' Never synced. Run "descix kb corpus sync" first.'));
914
+ continue;
915
+ }
916
+
917
+ console.log(chalk.white(` Last sync: ${syncState.timestamp}`));
918
+ console.log(chalk.white(` Commit: ${syncState.last_sync_commit?.substring(0, 8) || 'unknown'}`));
919
+ console.log(chalk.white(` Files tracked: ${syncState.synced_files_count}`));
920
+ console.log(chalk.white(` Chunks in store: ${syncState.total_chunks}`));
921
+
922
+ // Quick change detection: walk and compare blob SHAs
923
+ if (options.verbose) {
924
+ try {
925
+ const { files } = await walkCorpus(manifest, workspaceRoot);
926
+ // Compare current blob SHAs against what we'd expect from last sync
927
+ console.log(chalk.white(` Current files: ${files.length}`));
928
+
929
+ // Check git diff since last sync commit
930
+ if (syncState.last_sync_commit && syncState.last_sync_commit !== 'unknown') {
931
+ const { execSync } = await import('child_process');
932
+ try {
933
+ const diffOutput = execSync(
934
+ `git diff --name-only "${syncState.last_sync_commit}" HEAD`,
935
+ { cwd: workspaceRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
936
+ ).trim();
937
+
938
+ if (diffOutput) {
939
+ const changedFiles = diffOutput.split('\n');
940
+ // Filter to files in our sources
941
+ const sourcePaths = manifest._resolvedSources.map(s => s.path);
942
+ const relevantChanges = changedFiles.filter(f =>
943
+ sourcePaths.some(sp => f.startsWith(sp))
944
+ );
945
+
946
+ if (relevantChanges.length > 0) {
947
+ console.log(chalk.yellow(` Changed since last sync: ${relevantChanges.length} file(s)`));
948
+ for (const f of relevantChanges.slice(0, 10)) {
949
+ console.log(chalk.gray(` - ${f}`));
950
+ }
951
+ if (relevantChanges.length > 10) {
952
+ console.log(chalk.gray(` ... and ${relevantChanges.length - 10} more`));
953
+ }
954
+ } else {
955
+ console.log(chalk.green(' No changes since last sync'));
956
+ }
957
+ } else {
958
+ console.log(chalk.green(' No changes since last sync'));
959
+ }
960
+ } catch {
961
+ console.log(chalk.gray(' (unable to compute changes since last sync)'));
962
+ }
963
+ }
964
+ } catch (err) {
965
+ console.log(chalk.gray(` (walk error: ${err.message})`));
966
+ }
967
+ }
968
+ }
969
+
970
+ console.log('');
971
+
972
+ } catch (error) {
973
+ if (spinner.isSpinning) spinner.fail('Status check failed');
974
+ throw error;
975
+ }
976
+ }
977
+
978
+ export default { runCorpusSync, runCorpusStatus };