@descix/cli 1.0.0 → 1.0.3
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/LICENSE.md +671 -0
- package/README.md +43 -4
- package/agent-assets/guides/README.md +2 -2
- package/agent-assets/guides/dynamic-site-pattern.md +29 -14
- package/agent-assets/guides/knowledge-only-pattern.md +5 -5
- package/agent-assets/guides/microservice-pattern.md +48 -4
- package/agent-assets/guides/static-site-pattern.md +8 -0
- package/agent-assets/instructions/app-asset-validation.md +5 -5
- package/agent-assets/instructions/onboarding-flow.md +4 -4
- package/agent-assets/instructions/standard-folder-structure.md +20 -11
- package/agent-assets/reference/app-creation.md +42 -20
- package/agent-assets/reference/architecture.md +24 -22
- package/agent-assets/reference/local-dev.md +230 -67
- package/agent-assets/reference/mcp-integration.md +9 -9
- package/agent-assets/reference/platform-api-consumer.md +244 -0
- package/agent-assets/reference/workspace-config.md +7 -7
- package/agent-assets/rules/descix_mcp.template.mdc +8 -8
- package/bin/descix.js +2559 -1435
- package/bin/kb-deny-lint.mjs +91 -0
- package/bin/mcp-server.js +175 -91
- package/lib/agent-files.js +37 -25
- package/lib/api-client.js +223 -56
- package/lib/auth-guard.js +13 -3
- package/lib/chat-session.js +187 -0
- package/lib/cli-version.js +59 -0
- package/lib/commands/airdrop.js +251 -0
- package/lib/commands/auth.js +254 -72
- package/lib/commands/briefer/errors.js +88 -0
- package/lib/commands/briefer/index.js +319 -0
- package/lib/commands/briefer/sources/canonical-sources.js +129 -0
- package/lib/commands/briefer/sources/entitlements.js +209 -0
- package/lib/commands/briefer/sources/environments.js +205 -0
- package/lib/commands/briefer/sources/identifiers.js +214 -0
- package/lib/commands/briefer/sources/microservice-deploy.js +267 -0
- package/lib/commands/briefer/sources/routing.js +275 -0
- package/lib/commands/briefer/sources/what-is-not.js +223 -0
- package/lib/commands/briefer/stitcher.js +134 -0
- package/lib/commands/briefer/util/source-reader.js +502 -0
- package/lib/commands/config.js +210 -90
- package/lib/commands/corpus.js +978 -0
- package/lib/commands/credits.js +122 -0
- package/lib/commands/health.js +627 -0
- package/lib/commands/kb-list-render.js +32 -0
- package/lib/commands/kb.js +190 -413
- package/lib/commands/mcp.js +5 -5
- package/lib/commands/microservice-restart.js +359 -0
- package/lib/commands/model-config.js +313 -0
- package/lib/commands/retired-kb-sync.js +134 -0
- package/lib/commands/serve.js +26 -1
- package/lib/commands/status.js +23 -51
- package/lib/commands/update.js +36 -107
- package/lib/core/Chunker.js +491 -17
- package/lib/core/CorpusDenyLint.js +326 -0
- package/lib/core/CorpusWalker.js +357 -0
- package/lib/core/Hydrator.js +75 -29
- package/lib/core/ManifestLoader.js +533 -0
- package/lib/core/SiteWalker.js +218 -0
- package/lib/core/Syncer.js +163 -11
- package/lib/diagnostic-log.js +41 -0
- package/lib/environment-report.js +100 -0
- package/lib/global-config.js +4 -1
- package/lib/oauth-client.js +164 -0
- package/lib/origin.js +143 -0
- package/lib/service-api-client.js +95 -0
- package/lib/wallet-environment.js +49 -0
- package/lib/wallet-file.js +16 -2
- package/lib/wizard/setup.js +10 -9
- package/lib/workspace-config.js +380 -235
- package/lib/workspace-identity.js +92 -0
- package/lib/workspace-utils.js +41 -61
- package/package.json +29 -9
- package/templates/agent-claude.md +157 -9
- package/templates/agent-cline.md +68 -8
- package/templates/agent-copilot.md +68 -8
- package/templates/agent-cursor.md +68 -8
- package/templates/alignment-schema.json +20 -0
- package/templates/cloud-brain-hook.sh +20 -0
- package/templates/drive/README.md +1 -1
- package/templates/drive_templates/README.md +2 -2
- package/templates/scaffolds/README.md +6 -10
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +30 -0
- package/templates/scaffolds/microservice/app.js +51 -10
- package/templates/scaffolds/microservice/app.yaml +2 -2
- package/templates/scaffolds/microservice/defaults-config-dev.json +4 -0
- package/templates/scaffolds/microservice/dev-overrides.example.json +5 -0
- package/templates/scaffolds/microservice/manifest.json +0 -1
- package/templates/scaffolds/microservice/package.json +3 -3
- package/templates/scaffolds/microservice/scripts/register.js +56 -41
- package/templates/scaffolds/microservice/services/mcpClient.js +55 -65
- package/templates/scaffolds/microservice/services/utils.js +20 -238
- package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +24 -0
- package/SERVICE_README_sdk.md +0 -399
- package/descix-cli-1.0.0.tgz +0 -0
- package/lib/commands/app-wizard.js +0 -627
- package/lib/commands/folder.js +0 -291
- package/lib/core/PathContext.js +0 -335
- package/templates/scaffolds/microservice/dev-overrides.json.example +0 -5
- package/templates/scaffolds/site/DeSciXAppSDK.js +0 -62
- package/templates/scaffolds/site/README.md +0 -51
- package/templates/scaffolds/site/app.js +0 -22
- package/templates/scaffolds/site/index.html +0 -42
- package/templates/scaffolds/site/styles.css +0 -123
- package/test-hydration.js +0 -150
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SiteWalker - Manifest-driven file walker for site deployment
|
|
3
|
+
*
|
|
4
|
+
* Given a site manifest (.descix/manifests/site.json), walks source entries,
|
|
5
|
+
* applies include/exclude glob patterns, and computes file hashes for
|
|
6
|
+
* delta-based deployment to GCS.
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* - Files stay in place — no copying or staging
|
|
10
|
+
* - File hashes (SHA-256) enable delta uploads (skip unchanged files)
|
|
11
|
+
* - Multiple source entries are flattened into a deploy tree preserving
|
|
12
|
+
* relative paths from each source root
|
|
13
|
+
* - Unlike CorpusWalker, accepts any file type (HTML, CSS, JS, images, fonts, etc.)
|
|
14
|
+
* and uses include/exclude globs instead of syncignore + extension filtering
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from 'fs/promises';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import { createHash } from 'crypto';
|
|
20
|
+
import { minimatch } from 'minimatch';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if a relative path matches any of the given glob patterns.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} relativePath - Path relative to the source entry root
|
|
26
|
+
* @param {string[]} patterns - Array of glob patterns
|
|
27
|
+
* @returns {boolean} True if the path matches at least one pattern
|
|
28
|
+
*/
|
|
29
|
+
function matchesAny(relativePath, patterns) {
|
|
30
|
+
for (const pattern of patterns) {
|
|
31
|
+
if (minimatch(relativePath, pattern, { dot: true })) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
// Also check just the filename for patterns like "*.css"
|
|
35
|
+
const basename = path.basename(relativePath);
|
|
36
|
+
if (minimatch(basename, pattern, { dot: true })) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if a directory name matches any exclude patterns that look like directory names.
|
|
45
|
+
* Patterns ending with "/" are directory excludes (e.g., "node_modules/").
|
|
46
|
+
*
|
|
47
|
+
* @param {string} dirName - Directory name (not full path)
|
|
48
|
+
* @param {string[]} excludePatterns - Exclude patterns
|
|
49
|
+
* @returns {boolean} True if the directory should be skipped entirely
|
|
50
|
+
*/
|
|
51
|
+
function isDirExcluded(dirName, excludePatterns) {
|
|
52
|
+
for (const pattern of excludePatterns) {
|
|
53
|
+
// Pattern ending with / targets directories
|
|
54
|
+
if (pattern.endsWith('/') && dirName === pattern.slice(0, -1)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
// Also match directory-style patterns without trailing slash
|
|
58
|
+
if (dirName === pattern) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Compute SHA-256 hash of a file's contents.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} absolutePath - Absolute path to the file
|
|
69
|
+
* @returns {Promise<string>} Hex-encoded SHA-256 hash
|
|
70
|
+
*/
|
|
71
|
+
async function computeFileHash(absolutePath) {
|
|
72
|
+
const content = await fs.readFile(absolutePath);
|
|
73
|
+
return createHash('sha256').update(content).digest('hex');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get file size in bytes.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} absolutePath - Absolute path to the file
|
|
80
|
+
* @returns {Promise<number>} File size
|
|
81
|
+
*/
|
|
82
|
+
async function getFileSize(absolutePath) {
|
|
83
|
+
const stat = await fs.stat(absolutePath);
|
|
84
|
+
return stat.size;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Recursively walk a directory and collect file paths,
|
|
89
|
+
* applying include and exclude glob filters.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} dir - Directory to walk
|
|
92
|
+
* @param {string} baseDir - Base directory for computing relative paths
|
|
93
|
+
* @param {string[]} include - Include glob patterns (empty = include all)
|
|
94
|
+
* @param {string[]} exclude - Exclude glob patterns
|
|
95
|
+
* @returns {Promise<string[]>} Array of absolute file paths that pass filters
|
|
96
|
+
*/
|
|
97
|
+
async function walkDir(dir, baseDir, include, exclude) {
|
|
98
|
+
const results = [];
|
|
99
|
+
|
|
100
|
+
let entries;
|
|
101
|
+
try {
|
|
102
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
103
|
+
} catch {
|
|
104
|
+
return results; // Directory doesn't exist or unreadable
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
const fullPath = path.join(dir, entry.name);
|
|
109
|
+
const relativePath = path.relative(baseDir, fullPath);
|
|
110
|
+
|
|
111
|
+
if (entry.isDirectory()) {
|
|
112
|
+
// Skip excluded directories early (avoids walking into node_modules, etc.)
|
|
113
|
+
if (isDirExcluded(entry.name, exclude)) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// Also check full relative path against exclude patterns
|
|
117
|
+
if (matchesAny(relativePath + '/', exclude) || matchesAny(relativePath, exclude)) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const subFiles = await walkDir(fullPath, baseDir, include, exclude);
|
|
121
|
+
results.push(...subFiles);
|
|
122
|
+
} else if (entry.isFile()) {
|
|
123
|
+
// Check exclude first
|
|
124
|
+
if (exclude.length > 0 && matchesAny(relativePath, exclude)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// Check include (empty include = match all)
|
|
128
|
+
if (include.length === 0 || matchesAny(relativePath, include)) {
|
|
129
|
+
results.push(fullPath);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return results;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Walk a site manifest and return file entries with hashes for deployment.
|
|
139
|
+
*
|
|
140
|
+
* For each source entry in the manifest:
|
|
141
|
+
* 1. Resolve the local path (relative to app root / workspace root)
|
|
142
|
+
* 2. Walk the directory, applying include/exclude globs
|
|
143
|
+
* 3. Compute SHA-256 hash for each file
|
|
144
|
+
* 4. Return file list with deploy paths (relative to deploy root)
|
|
145
|
+
*
|
|
146
|
+
* @param {Object} manifest - Validated site manifest with _resolvedSources
|
|
147
|
+
* @param {string} appRoot - Absolute path to the app root directory
|
|
148
|
+
* @returns {Promise<Object>} { files: Array<SiteFileEntry>, totalSize: number }
|
|
149
|
+
*
|
|
150
|
+
* SiteFileEntry: {
|
|
151
|
+
* absolutePath: string, // Local filesystem path
|
|
152
|
+
* deployPath: string, // Path in the deploy tree (relative)
|
|
153
|
+
* hash: string, // SHA-256 hex hash
|
|
154
|
+
* size: number, // File size in bytes
|
|
155
|
+
* sourceEntry: string // Source manifest path for traceability
|
|
156
|
+
* }
|
|
157
|
+
*/
|
|
158
|
+
export async function walkSite(manifest, appRoot) {
|
|
159
|
+
const files = [];
|
|
160
|
+
let totalSize = 0;
|
|
161
|
+
|
|
162
|
+
for (const source of manifest._resolvedSources) {
|
|
163
|
+
const { absolutePath, include, exclude, sourcePath } = source;
|
|
164
|
+
|
|
165
|
+
// Check if source is a single file
|
|
166
|
+
let stat;
|
|
167
|
+
try {
|
|
168
|
+
stat = await fs.stat(absolutePath);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (err.code === 'ENOENT') {
|
|
171
|
+
// Source doesn't exist — skip with warning (caller can log)
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (stat.isFile()) {
|
|
178
|
+
// Single file source entry — deploy at the filename level
|
|
179
|
+
const hash = await computeFileHash(absolutePath);
|
|
180
|
+
const size = stat.size;
|
|
181
|
+
totalSize += size;
|
|
182
|
+
|
|
183
|
+
files.push({
|
|
184
|
+
absolutePath,
|
|
185
|
+
deployPath: path.basename(absolutePath),
|
|
186
|
+
hash,
|
|
187
|
+
size,
|
|
188
|
+
sourceEntry: sourcePath
|
|
189
|
+
});
|
|
190
|
+
} else if (stat.isDirectory()) {
|
|
191
|
+
// Directory: walk with include/exclude
|
|
192
|
+
const filePaths = await walkDir(absolutePath, absolutePath, include, exclude);
|
|
193
|
+
|
|
194
|
+
for (const filePath of filePaths) {
|
|
195
|
+
const hash = await computeFileHash(filePath);
|
|
196
|
+
const size = await getFileSize(filePath);
|
|
197
|
+
totalSize += size;
|
|
198
|
+
|
|
199
|
+
// Deploy path is relative to the source directory root (not the source entry label).
|
|
200
|
+
// e.g. source.path = "site/dist" → dist/index.html under {env}/{app}/site/
|
|
201
|
+
const relativeToSource = path.relative(absolutePath, filePath);
|
|
202
|
+
const deployPath = relativeToSource.split(path.sep).join('/');
|
|
203
|
+
|
|
204
|
+
files.push({
|
|
205
|
+
absolutePath: filePath,
|
|
206
|
+
deployPath,
|
|
207
|
+
hash,
|
|
208
|
+
size,
|
|
209
|
+
sourceEntry: sourcePath
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { files, totalSize };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export default { walkSite };
|
package/lib/core/Syncer.js
CHANGED
|
@@ -89,7 +89,9 @@ export async function getRemoteChunkMetadata(apiClient, communityId, appId, kbId
|
|
|
89
89
|
app_id: appId,
|
|
90
90
|
kb_id: kbId
|
|
91
91
|
});
|
|
92
|
-
|
|
92
|
+
// apiClient.invoke returns { status, message: { chunks, count, ... } }
|
|
93
|
+
const data = result.message || result;
|
|
94
|
+
return data.chunks || [];
|
|
93
95
|
} catch (error) {
|
|
94
96
|
// Fall back to IDs-only endpoint
|
|
95
97
|
try {
|
|
@@ -97,8 +99,9 @@ export async function getRemoteChunkMetadata(apiClient, communityId, appId, kbId
|
|
|
97
99
|
app_id: appId,
|
|
98
100
|
kb_id: kbId
|
|
99
101
|
});
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
+
// apiClient.invoke returns { status, message: { chunk_ids, count, ... } }
|
|
103
|
+
const data = result.message || result;
|
|
104
|
+
return data.chunk_ids || [];
|
|
102
105
|
} catch {
|
|
103
106
|
return [];
|
|
104
107
|
}
|
|
@@ -151,8 +154,11 @@ export async function upsertChunks(apiClient, communityId, appId, kbId, chunks)
|
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
/**
|
|
154
|
-
* Delete stale chunks from Pinecone
|
|
155
|
-
*
|
|
157
|
+
* Delete stale chunks from Pinecone by explicit chunk IDs.
|
|
158
|
+
*
|
|
159
|
+
* Use this when you have the exact chunk_ids to purge (e.g., shrinking file
|
|
160
|
+
* where chunks N..M no longer exist locally but linger in Pinecone).
|
|
161
|
+
*
|
|
156
162
|
* @param {Object} apiClient - DeSciXApiClient instance
|
|
157
163
|
* @param {string} communityId - Community ID
|
|
158
164
|
* @param {string} appId - App ID
|
|
@@ -164,14 +170,127 @@ export async function deleteStaleChunks(apiClient, communityId, appId, kbId, chu
|
|
|
164
170
|
if (chunkIds.length === 0) {
|
|
165
171
|
return { deleted: 0 };
|
|
166
172
|
}
|
|
167
|
-
|
|
168
|
-
await apiClient.invoke('kb_delete_chunks', {
|
|
173
|
+
|
|
174
|
+
const result = await apiClient.invoke('kb_delete_chunks', {
|
|
175
|
+
community_id: communityId,
|
|
169
176
|
app_id: appId,
|
|
170
177
|
kb_id: kbId,
|
|
171
178
|
chunk_ids: chunkIds
|
|
172
179
|
});
|
|
173
|
-
|
|
174
|
-
|
|
180
|
+
|
|
181
|
+
const data = result?.message || result;
|
|
182
|
+
return { deleted: data.deleted_count ?? chunkIds.length };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Delete stale chunks from Pinecone by file_id (metadata filter).
|
|
187
|
+
*
|
|
188
|
+
* This is the canonical stale-purge path for `kb corpus sync`: when a file's
|
|
189
|
+
* blob_sha changes, the prior blob_sha is a stale file_id. Passing it here
|
|
190
|
+
* deletes every chunk where metadata.file_id matches, scoped by the full
|
|
191
|
+
* multi-tenancy filter (community_id, app_id, knowledgebase_name) on the
|
|
192
|
+
* server side via pineconeService.deleteVectorsByFileId.
|
|
193
|
+
*
|
|
194
|
+
* Bulk: pass multiple file_ids in one call to amortize the round-trip.
|
|
195
|
+
*
|
|
196
|
+
* @param {Object} apiClient - DeSciXApiClient instance
|
|
197
|
+
* @param {string} communityId - Community ID
|
|
198
|
+
* @param {string} appId - App ID
|
|
199
|
+
* @param {string} kbId - Knowledge base ID
|
|
200
|
+
* @param {Array<string>} fileIds - file_id values to delete (e.g., ['corpus:abc...', 'local:def...'])
|
|
201
|
+
* @returns {Promise<{deleted: number, deleted_by_file_id: number}>}
|
|
202
|
+
*/
|
|
203
|
+
export async function deleteStaleChunksByFileId(apiClient, communityId, appId, kbId, fileIds) {
|
|
204
|
+
if (!Array.isArray(fileIds) || fileIds.length === 0) {
|
|
205
|
+
return { deleted: 0, deleted_by_file_id: 0 };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Filter out null/undefined/empty entries — pineconeService treats empty fileId as 0-count no-op
|
|
209
|
+
const validFileIds = fileIds.filter(Boolean);
|
|
210
|
+
if (validFileIds.length === 0) {
|
|
211
|
+
return { deleted: 0, deleted_by_file_id: 0 };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const result = await apiClient.invoke('kb_delete_chunks', {
|
|
215
|
+
community_id: communityId,
|
|
216
|
+
app_id: appId,
|
|
217
|
+
kb_id: kbId,
|
|
218
|
+
file_ids: validFileIds
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const data = result?.message || result;
|
|
222
|
+
return {
|
|
223
|
+
deleted: data.deleted_count ?? validFileIds.length,
|
|
224
|
+
deleted_by_file_id: data.deleted_by_file_id ?? validFileIds.length
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Purge EVERY vector in a KB's {community_id, app_id, knowledgebase_name} scope.
|
|
230
|
+
*
|
|
231
|
+
* This is the FULL metadata-scoped purge — it clears all vectors regardless of
|
|
232
|
+
* their file_id scheme, including legacy/orphan vectors that carry no `corpus:`
|
|
233
|
+
* file_id and are therefore INVISIBLE to listRemoteFileIds (the corpus-scheme
|
|
234
|
+
* enumeration). It is the only primitive that fully clears accumulated orphan
|
|
235
|
+
* pollution before a `--rebuild` re-upsert.
|
|
236
|
+
*
|
|
237
|
+
* Server-side it routes to kb_delete_chunks(purge_scope: true), which delegates
|
|
238
|
+
* to the canonical KnowledgeBase.deleteRAG(). As of WS-KB-CORPUS-SCOPEPURGE-SCALE
|
|
239
|
+
* that purge is ROBUST AT SCALE: it enumerates the KB's vectors by their canonical
|
|
240
|
+
* id-prefix `${app_id}:${kb_id}:` (deterministic listPaginated) and deletes them in
|
|
241
|
+
* bounded batches of 1000, so an arbitrarily large scope (~20k+ vectors) is cleared
|
|
242
|
+
* without exceeding the Pinecone client's max-retries ceiling (the prior single
|
|
243
|
+
* giant metadata deleteMany failed at that size). It still clears ALL orphans
|
|
244
|
+
* (they share the prefix) AND resets the KB doc's rag fields (rag_vector_count→0)
|
|
245
|
+
* so `kb doctor` reports clean drift after the subsequent re-sync. The rag-field
|
|
246
|
+
* reset happens ONLY after the live purge completes. Touches Pinecone vectors +
|
|
247
|
+
* the KB doc's rag fields ONLY — no Firestore/GCS/Products cascade. On a partial
|
|
248
|
+
* batch failure it FAILS LOUD (recoverable by re-running — idempotent).
|
|
249
|
+
*
|
|
250
|
+
* @param {Object} apiClient - DeSciXApiClient instance
|
|
251
|
+
* @param {string} communityId - Community ID (required server-side; full scope)
|
|
252
|
+
* @param {string} appId - App ID
|
|
253
|
+
* @param {string} kbId - Knowledge base ID
|
|
254
|
+
* @returns {Promise<{ deleted: number, purged_scope: boolean }>}
|
|
255
|
+
* `deleted` is the actual count of vectors purged (the batched path always
|
|
256
|
+
* reports it); -1 only if a future primitive cannot report a count.
|
|
257
|
+
*/
|
|
258
|
+
export async function purgeKbScope(apiClient, communityId, appId, kbId) {
|
|
259
|
+
const result = await apiClient.invoke('kb_delete_chunks', {
|
|
260
|
+
community_id: communityId,
|
|
261
|
+
app_id: appId,
|
|
262
|
+
kb_id: kbId,
|
|
263
|
+
purge_scope: true
|
|
264
|
+
});
|
|
265
|
+
const data = result?.message || result;
|
|
266
|
+
return {
|
|
267
|
+
deleted: typeof data?.deleted_count === 'number' ? data.deleted_count : -1,
|
|
268
|
+
purged_scope: data?.purged_scope === true
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Enumerate all file_id values currently present in a KB's Pinecone namespace.
|
|
274
|
+
*
|
|
275
|
+
* Used by `descix kb corpus sync --rebuild` to compute drift:
|
|
276
|
+
* stale = pinecone_file_ids − current_local_file_ids
|
|
277
|
+
*
|
|
278
|
+
* @param {Object} apiClient - DeSciXApiClient instance
|
|
279
|
+
* @param {string} appId - App ID
|
|
280
|
+
* @param {string} kbId - Knowledge base ID
|
|
281
|
+
* @returns {Promise<{ file_ids: string[], unique_count: number, total_chunks: number }>}
|
|
282
|
+
*/
|
|
283
|
+
export async function listRemoteFileIds(apiClient, appId, kbId) {
|
|
284
|
+
const result = await apiClient.invoke('kb_list_file_ids', {
|
|
285
|
+
app_id: appId,
|
|
286
|
+
kb_id: kbId
|
|
287
|
+
});
|
|
288
|
+
const data = result?.message || result;
|
|
289
|
+
return {
|
|
290
|
+
file_ids: data.file_ids || [],
|
|
291
|
+
unique_count: data.unique_count || 0,
|
|
292
|
+
total_chunks: data.total_chunks || 0
|
|
293
|
+
};
|
|
175
294
|
}
|
|
176
295
|
|
|
177
296
|
/**
|
|
@@ -255,10 +374,40 @@ export async function syncKb(apiClient, config, options = {}) {
|
|
|
255
374
|
} else {
|
|
256
375
|
if (onProgress) onProgress('All chunks already in sync');
|
|
257
376
|
}
|
|
258
|
-
|
|
377
|
+
|
|
378
|
+
// 6. File-level stale purge (belt-and-suspenders).
|
|
379
|
+
// The chunk_id-based delete in step 4 handles the common shrinking/deletion case
|
|
380
|
+
// but only sees chunk_ids that were returned by getRemoteChunkMetadata. For
|
|
381
|
+
// robustness — and to close DN-2 at the lower-level kb sync surface too — we
|
|
382
|
+
// also enumerate remote file_ids and delete any whose file_id is not present
|
|
383
|
+
// in the current local chunk set. This catches file deletions and modifications
|
|
384
|
+
// that produced new chunk_id shapes (e.g., content-hash-based ids).
|
|
385
|
+
let deletedByFileId = 0;
|
|
386
|
+
try {
|
|
387
|
+
const localFileIds = new Set(localChunks.map(c => c.file_id).filter(Boolean));
|
|
388
|
+
const remoteFileIdsResp = await apiClient.invoke('kb_list_file_ids', {
|
|
389
|
+
app_id: appId,
|
|
390
|
+
kb_id: kbId
|
|
391
|
+
});
|
|
392
|
+
const remoteData = remoteFileIdsResp?.message || remoteFileIdsResp;
|
|
393
|
+
const remoteFileIds = remoteData?.file_ids || [];
|
|
394
|
+
const staleFileIds = remoteFileIds.filter(fid => !localFileIds.has(fid));
|
|
395
|
+
if (staleFileIds.length > 0) {
|
|
396
|
+
if (onProgress) onProgress(`Purging ${staleFileIds.length} stale file_id(s)...`);
|
|
397
|
+
const purgeResult = await deleteStaleChunksByFileId(apiClient, communityId, appId, kbId, staleFileIds);
|
|
398
|
+
deletedByFileId = purgeResult.deleted;
|
|
399
|
+
}
|
|
400
|
+
} catch (purgeErr) {
|
|
401
|
+
// Non-fatal: surface via verbose log. The chunk_id-based delete above is the primary
|
|
402
|
+
// path; this is a safety net. We do NOT silently swallow — caller sees a warning.
|
|
403
|
+
if (verbose) console.log(` File-id purge skipped: ${purgeErr.message}`);
|
|
404
|
+
}
|
|
405
|
+
|
|
259
406
|
return {
|
|
260
407
|
synced,
|
|
261
|
-
deleted,
|
|
408
|
+
deleted: deleted + deletedByFileId,
|
|
409
|
+
deleted_chunk_ids: deleted,
|
|
410
|
+
deleted_by_file_id: deletedByFileId,
|
|
262
411
|
unchanged
|
|
263
412
|
};
|
|
264
413
|
}
|
|
@@ -306,5 +455,8 @@ export default {
|
|
|
306
455
|
getRemoteChunkMetadata,
|
|
307
456
|
upsertChunks,
|
|
308
457
|
deleteStaleChunks,
|
|
458
|
+
deleteStaleChunksByFileId,
|
|
459
|
+
purgeKbScope,
|
|
460
|
+
listRemoteFileIds,
|
|
309
461
|
getSyncStatus
|
|
310
462
|
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ONE OWNER of every write the CLI makes ABOUT ITS OWN EXECUTION.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS (measured 2026-08-30). `bin/mcp-server.js` speaks JSON-RPC over stdio, so its
|
|
5
|
+
* stdout IS the protocol stream. Driving one `tools/call` put 130 bytes of non-JSON-RPC text into
|
|
6
|
+
* that stream, from `console.log` calls in `lib/api-client.js`, on the credential-refresh path —
|
|
7
|
+
* which fires exactly when a token expires, the moment a long-running MCP session is least able
|
|
8
|
+
* to survive a corrupt frame.
|
|
9
|
+
*
|
|
10
|
+
* THE SCOPE IS THE MEASURED IMPORT GRAPH, NOT A GREP. Walking the real import graph from
|
|
11
|
+
* `bin/mcp-server.js` (static and dynamic), 12 files are reachable carrying 74 `console.log`
|
|
12
|
+
* sites — `lib/api-client.js` 9 and `lib/commands/auth.js` 65. The package-wide count is 1108;
|
|
13
|
+
* that number is not the target and never was. Everything on the reachable graph writes through
|
|
14
|
+
* here.
|
|
15
|
+
*
|
|
16
|
+
* WHY A WRITER AND NOT A WRAPPER. There WAS already a fix, and its shape is the lesson:
|
|
17
|
+
* `mcpListTools()` monkey-patched `console.log = console.error` for the duration of ONE call and
|
|
18
|
+
* restored it in a `finally`. A call-site workaround for a write-site defect — it protected the
|
|
19
|
+
* one path its author was looking at and left every other path through `invoke()`/`invokeRaw()`
|
|
20
|
+
* corrupting the stream. It is DELETED in the same change that makes it unnecessary; a fix that
|
|
21
|
+
* does not sweep the workarounds it invalidates leaves a time bomb primed toward silence.
|
|
22
|
+
*
|
|
23
|
+
* STDERR, ALWAYS, WITH NO MODE SWITCH. A stream decision that can be configured is one that will
|
|
24
|
+
* eventually be configured wrongly, and the caller who gets it wrong is a protocol transport that
|
|
25
|
+
* cannot report its own corruption. On a terminal stderr is still visible to the human, so the
|
|
26
|
+
* interactive login flow reads exactly as before; what changes is that `descix login > file` no
|
|
27
|
+
* longer captures the prompts, because prompts were never the command's OUTPUT.
|
|
28
|
+
*
|
|
29
|
+
* WHAT DOES NOT BELONG HERE: anything the user ASKED FOR — a command's JSON document, a report
|
|
30
|
+
* like `config show`, a value meant to be piped. Those are OUTPUT and they belong on stdout.
|
|
31
|
+
* `bin/descix.js::progress()` owns the narrower question of where human progress text goes when
|
|
32
|
+
* `--json` makes stdout a data channel; this owns the general one.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Write a diagnostic / human-progress line. Same call shape as console.log; always stderr.
|
|
37
|
+
* @param {...any} args
|
|
38
|
+
*/
|
|
39
|
+
export function diag(...args) {
|
|
40
|
+
console.error(...args);
|
|
41
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ONE OWNER OF "which environment is this origin, and how does the CLI SAY SO".
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FILE EXISTS: contract I1 (A', rev 2) requires that every network-bound command
|
|
5
|
+
* PRINT the resolved environment, origin and source — always, not only when it landed on the
|
|
6
|
+
* default. That print needs two facts: the env NAME for an origin, and one spelling of the
|
|
7
|
+
* line. Both were about to be derived in a second place.
|
|
8
|
+
*
|
|
9
|
+
* `wallet-environment.js` already derived origin -> env name privately, for the wallet stamp.
|
|
10
|
+
* Two derivations of one fact is the mirror-drift bug class this contract exists to end, so
|
|
11
|
+
* the derivation moved HERE and the wallet stamp became a consumer of it. There is exactly one
|
|
12
|
+
* answer to "what environment is https://dev.descix.net", and both surfaces read it from here.
|
|
13
|
+
*
|
|
14
|
+
* The origin TABLE is still owned upstream (`@descix/app-sdk/dev` ENV_ORIGINS, surfaced as
|
|
15
|
+
* `WorkspaceConfig.ENV_MAP`). This module holds no copy of it.
|
|
16
|
+
*/
|
|
17
|
+
import { WorkspaceConfig } from './workspace-config.js';
|
|
18
|
+
import { normalizeOrigin } from './origin.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The environment NAME for an origin.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} origin
|
|
24
|
+
* @returns {string} the ENV_MAP name whose url matches (`dev` | `demo` | `prod`), or `'custom'`
|
|
25
|
+
* when the origin is a real one that simply is not in the map — a self-hosted or
|
|
26
|
+
* port-forwarded gateway. 'custom' is an HONEST answer ("a real origin, not one of the
|
|
27
|
+
* three named ones"), not a fallback standing in for a missing fact: the origin itself
|
|
28
|
+
* is always printed beside it.
|
|
29
|
+
* @throws when called with no origin. There is no default: guessing which environment an
|
|
30
|
+
* origin belongs to is exactly the misreport this contract exists to end.
|
|
31
|
+
*/
|
|
32
|
+
export function environmentNameFor(origin) {
|
|
33
|
+
if (!origin || String(origin).trim() === '') {
|
|
34
|
+
throw new Error(
|
|
35
|
+
'environmentNameFor: refusing to name the environment of an empty origin — ' +
|
|
36
|
+
'it cannot be guessed.',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const normalized = normalizeOrigin(origin);
|
|
40
|
+
const match = Object.entries(WorkspaceConfig.ENV_MAP)
|
|
41
|
+
.find(([, entry]) => entry && normalizeOrigin(entry.url) === normalized);
|
|
42
|
+
return match ? match[0] : 'custom';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The ONE spelling of the env line every network-bound command prints.
|
|
47
|
+
*
|
|
48
|
+
* Shape is the contract's own (I1, rev 2): `env: <name> (<source>) <origin>`. The SOURCE is the
|
|
49
|
+
* whole point — it is the fact whose absence made "the developer chose production" and "the
|
|
50
|
+
* developer chose nothing" one observable state on 1.0.1.
|
|
51
|
+
*
|
|
52
|
+
* @param {{origin: string, source: string}} resolved - as returned by `resolveOrigin`
|
|
53
|
+
* @returns {string} the line, WITHOUT a trailing newline
|
|
54
|
+
*/
|
|
55
|
+
export function formatEnvLine({ origin, source }) {
|
|
56
|
+
return `env: ${environmentNameFor(origin)} (${source}) ${origin}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The SOURCE label for an origin handed in by the caller rather than resolved from config —
|
|
61
|
+
* `--api-url`, `descix auth login -u`, a microservice constructing a client against a known
|
|
62
|
+
* origin. Exported so the label has one spelling.
|
|
63
|
+
*/
|
|
64
|
+
export const EXPLICIT_ORIGIN_SOURCE = 'explicit --api-url / caller-supplied baseUrl';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Distinct (origin, source) pairs already reported in this process.
|
|
68
|
+
*
|
|
69
|
+
* WHY DEDUPE AND NOT A ONE-SHOT FLAG: a single command constructs many clients (87 sites), so
|
|
70
|
+
* a per-client print would emit the same line a dozen times and train the reader to skim past
|
|
71
|
+
* it. But a one-shot flag would HIDE a second, DIFFERENT origin being contacted in the same
|
|
72
|
+
* invocation — which is precisely the kind of thing this contract exists to surface. Keying on
|
|
73
|
+
* the pair prints each distinct target exactly once.
|
|
74
|
+
*/
|
|
75
|
+
const reported = new Set();
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* PRINT the resolved environment for a network-bound invocation. Always — not only when the
|
|
79
|
+
* origin came from the declared default.
|
|
80
|
+
*
|
|
81
|
+
* STDERR, DELIBERATELY. stdout is a DATA CHANNEL under `--json` and is the protocol channel for
|
|
82
|
+
* the MCP stdio server; a status line on stdout would corrupt both. stderr is where a human
|
|
83
|
+
* piping stdout still sees it.
|
|
84
|
+
*
|
|
85
|
+
* @param {{origin: string, source: string}} resolved
|
|
86
|
+
* @returns {string|null} the line printed, or null when this pair was already reported
|
|
87
|
+
*/
|
|
88
|
+
export function reportEnvironment({ origin, source }) {
|
|
89
|
+
const key = `${origin}\u0000${source}`;
|
|
90
|
+
if (reported.has(key)) return null;
|
|
91
|
+
reported.add(key);
|
|
92
|
+
const line = formatEnvLine({ origin, source });
|
|
93
|
+
process.stderr.write(line + '\n');
|
|
94
|
+
return line;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** TEST SEAM ONLY: forget what has been reported, so a test can observe the print again. */
|
|
98
|
+
export function _resetEnvironmentReportForTests() {
|
|
99
|
+
reported.clear();
|
|
100
|
+
}
|
package/lib/global-config.js
CHANGED
|
@@ -20,7 +20,10 @@ export class GlobalConfig {
|
|
|
20
20
|
constructor(config = {}) {
|
|
21
21
|
this.user_base_folder_id = config.user_base_folder_id || null;
|
|
22
22
|
this.user_base_folder_name = config.user_base_folder_name || null;
|
|
23
|
-
|
|
23
|
+
// NO default. An absent api_url means the global config names no origin, which is a fact
|
|
24
|
+
// the origin owner knows how to report; substituting the production literal here made
|
|
25
|
+
// ~/.descix/config.json claim an origin the user never set.
|
|
26
|
+
this.api_url = config.api_url || null;
|
|
24
27
|
this.environment = config.environment || 'production';
|
|
25
28
|
this.registered_at = config.registered_at || null;
|
|
26
29
|
}
|