@descix/cli 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/agent-assets/guides/README.md +1 -1
- package/agent-assets/guides/dynamic-site-pattern.md +17 -30
- package/agent-assets/guides/knowledge-only-pattern.md +11 -15
- package/agent-assets/guides/microservice-pattern.md +12 -14
- package/agent-assets/guides/static-site-pattern.md +12 -15
- package/agent-assets/instructions/app-asset-validation.md +1 -1
- package/agent-assets/instructions/onboarding-flow.md +4 -4
- package/agent-assets/reference/app-creation.md +36 -101
- package/agent-assets/reference/architecture.md +43 -51
- package/agent-assets/reference/local-dev.md +1 -6
- package/agent-assets/reference/mcp-integration.md +21 -23
- package/agent-assets/reference/workspace-config.md +97 -340
- package/bin/descix.js +337 -618
- package/lib/api-client.js +30 -22
- package/lib/command-visibility.js +213 -0
- package/lib/commands/airdrop.js +10 -0
- package/lib/commands/auth.js +16 -2
- package/lib/commands/config.js +13 -31
- package/lib/commands/corpus.js +163 -59
- package/lib/commands/health.js +116 -21
- package/lib/commands/init.js +17 -45
- package/lib/commands/kb.js +7 -0
- package/lib/commands/retired-kb-sync.js +5 -27
- package/lib/commands/status.js +0 -9
- package/lib/core/Chunker.js +1 -1
- package/lib/core/CorpusWalker.js +238 -47
- package/lib/core/ManifestLoader.js +68 -0
- package/lib/core/Syncer.js +147 -46
- package/lib/interactive.js +158 -0
- package/lib/origin.js +3 -3
- package/lib/sdk-assets.js +28 -0
- package/lib/verb-invokes.generated.json +233 -0
- package/lib/wallet-file.js +36 -37
- package/lib/workspace-config.js +266 -199
- package/lib/workspace-identity.js +0 -1
- package/package.json +7 -3
- package/templates/agent-claude.md +1 -3
- package/templates/agent-cline.md +1 -2
- package/templates/agent-copilot.md +1 -2
- package/templates/agent-cursor.md +1 -2
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +1 -1
- package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +2 -3
- package/agent-assets/rules/descix_mcp.template.mdc +0 -243
- package/lib/commands/briefer/errors.js +0 -88
- package/lib/commands/briefer/index.js +0 -319
- package/lib/commands/briefer/sources/canonical-sources.js +0 -129
- package/lib/commands/briefer/sources/entitlements.js +0 -220
- package/lib/commands/briefer/sources/environments.js +0 -205
- package/lib/commands/briefer/sources/identifiers.js +0 -214
- package/lib/commands/briefer/sources/microservice-deploy.js +0 -267
- package/lib/commands/briefer/sources/routing.js +0 -275
- package/lib/commands/briefer/sources/what-is-not.js +0 -223
- package/lib/commands/briefer/stitcher.js +0 -134
- package/lib/commands/briefer/util/source-reader.js +0 -502
- package/lib/commands/mcp.js +0 -273
- package/lib/commands/update.js +0 -581
- package/lib/wizard/setup.js +0 -503
- package/templates/descix_mcp.mdc +0 -122
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import * as fs from 'fs/promises';
|
|
16
|
+
import { statSync, realpathSync } from 'fs';
|
|
16
17
|
import * as path from 'path';
|
|
17
18
|
import { execFileSync } from 'child_process';
|
|
18
19
|
// Canonical doc_class taxonomy lives in CorpusDenyLint — import it, never re-list.
|
|
@@ -110,6 +111,73 @@ export function detectRepoSlug(dir) {
|
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* THE ONE OWNER OF "WHICH REPOSITORY OWNS THIS FILE".
|
|
116
|
+
*
|
|
117
|
+
* A workspace is NOT one repository. This one crosses three kinds of boundary at once:
|
|
118
|
+
* the superrepo itself; git SUBMODULES (DeSciX/DeSciX_Cloud|Core|Powch are gitlinks —
|
|
119
|
+
* `git ls-tree HEAD DeSciX/` shows mode 160000); and SIBLING repos that are not submodules
|
|
120
|
+
* at all but independent checkouts the superrepo deliberately ignores (EGPT-research via
|
|
121
|
+
* .gitignore:9, FRAQTL — each with its own `.git` and its own `origin` remote).
|
|
122
|
+
*
|
|
123
|
+
* A REF HAS NO MEANING EXCEPT RELATIVE TO A REPOSITORY. Resolving one against the ambient
|
|
124
|
+
* workspace root is what made this gate refuse 596 files that ARE committed: the superrepo
|
|
125
|
+
* sees a submodule only as a gitlink commit, and an ignored sibling repo not at all.
|
|
126
|
+
* `git rev-parse --show-toplevel` walks UP from the path and stops at the FIRST enclosing
|
|
127
|
+
* repository — which is precisely the repository whose branches the source's `ref` names,
|
|
128
|
+
* and which settles submodule and sibling boundaries with ONE mechanism rather than two.
|
|
129
|
+
*
|
|
130
|
+
* This is NOT the question `detectRepoSlug` answers. That one is "what is this repository
|
|
131
|
+
* CALLED" (for the cross-repo self-naming refusal); this one is "where does it BEGIN", so a
|
|
132
|
+
* ref and a repo-relative path can be formed. Both live here so repository identity has a
|
|
133
|
+
* single home and no consumer re-derives either.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} absolutePath - absolute path to a file or directory in the workspace
|
|
136
|
+
* @returns {{repoRoot: string, repoRelativePath: string}|null} null when the path lies in
|
|
137
|
+
* NO git repository at all — callers must fail loud, never assume a root.
|
|
138
|
+
*/
|
|
139
|
+
const OWNING_REPO_CACHE = new Map(); // real directory -> repoRoot. POSITIVE results only.
|
|
140
|
+
export function resolveOwningRepo(absolutePath) {
|
|
141
|
+
const abs = path.resolve(absolutePath);
|
|
142
|
+
// `--show-toplevel` needs a directory that exists, and a source may name a file. Split
|
|
143
|
+
// off the basename ONLY for a non-directory: for a DIRECTORY source, `path.dirname` would
|
|
144
|
+
// step over the very boundary being resolved (dirname of `DeSciX/DeSciX_Cloud` is the
|
|
145
|
+
// superrepo, which is the wrong answer).
|
|
146
|
+
let dir = abs, base = null;
|
|
147
|
+
try {
|
|
148
|
+
if (!statSync(abs).isDirectory()) { dir = path.dirname(abs); base = path.basename(abs); }
|
|
149
|
+
} catch {
|
|
150
|
+
dir = path.dirname(abs); base = path.basename(abs);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// BOTH SIDES MUST BE REAL PATHS OR THE SUBTRACTION IS GARBAGE. `git rev-parse
|
|
154
|
+
// --show-toplevel` always answers with symlinks resolved, so comparing it against a
|
|
155
|
+
// caller path that still contains one yields a `../../..`-riddled relative path that
|
|
156
|
+
// matches nothing. This is not a corner case: on macOS every temp dir is /var ->
|
|
157
|
+
// /private/var, and this repo's own dev flow serves the Core tree through a symlink.
|
|
158
|
+
let realDir;
|
|
159
|
+
try { realDir = realpathSync(dir); } catch { realDir = dir; }
|
|
160
|
+
|
|
161
|
+
let repoRoot = OWNING_REPO_CACHE.get(realDir);
|
|
162
|
+
if (repoRoot === undefined) {
|
|
163
|
+
try {
|
|
164
|
+
repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
165
|
+
cwd: realDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
166
|
+
}).trim();
|
|
167
|
+
} catch {
|
|
168
|
+
return null; // No repository. NOT cached: a negative is not durable — the directory
|
|
169
|
+
} // may be `git init`-ed later in the same process (fixtures do exactly this).
|
|
170
|
+
if (!repoRoot) return null;
|
|
171
|
+
OWNING_REPO_CACHE.set(realDir, repoRoot);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const repoRelativeDir = path.relative(repoRoot, realDir);
|
|
175
|
+
return {
|
|
176
|
+
repoRoot,
|
|
177
|
+
repoRelativePath: base ? path.join(repoRelativeDir, base) : repoRelativeDir,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
113
181
|
/**
|
|
114
182
|
* Enforce the two-shape source contract on ONE source entry.
|
|
115
183
|
*
|
package/lib/core/Syncer.js
CHANGED
|
@@ -25,6 +25,54 @@ import * as fs from 'fs/promises';
|
|
|
25
25
|
import * as path from 'path';
|
|
26
26
|
import { loadChunks } from './Chunker.js';
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* THE ONE VALUE this CLI uses to mean "the store did not report a count".
|
|
30
|
+
*
|
|
31
|
+
* A receipt is derived from the STORE, never from the REQUEST. Where the store
|
|
32
|
+
* cannot report, we say so — we never substitute the number of things we ASKED
|
|
33
|
+
* to be changed, because that number is a description of our own request and
|
|
34
|
+
* carries no information about what happened.
|
|
35
|
+
*
|
|
36
|
+
* @type {number}
|
|
37
|
+
*/
|
|
38
|
+
export const UNREPORTED_COUNT = -1;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Normalize a count the store returned into either that count or UNREPORTED_COUNT.
|
|
42
|
+
* This is the ONLY place the "did the store report a number?" decision is made.
|
|
43
|
+
*
|
|
44
|
+
* @param {*} value - the raw count field as it arrived from the store
|
|
45
|
+
* @returns {number} the store's count, or UNREPORTED_COUNT if it reported none
|
|
46
|
+
*/
|
|
47
|
+
export function reportedCount(value) {
|
|
48
|
+
return Number.isFinite(value) ? value : UNREPORTED_COUNT;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when `value` is a count the store actually reported. Callers use this to
|
|
53
|
+
* choose between printing a number and printing "unknown" — never to fabricate one.
|
|
54
|
+
*
|
|
55
|
+
* @param {*} value
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
export function isReportedCount(value) {
|
|
59
|
+
return Number.isFinite(value) && value >= 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sum counts across batches while preserving unknown-ness: if ANY batch could not
|
|
64
|
+
* be reported, the total is not a number we can honestly state.
|
|
65
|
+
*
|
|
66
|
+
* @param {number} runningTotal - UNREPORTED_COUNT once any batch was unreportable
|
|
67
|
+
* @param {*} batchValue - the raw count field from this batch's store response
|
|
68
|
+
* @returns {number}
|
|
69
|
+
*/
|
|
70
|
+
export function accumulateReportedCount(runningTotal, batchValue) {
|
|
71
|
+
const next = reportedCount(batchValue);
|
|
72
|
+
if (!isReportedCount(runningTotal) || !isReportedCount(next)) return UNREPORTED_COUNT;
|
|
73
|
+
return runningTotal + next;
|
|
74
|
+
}
|
|
75
|
+
|
|
28
76
|
/**
|
|
29
77
|
* Compute delta between local chunks and remote Pinecone records
|
|
30
78
|
* Uses content_hash for efficient change detection when available.
|
|
@@ -73,39 +121,64 @@ export function computeChunkDelta(localChunks, remoteChunks) {
|
|
|
73
121
|
}
|
|
74
122
|
|
|
75
123
|
/**
|
|
76
|
-
* Get existing chunk metadata from Pinecone (with content_hash for delta)
|
|
77
|
-
* Falls back to IDs-only
|
|
78
|
-
*
|
|
124
|
+
* Get existing chunk metadata from Pinecone (with content_hash for delta).
|
|
125
|
+
* Falls back to the IDs-only endpoint when the metadata endpoint is unavailable.
|
|
126
|
+
*
|
|
127
|
+
* An empty array means the STORE REPORTED an empty KB. It never means "we could
|
|
128
|
+
* not find out" — when neither endpoint can report, this THROWS naming both
|
|
129
|
+
* endpoints and both causes, because an unknown remote set presented as an empty
|
|
130
|
+
* one makes every caller conclude that nothing is stale.
|
|
131
|
+
*
|
|
79
132
|
* @param {Object} apiClient - DeSciXApiClient instance
|
|
80
133
|
* @param {string} communityId - Community ID
|
|
81
134
|
* @param {string} appId - App ID
|
|
82
135
|
* @param {string} kbId - Knowledge base ID
|
|
83
|
-
* @returns {Promise<Array<{id: string, content_hash: string}>>} Chunk
|
|
136
|
+
* @returns {Promise<Array<{id: string, content_hash: string}>|Array<string>>} Chunk
|
|
137
|
+
* metadata, or plain chunk ids from the compatibility endpoint.
|
|
138
|
+
* @throws {Error} when neither endpoint reports the remote chunk set.
|
|
84
139
|
*/
|
|
85
140
|
export async function getRemoteChunkMetadata(apiClient, communityId, appId, kbId) {
|
|
141
|
+
// A fallback is a COMPATIBILITY PATH, not an error handler: we fall back when
|
|
142
|
+
// the newer endpoint is absent, and we FAIL LOUD when neither can report.
|
|
143
|
+
// Returning [] here would present a transport failure as a store state
|
|
144
|
+
// ("remote is empty"), and every caller would then compute that nothing is
|
|
145
|
+
// stale and re-upsert everything — silently.
|
|
146
|
+
const failures = [];
|
|
147
|
+
|
|
86
148
|
try {
|
|
87
|
-
// Try new metadata endpoint first
|
|
88
149
|
const result = await apiClient.invoke('kb_get_chunk_metadata', {
|
|
89
150
|
app_id: appId,
|
|
90
151
|
kb_id: kbId
|
|
91
152
|
});
|
|
92
153
|
// apiClient.invoke returns { status, message: { chunks, count, ... } }
|
|
93
|
-
const data = result
|
|
94
|
-
|
|
154
|
+
const data = result?.message || result;
|
|
155
|
+
// An empty ARRAY is the store reporting an empty KB — legal, and preserved.
|
|
156
|
+
// An ABSENT field is the store not reporting at all — not the same fact.
|
|
157
|
+
if (Array.isArray(data?.chunks)) return data.chunks;
|
|
158
|
+
failures.push("kb_get_chunk_metadata: response carried no 'chunks' array");
|
|
95
159
|
} catch (error) {
|
|
96
|
-
|
|
97
|
-
try {
|
|
98
|
-
const result = await apiClient.invoke('kb_get_chunk_ids', {
|
|
99
|
-
app_id: appId,
|
|
100
|
-
kb_id: kbId
|
|
101
|
-
});
|
|
102
|
-
// apiClient.invoke returns { status, message: { chunk_ids, count, ... } }
|
|
103
|
-
const data = result.message || result;
|
|
104
|
-
return data.chunk_ids || [];
|
|
105
|
-
} catch {
|
|
106
|
-
return [];
|
|
107
|
-
}
|
|
160
|
+
failures.push(`kb_get_chunk_metadata: ${error.message}`);
|
|
108
161
|
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const result = await apiClient.invoke('kb_get_chunk_ids', {
|
|
165
|
+
app_id: appId,
|
|
166
|
+
kb_id: kbId
|
|
167
|
+
});
|
|
168
|
+
// apiClient.invoke returns { status, message: { chunk_ids, count, ... } }
|
|
169
|
+
const data = result?.message || result;
|
|
170
|
+
if (Array.isArray(data?.chunk_ids)) return data.chunk_ids;
|
|
171
|
+
failures.push("kb_get_chunk_ids: response carried no 'chunk_ids' array");
|
|
172
|
+
} catch (error) {
|
|
173
|
+
failures.push(`kb_get_chunk_ids: ${error.message}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
throw new Error(
|
|
177
|
+
`Cannot enumerate remote chunks for ${appId}/${kbId}: both endpoints failed to report. ` +
|
|
178
|
+
'The remote chunk set is UNKNOWN — it must NOT be treated as an empty KB, because ' +
|
|
179
|
+
'that would silently skip stale-chunk deletion and re-upsert everything. ' +
|
|
180
|
+
failures.join(' | ')
|
|
181
|
+
);
|
|
109
182
|
}
|
|
110
183
|
|
|
111
184
|
/**
|
|
@@ -129,7 +202,9 @@ export async function getRemoteChunkIds(apiClient, communityId, appId, kbId) {
|
|
|
129
202
|
* @param {string} appId - App ID
|
|
130
203
|
* @param {string} kbId - Knowledge base ID
|
|
131
204
|
* @param {Array} chunks - Chunk records to upsert
|
|
132
|
-
* @returns {Promise<{upserted: number}>}
|
|
205
|
+
* @returns {Promise<{upserted: number}>} `upserted` is the store's own count, or
|
|
206
|
+
* UNREPORTED_COUNT if ANY batch's store response carried no count.
|
|
207
|
+
* Test it with isReportedCount(); never print it unguarded.
|
|
133
208
|
*/
|
|
134
209
|
export async function upsertChunks(apiClient, communityId, appId, kbId, chunks) {
|
|
135
210
|
if (chunks.length === 0) {
|
|
@@ -147,7 +222,9 @@ export async function upsertChunks(apiClient, communityId, appId, kbId, chunks)
|
|
|
147
222
|
kb_id: kbId,
|
|
148
223
|
chunks: batch
|
|
149
224
|
});
|
|
150
|
-
|
|
225
|
+
// apiClient.invoke returns the whole { status, message: {...} } envelope.
|
|
226
|
+
const data = result?.message || result;
|
|
227
|
+
totalUpserted = accumulateReportedCount(totalUpserted, data?.upserted_count);
|
|
151
228
|
}
|
|
152
229
|
|
|
153
230
|
return { upserted: totalUpserted };
|
|
@@ -164,7 +241,9 @@ export async function upsertChunks(apiClient, communityId, appId, kbId, chunks)
|
|
|
164
241
|
* @param {string} appId - App ID
|
|
165
242
|
* @param {string} kbId - Knowledge base ID
|
|
166
243
|
* @param {Array<string>} chunkIds - Chunk IDs to delete
|
|
167
|
-
* @returns {Promise<{deleted: number}>}
|
|
244
|
+
* @returns {Promise<{deleted: number}>} `deleted` is the store's own count, or
|
|
245
|
+
* UNREPORTED_COUNT if the store reported none. It is NOT chunkIds.length.
|
|
246
|
+
* Test it with isReportedCount(); never print it unguarded.
|
|
168
247
|
*/
|
|
169
248
|
export async function deleteStaleChunks(apiClient, communityId, appId, kbId, chunkIds) {
|
|
170
249
|
if (chunkIds.length === 0) {
|
|
@@ -179,7 +258,7 @@ export async function deleteStaleChunks(apiClient, communityId, appId, kbId, chu
|
|
|
179
258
|
});
|
|
180
259
|
|
|
181
260
|
const data = result?.message || result;
|
|
182
|
-
return { deleted: data
|
|
261
|
+
return { deleted: reportedCount(data?.deleted_count) };
|
|
183
262
|
}
|
|
184
263
|
|
|
185
264
|
/**
|
|
@@ -198,7 +277,10 @@ export async function deleteStaleChunks(apiClient, communityId, appId, kbId, chu
|
|
|
198
277
|
* @param {string} appId - App ID
|
|
199
278
|
* @param {string} kbId - Knowledge base ID
|
|
200
279
|
* @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}>}
|
|
280
|
+
* @returns {Promise<{deleted: number, deleted_by_file_id: number}>} Each field is the
|
|
281
|
+
* store's own count, or UNREPORTED_COUNT if the store reported that field.
|
|
282
|
+
* The two are INDEPENDENT — one may be reported and the other not. Neither
|
|
283
|
+
* is validFileIds.length. Test with isReportedCount(); never print unguarded.
|
|
202
284
|
*/
|
|
203
285
|
export async function deleteStaleChunksByFileId(apiClient, communityId, appId, kbId, fileIds) {
|
|
204
286
|
if (!Array.isArray(fileIds) || fileIds.length === 0) {
|
|
@@ -220,8 +302,8 @@ export async function deleteStaleChunksByFileId(apiClient, communityId, appId, k
|
|
|
220
302
|
|
|
221
303
|
const data = result?.message || result;
|
|
222
304
|
return {
|
|
223
|
-
deleted: data
|
|
224
|
-
deleted_by_file_id: data
|
|
305
|
+
deleted: reportedCount(data?.deleted_count),
|
|
306
|
+
deleted_by_file_id: reportedCount(data?.deleted_by_file_id)
|
|
225
307
|
};
|
|
226
308
|
}
|
|
227
309
|
|
|
@@ -253,7 +335,8 @@ export async function deleteStaleChunksByFileId(apiClient, communityId, appId, k
|
|
|
253
335
|
* @param {string} kbId - Knowledge base ID
|
|
254
336
|
* @returns {Promise<{ deleted: number, purged_scope: boolean }>}
|
|
255
337
|
* `deleted` is the actual count of vectors purged (the batched path always
|
|
256
|
-
* reports it)
|
|
338
|
+
* reports it), or UNREPORTED_COUNT if the store reported no count.
|
|
339
|
+
* Test it with isReportedCount(); never print it unguarded.
|
|
257
340
|
*/
|
|
258
341
|
export async function purgeKbScope(apiClient, communityId, appId, kbId) {
|
|
259
342
|
const result = await apiClient.invoke('kb_delete_chunks', {
|
|
@@ -264,7 +347,7 @@ export async function purgeKbScope(apiClient, communityId, appId, kbId) {
|
|
|
264
347
|
});
|
|
265
348
|
const data = result?.message || result;
|
|
266
349
|
return {
|
|
267
|
-
deleted:
|
|
350
|
+
deleted: reportedCount(data?.deleted_count),
|
|
268
351
|
purged_scope: data?.purged_scope === true
|
|
269
352
|
};
|
|
270
353
|
}
|
|
@@ -286,10 +369,21 @@ export async function listRemoteFileIds(apiClient, appId, kbId) {
|
|
|
286
369
|
kb_id: kbId
|
|
287
370
|
});
|
|
288
371
|
const data = result?.message || result;
|
|
372
|
+
// Same rule as getRemoteChunkMetadata: an empty array is the store REPORTING an
|
|
373
|
+
// empty namespace; an ABSENT array is the store not reporting. This list is the
|
|
374
|
+
// drift input (stale = remote_file_ids - local), so fabricating [] here would
|
|
375
|
+
// silently mean "nothing is stale" — the failure this module exists to prevent.
|
|
376
|
+
if (!Array.isArray(data?.file_ids)) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`Cannot enumerate remote file_ids for ${appId}/${kbId}: the store reported no ` +
|
|
379
|
+
`'file_ids' array. The remote file set is UNKNOWN and must NOT be treated as ` +
|
|
380
|
+
`empty — that would silently skip the stale purge.`
|
|
381
|
+
);
|
|
382
|
+
}
|
|
289
383
|
return {
|
|
290
|
-
file_ids: data.file_ids
|
|
291
|
-
unique_count: data
|
|
292
|
-
total_chunks: data
|
|
384
|
+
file_ids: data.file_ids,
|
|
385
|
+
unique_count: reportedCount(data?.unique_count),
|
|
386
|
+
total_chunks: reportedCount(data?.total_chunks)
|
|
293
387
|
};
|
|
294
388
|
}
|
|
295
389
|
|
|
@@ -336,9 +430,9 @@ export async function syncKb(apiClient, config, options = {}) {
|
|
|
336
430
|
if (onProgress) onProgress('Fetching existing chunks from Pinecone...');
|
|
337
431
|
const remoteChunks = await getRemoteChunkMetadata(apiClient, communityId, appId, kbId);
|
|
338
432
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
433
|
+
// getRemoteChunkMetadata returns an array or throws — it never reports an
|
|
434
|
+
// unknown remote set as an empty one, so there is no non-array case to default.
|
|
435
|
+
const remoteCount = remoteChunks.length;
|
|
342
436
|
|
|
343
437
|
if (verbose) {
|
|
344
438
|
console.log(` Local chunks: ${localChunks.length}`);
|
|
@@ -361,7 +455,10 @@ export async function syncKb(apiClient, config, options = {}) {
|
|
|
361
455
|
deleted = deleteResult.deleted;
|
|
362
456
|
if (verbose) console.log(` Deleted ${deleted} stale chunks`);
|
|
363
457
|
} catch (error) {
|
|
364
|
-
|
|
458
|
+
// The delete ERRORED: how many were removed is unknown. Leaving `deleted` at 0
|
|
459
|
+
// would report "nothing was deleted" — a claim about the store we cannot make.
|
|
460
|
+
deleted = UNREPORTED_COUNT;
|
|
461
|
+
console.warn(` Delete failed (deleted count unknown): ${error.message}`);
|
|
365
462
|
}
|
|
366
463
|
}
|
|
367
464
|
|
|
@@ -385,12 +482,11 @@ export async function syncKb(apiClient, config, options = {}) {
|
|
|
385
482
|
let deletedByFileId = 0;
|
|
386
483
|
try {
|
|
387
484
|
const localFileIds = new Set(localChunks.map(c => c.file_id).filter(Boolean));
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
const remoteFileIds = remoteData?.file_ids || [];
|
|
485
|
+
// Consume the module's own enumeration owner rather than re-invoking
|
|
486
|
+
// kb_list_file_ids and hand-unwrapping its payload: two derivations of one
|
|
487
|
+
// fact drift apart silently, and this copy would keep the `|| []` defect
|
|
488
|
+
// that the owner no longer has.
|
|
489
|
+
const { file_ids: remoteFileIds } = await listRemoteFileIds(apiClient, appId, kbId);
|
|
394
490
|
const staleFileIds = remoteFileIds.filter(fid => !localFileIds.has(fid));
|
|
395
491
|
if (staleFileIds.length > 0) {
|
|
396
492
|
if (onProgress) onProgress(`Purging ${staleFileIds.length} stale file_id(s)...`);
|
|
@@ -398,14 +494,18 @@ export async function syncKb(apiClient, config, options = {}) {
|
|
|
398
494
|
deletedByFileId = purgeResult.deleted;
|
|
399
495
|
}
|
|
400
496
|
} catch (purgeErr) {
|
|
401
|
-
// Non-fatal:
|
|
402
|
-
//
|
|
403
|
-
if (verbose)
|
|
497
|
+
// Non-fatal: the chunk_id-based delete above is the primary path and this is a
|
|
498
|
+
// safety net. We do NOT silently swallow — the caller sees a warning. That claim
|
|
499
|
+
// was previously false: the warning sat behind `if (verbose)`, so a default run
|
|
500
|
+
// saw nothing. It is unconditional now, which is what makes the sentence true.
|
|
501
|
+
console.warn(` File-id purge skipped (stale chunks may remain): ${purgeErr.message}`);
|
|
404
502
|
}
|
|
405
503
|
|
|
406
504
|
return {
|
|
407
505
|
synced,
|
|
408
|
-
|
|
506
|
+
// Unknown-ness survives the sum: if either leg could not be reported, their
|
|
507
|
+
// total is not a number we can state. (-1 + 5 would silently read as 4.)
|
|
508
|
+
deleted: accumulateReportedCount(deleted, deletedByFileId),
|
|
409
509
|
deleted_chunk_ids: deleted,
|
|
410
510
|
deleted_by_file_id: deletedByFileId,
|
|
411
511
|
unchanged
|
|
@@ -433,7 +533,8 @@ export async function getSyncStatus(apiClient, config) {
|
|
|
433
533
|
|
|
434
534
|
// Get remote chunk metadata
|
|
435
535
|
const remoteChunks = await getRemoteChunkMetadata(apiClient, communityId, appId, kbId);
|
|
436
|
-
|
|
536
|
+
// Array-or-throw, as above: no fabricated zero for an unknown remote set.
|
|
537
|
+
const remoteCount = remoteChunks.length;
|
|
437
538
|
|
|
438
539
|
// Compute delta using content_hash
|
|
439
540
|
const { toUpsert, toDelete, unchanged } = computeChunkDelta(localChunks, remoteChunks);
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE OWNER for the question "may I prompt?".
|
|
3
|
+
*
|
|
4
|
+
* Every prompt site in this CLI consumes this module. No site derives the answer itself, and
|
|
5
|
+
* there is no flag, env var or option that restores prompting when the environment cannot
|
|
6
|
+
* support it: the guard is a property of the ENVIRONMENT, not of the invocation. A flag would
|
|
7
|
+
* be a guard the caller must remember, and forgetting it reproduces the exact hang this module
|
|
8
|
+
* exists to remove.
|
|
9
|
+
*
|
|
10
|
+
* The rule is enforced by ENCAPSULATION, not by detection: you cannot obtain a prompt handle
|
|
11
|
+
* without passing the gate, because createPromptSession() is the only way to get one and it
|
|
12
|
+
* calls requireInteractive() before it constructs anything. A comment asking authors to check
|
|
13
|
+
* is not a guard — an author who does not read it ships the hang.
|
|
14
|
+
*
|
|
15
|
+
* Reference behaviour is gcloud: it refuses non-interactively rather than hanging or silently
|
|
16
|
+
* succeeding. Both failure modes were measured in this CLI before this module existed:
|
|
17
|
+
* - stdin an open pipe or a FIFO whose write end is held: readline waits forever. SIGKILL.
|
|
18
|
+
* - stdin /dev/null: readline closes, the question callback NEVER fires, the event loop
|
|
19
|
+
* drains and node exits 0 having written NOTHING. That is worse than the hang, because it
|
|
20
|
+
* is indistinguishable from success.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import readline from 'readline';
|
|
24
|
+
import chalk from 'chalk';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The single derivation of "is there a human on the other end of stdin".
|
|
28
|
+
*
|
|
29
|
+
* stdin is the decisive stream: a prompt must READ. stdout being redirected does not stop a
|
|
30
|
+
* terminal user from answering, so it is deliberately not consulted.
|
|
31
|
+
*
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function stdinIsInteractive() {
|
|
35
|
+
return process.stdin.isTTY === true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Thrown when a command needs to prompt and cannot. Carries the refusal text already formatted.
|
|
40
|
+
* Command actions in bin/descix.js catch Error, print `error.message` and exit 1, so throwing
|
|
41
|
+
* this produces a LOUD, NON-ZERO refusal — never a hang, never a silent exit 0.
|
|
42
|
+
*/
|
|
43
|
+
export class NonInteractiveError extends Error {
|
|
44
|
+
constructor(message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = 'NonInteractiveError';
|
|
47
|
+
this.isNonInteractive = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build the refusal text. Names what was being asked and how to get the job done without a
|
|
53
|
+
* prompt. It NEVER prescribes a destructive remedy: where the non-interactive form overwrites
|
|
54
|
+
* or deletes something, the caller is told so explicitly and the non-destructive option leads.
|
|
55
|
+
*
|
|
56
|
+
* @param {Object} o
|
|
57
|
+
* @param {string} o.what - the command, e.g. 'descix init'
|
|
58
|
+
* @param {string} [o.question] - the question that cannot be asked
|
|
59
|
+
* @param {string[]} [o.nonInteractiveForm] - concrete lines the caller can run instead
|
|
60
|
+
*/
|
|
61
|
+
function refusalText({ what, question, nonInteractiveForm, destructive }) {
|
|
62
|
+
// NO leading blank line and NO ❌ marker: command actions print `❌ ${error.message}`
|
|
63
|
+
// themselves. Emitting one here produced a doubled marker — the message OWNS the sentence,
|
|
64
|
+
// the caller OWNS the decoration.
|
|
65
|
+
const lines = [];
|
|
66
|
+
lines.push(`${what} cannot prompt during non-interactive execution.`);
|
|
67
|
+
lines.push(chalk.gray(' stdin is not a terminal, so there is no one to answer.'));
|
|
68
|
+
if (question) {
|
|
69
|
+
lines.push('');
|
|
70
|
+
lines.push(chalk.gray(` It needed to ask: ${question}`));
|
|
71
|
+
}
|
|
72
|
+
lines.push('');
|
|
73
|
+
if (destructive) {
|
|
74
|
+
// The confirmation being refused guards an IRREVERSIBLE act. The safe reading LEADS, and we
|
|
75
|
+
// never hand back a copy-pasteable destructive command as if it were the obvious next step.
|
|
76
|
+
lines.push(chalk.yellow(' This was a confirmation for an IRREVERSIBLE operation.'));
|
|
77
|
+
lines.push(chalk.gray(' Nothing has been changed.'));
|
|
78
|
+
lines.push('');
|
|
79
|
+
}
|
|
80
|
+
if (nonInteractiveForm && nonInteractiveForm.length > 0) {
|
|
81
|
+
lines.push(chalk.white(' Non-interactive form:'));
|
|
82
|
+
for (const line of nonInteractiveForm) lines.push(` ${line}`);
|
|
83
|
+
} else {
|
|
84
|
+
lines.push(chalk.white(` ${what} has no non-interactive form. Run it in a terminal.`));
|
|
85
|
+
}
|
|
86
|
+
lines.push('');
|
|
87
|
+
return lines.join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The gate. Call this before ANY prompt — readline, inquirer, or anything else.
|
|
92
|
+
* Returns silently when a human can answer; throws a loud NonInteractiveError when not.
|
|
93
|
+
*
|
|
94
|
+
* @param {Object} o - see refusalText
|
|
95
|
+
* @throws {NonInteractiveError}
|
|
96
|
+
*/
|
|
97
|
+
export function requireInteractive({ what, question, nonInteractiveForm, destructive } = {}) {
|
|
98
|
+
if (stdinIsInteractive()) return;
|
|
99
|
+
throw new NonInteractiveError(refusalText({ what, question, nonInteractiveForm, destructive }));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The ONLY way to obtain a prompt handle in this CLI.
|
|
104
|
+
*
|
|
105
|
+
* Gated at construction, so a site physically cannot reach readline without the check. Each
|
|
106
|
+
* ask() also re-states the question in the refusal if the session is used later, and every
|
|
107
|
+
* ask() rejects rather than hanging if stdin stops being a terminal mid-session.
|
|
108
|
+
*
|
|
109
|
+
* @param {Object} o
|
|
110
|
+
* @param {string} o.what - the command, e.g. 'descix init'
|
|
111
|
+
* @param {string[]} [o.nonInteractiveForm]
|
|
112
|
+
* @param {boolean} [o.terminal] - readline terminal option (airdrop needs true to mask input)
|
|
113
|
+
* @returns {{ask: Function, askYesNo: Function, close: Function}}
|
|
114
|
+
*/
|
|
115
|
+
export function createPromptSession({ what, nonInteractiveForm, terminal, destructive } = {}) {
|
|
116
|
+
requireInteractive({ what, nonInteractiveForm, destructive });
|
|
117
|
+
|
|
118
|
+
const rl = readline.createInterface({
|
|
119
|
+
input: process.stdin,
|
|
120
|
+
output: process.stdout,
|
|
121
|
+
...(terminal === undefined ? {} : { terminal })
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** Ask for free text, with an optional default. */
|
|
125
|
+
const ask = (question, defaultValue = '') => {
|
|
126
|
+
requireInteractive({ what, question, nonInteractiveForm, destructive });
|
|
127
|
+
const suffix = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `;
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
rl.question(suffix, (answer) => resolve(answer.trim() || defaultValue));
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Ask a yes/no question with an explicit default. */
|
|
134
|
+
const askYesNo = (question, defaultYes = true) => {
|
|
135
|
+
requireInteractive({ what, question, nonInteractiveForm, destructive });
|
|
136
|
+
const suffix = defaultYes ? '(Y/n)' : '(y/N)';
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
rl.question(`${question} ${suffix}: `, (answer) => {
|
|
139
|
+
const trimmed = answer.trim().toLowerCase();
|
|
140
|
+
if (trimmed === '') return resolve(defaultYes);
|
|
141
|
+
resolve(trimmed === 'y' || trimmed === 'yes');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Ask with the prompt text EXACTLY as given — no added suffix. For sites that already spell
|
|
148
|
+
* their own "[y/N] " and must keep their wording verbatim.
|
|
149
|
+
*/
|
|
150
|
+
const askRaw = (question) => {
|
|
151
|
+
requireInteractive({ what, question, nonInteractiveForm, destructive });
|
|
152
|
+
return new Promise((resolve) => rl.question(question, resolve));
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return { ask, askYesNo, askRaw, close: () => rl.close() };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export default { stdinIsInteractive, requireInteractive, createPromptSession, NonInteractiveError };
|
package/lib/origin.js
CHANGED
|
@@ -79,15 +79,16 @@ export const ORIGIN_SOURCE_LABELS = {
|
|
|
79
79
|
flagEnv: '--env flag',
|
|
80
80
|
envVar: 'DESCIX_API_URL environment variable',
|
|
81
81
|
workspaceEnvApiUrl: '.descix/workspace.json env.apiUrl',
|
|
82
|
-
legacyApiUrl: '.descix/workspace.json apiUrl (legacy)',
|
|
83
82
|
globalApiUrl: '~/.descix/config.json api_url',
|
|
84
83
|
};
|
|
85
84
|
|
|
86
85
|
/**
|
|
87
86
|
* The resolution order, most explicit first. The ORDER is the contract; this array is the only
|
|
88
87
|
* place it is written down. Labels come from ORIGIN_SOURCE_LABELS, never from a second literal.
|
|
88
|
+
* The workspace names its origin at `env.apiUrl` and nowhere else — a top-level `apiUrl` is a
|
|
89
|
+
* retired v1 key that `WorkspaceConfig.load()` refuses by name.
|
|
89
90
|
*/
|
|
90
|
-
const PRECEDENCE = ['envVar', 'workspaceEnvApiUrl', '
|
|
91
|
+
const PRECEDENCE = ['envVar', 'workspaceEnvApiUrl', 'globalApiUrl'];
|
|
91
92
|
|
|
92
93
|
/**
|
|
93
94
|
* What this invocation's FLAGS named, if anything: `{ origin, sourceKey }`.
|
|
@@ -181,7 +182,6 @@ function isUsableOrigin(value) {
|
|
|
181
182
|
* @param {object} sources
|
|
182
183
|
* @param {string|null} [sources.envVar] process.env.DESCIX_API_URL
|
|
183
184
|
* @param {string|null} [sources.workspaceEnvApiUrl] workspace.json env.apiUrl
|
|
184
|
-
* @param {string|null} [sources.legacyApiUrl] workspace.json top-level apiUrl
|
|
185
185
|
* @param {string|null} [sources.globalApiUrl] ~/.descix/config.json api_url
|
|
186
186
|
* @returns {{ origin: string, source: string, isDefault: boolean }} the origin AND which source
|
|
187
187
|
* supplied it — the source is returned because "where did this come from" is the
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SDK's agent assets (instructions, guides, reference) ship inside this package at
|
|
3
|
+
* agent-assets/. `pullSdkAssets` copies them into a workspace at .descix/sdk-assets/ so a local AI
|
|
4
|
+
* agent can read them. A missing source is a broken install and throws; it is never skipped.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'fs/promises';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
|
|
10
|
+
/** agent-assets/ at the package root, one level above lib/. */
|
|
11
|
+
export const SDK_ASSETS_SOURCE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'agent-assets');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Copy the package's agent-assets/ to <workspaceRoot>/.descix/sdk-assets/.
|
|
15
|
+
* @param {string} workspaceRoot
|
|
16
|
+
* @returns {Promise<string>} the target directory
|
|
17
|
+
* @throws when the package's agent-assets/ is absent or the copy fails
|
|
18
|
+
*/
|
|
19
|
+
export async function pullSdkAssets(workspaceRoot) {
|
|
20
|
+
const target = path.join(workspaceRoot, '.descix', 'sdk-assets');
|
|
21
|
+
try {
|
|
22
|
+
await fs.access(SDK_ASSETS_SOURCE);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(`SDK agent assets are missing from this install (${SDK_ASSETS_SOURCE}). Reinstall @descix/cli.`);
|
|
25
|
+
}
|
|
26
|
+
await fs.cp(SDK_ASSETS_SOURCE, target, { recursive: true });
|
|
27
|
+
return target;
|
|
28
|
+
}
|