@geraldmaron/construct 1.5.0 → 1.5.2
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 +3 -0
- package/bin/construct +336 -17
- package/lib/artifact-loop-core.mjs +26 -5
- package/lib/artifact-manifest-overlay.mjs +273 -0
- package/lib/artifact-manifest.mjs +13 -10
- package/lib/cli-commands.mjs +44 -4
- package/lib/config/source-target-registry.mjs +1 -0
- package/lib/config/source-targets.mjs +30 -0
- package/lib/doc-stamp.mjs +12 -0
- package/lib/doctor/index.mjs +2 -1
- package/lib/doctor/source-target-health.mjs +101 -0
- package/lib/doctor/watchers/source-targets.mjs +44 -0
- package/lib/document-ingest.mjs +25 -3
- package/lib/embed/demand-fetch.mjs +30 -36
- package/lib/embed/providers/directory.mjs +117 -0
- package/lib/embed/providers/registry.mjs +7 -0
- package/lib/extensions/manifests/atlassian-jira.manifest.json +1 -1
- package/lib/extensions/manifests/directory.manifest.json +24 -1
- package/lib/extensions/manifests/github.manifest.json +10 -0
- package/lib/extensions/manifests/linear.manifest.json +1 -1
- package/lib/hooks/session-start.mjs +18 -11
- package/lib/init-unified.mjs +2 -7
- package/lib/knowledge/rag.mjs +52 -11
- package/lib/knowledge/search.mjs +69 -6
- package/lib/knowledge/synthesis.mjs +157 -0
- package/lib/mcp/server.mjs +2 -2
- package/lib/mcp/tool-definitions-workflow.mjs +35 -7
- package/lib/mcp/tools/artifact-author.mjs +126 -13
- package/lib/mcp/tools/orchestration-run.mjs +3 -0
- package/lib/mcp/tools/skills.mjs +17 -0
- package/lib/model-cheapest-provider.mjs +2 -1
- package/lib/model-policy.mjs +329 -0
- package/lib/model-router.mjs +10 -9
- package/lib/model-tiers.mjs +27 -0
- package/lib/models/catalog.mjs +5 -4
- package/lib/orchestration/classification.mjs +11 -0
- package/lib/orchestration/context-bindings.mjs +85 -0
- package/lib/orchestration/readiness.mjs +2 -1
- package/lib/orchestration/research-evidence-gate.mjs +104 -0
- package/lib/orchestration/runtime.mjs +35 -1
- package/lib/orchestration/worker.mjs +9 -0
- package/lib/providers/directory/index.mjs +19 -7
- package/lib/setup.mjs +2 -1
- package/lib/sources/content-roots.mjs +147 -0
- package/lib/sources/repo-cache.mjs +142 -0
- package/lib/template-registry.mjs +2 -0
- package/lib/test-corpus-inventory.mjs +1 -0
- package/lib/tracker/contribute.mjs +266 -0
- package/lib/validator.mjs +2 -3
- package/package.json +1 -1
- package/registry/agent-manifest.json +0 -4
- package/registry/capabilities.json +20 -1
- package/templates/docs/README.md +22 -0
- package/templates/docs/adhoc.md +12 -0
- package/templates/docs/strategy-comparison.md +19 -0
package/lib/document-ingest.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { purgeExpiredData } from './storage/admin.mjs';
|
|
|
15
15
|
import { stampFrontmatter } from './doc-stamp.mjs';
|
|
16
16
|
import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS } from './knowledge/layout.mjs';
|
|
17
17
|
import { loadProjectConfig } from './config/project-config.mjs';
|
|
18
|
+
import { resolveEffectiveSourceTargetsFromConfig } from './config/source-targets.mjs';
|
|
18
19
|
import { resolveIngestStrategy, INGEST_STRATEGIES, INGEST_ORCHESTRATION_STRATEGIES } from './ingest/strategy.mjs';
|
|
19
20
|
import { extractViaProvider } from './ingest/provider-extract.mjs';
|
|
20
21
|
import { extractViaDoclingRemote } from './ingest/docling-remote.mjs';
|
|
@@ -261,6 +262,7 @@ export async function ingestDocuments(inputPaths, {
|
|
|
261
262
|
outputPath = null,
|
|
262
263
|
outputDir = null,
|
|
263
264
|
target = 'knowledge/internal',
|
|
265
|
+
as = null,
|
|
264
266
|
sync = false,
|
|
265
267
|
strict = false,
|
|
266
268
|
highFidelity = true,
|
|
@@ -281,6 +283,23 @@ export async function ingestDocuments(inputPaths, {
|
|
|
281
283
|
}
|
|
282
284
|
|
|
283
285
|
const { config } = loadProjectConfig(cwd, env);
|
|
286
|
+
|
|
287
|
+
// `--as=<targetId>` binds ingested knowledge to a registered source target so
|
|
288
|
+
// the written files carry re-verifiable provenance (origin_target_id /
|
|
289
|
+
// origin_provider in the stamp) rather than landing unattributed. An unknown
|
|
290
|
+
// id is a hard error — silently dropping the binding would defeat the point.
|
|
291
|
+
let originFields = null;
|
|
292
|
+
if (as) {
|
|
293
|
+
const targets = resolveEffectiveSourceTargetsFromConfig(config, env);
|
|
294
|
+
const bound = targets.find((t) => t.id === as);
|
|
295
|
+
if (!bound) {
|
|
296
|
+
const known = targets.map((t) => t.id).join(', ') || '(none registered)';
|
|
297
|
+
const err = new Error(`ingest --as: unknown source target "${as}". Known targets: ${known}`);
|
|
298
|
+
err.code = 'INGEST_UNKNOWN_TARGET';
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
301
|
+
originFields = { origin_target_id: bound.id, origin_provider: bound.provider };
|
|
302
|
+
}
|
|
284
303
|
const resolvedStrategy = resolveIngestStrategy({ config, env, override: strategy, orchestrationOverride: orchestration, cwd });
|
|
285
304
|
|
|
286
305
|
const results = [];
|
|
@@ -318,7 +337,7 @@ export async function ingestDocuments(inputPaths, {
|
|
|
318
337
|
cwd,
|
|
319
338
|
metadata,
|
|
320
339
|
});
|
|
321
|
-
writeFileSync(finalPath, stampFrontmatter(markdown, { generator: 'construct/ingest' }));
|
|
340
|
+
writeFileSync(finalPath, stampFrontmatter(markdown, { generator: 'construct/ingest', extraFields: originFields }));
|
|
322
341
|
results.push({
|
|
323
342
|
sourcePath,
|
|
324
343
|
outputPath: finalPath,
|
|
@@ -390,6 +409,7 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
390
409
|
let outputPath = null;
|
|
391
410
|
let outputDir = null;
|
|
392
411
|
let target = 'knowledge/internal';
|
|
412
|
+
let as = null;
|
|
393
413
|
let sync = false;
|
|
394
414
|
let strict = false;
|
|
395
415
|
let fidelity = 'high';
|
|
@@ -400,6 +420,7 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
400
420
|
if (arg.startsWith('--out=')) outputPath = arg.split('=').slice(1).join('=');
|
|
401
421
|
else if (arg.startsWith('--out-dir=')) outputDir = arg.split('=').slice(1).join('=');
|
|
402
422
|
else if (arg.startsWith('--target=')) target = arg.split('=').slice(1).join('=');
|
|
423
|
+
else if (arg.startsWith('--as=')) as = arg.split('=').slice(1).join('=');
|
|
403
424
|
else if (arg.startsWith('--strategy=')) strategy = arg.split('=').slice(1).join('=');
|
|
404
425
|
else if (arg === '--strategy') strategy = '';
|
|
405
426
|
else if (arg.startsWith('--orchestration=')) orchestration = arg.split('=').slice(1).join('=');
|
|
@@ -421,7 +442,8 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
421
442
|
if (inputs.length === 0) {
|
|
422
443
|
throw new Error(
|
|
423
444
|
`Usage: construct ingest <file-or-dir> [more paths] [--out=FILE] [--out-dir=DIR] ` +
|
|
424
|
-
`[--target=sibling|knowledge/<subdir>] [--strategy=adapter|provider] [--orchestration=prompt-only|orchestrated] [--sync] [--strict] [--fidelity=fast|high]\n` +
|
|
445
|
+
`[--target=sibling|knowledge/<subdir>] [--as=<targetId>] [--strategy=adapter|provider] [--orchestration=prompt-only|orchestrated] [--sync] [--strict] [--fidelity=fast|high]\n` +
|
|
446
|
+
` --as: bind ingested knowledge to a registered source target; stamps origin provenance\n` +
|
|
425
447
|
` --strategy: extraction strategy override (adapter = local extractors; provider = configured provider/model)\n` +
|
|
426
448
|
` --orchestration: prompt-only (deterministic extraction) or orchestrated (engage the specialist chain)\n` +
|
|
427
449
|
` --strict: exit non-zero if any extraction drops occur\n` +
|
|
@@ -443,5 +465,5 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
443
465
|
);
|
|
444
466
|
}
|
|
445
467
|
|
|
446
|
-
return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, strategy: strategy || null, orchestration: orchestration || null, env });
|
|
468
|
+
return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, as: as || null, sync, strict, highFidelity, strategy: strategy || null, orchestration: orchestration || null, env });
|
|
447
469
|
}
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
resolveEffectiveSourceTargetsFromConfig,
|
|
29
29
|
resolveKnownSourcesFromTargets,
|
|
30
30
|
} from '../config/source-targets.mjs';
|
|
31
|
-
import { getSourceTargetDescriptor, renderTemplate } from '../config/source-target-registry.mjs';
|
|
31
|
+
import { getSourceTargetDescriptor, listSourceTargetDescriptors, renderTemplate } from '../config/source-target-registry.mjs';
|
|
32
32
|
|
|
33
33
|
// ─── Self-query detection ────────────────────────────────────────────────────
|
|
34
34
|
|
|
@@ -79,41 +79,22 @@ export function resolveKnownSources(env = process.env, cwd = process.cwd()) {
|
|
|
79
79
|
return resolveKnownSourcesFromTargets(targets);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// No configured or legacy-env targets resolved — which means every list-valued
|
|
83
|
+
// legacy var (GITHUB_REPOS, JIRA_PROJECTS, LINEAR_TEAMS, SLACK_CHANNELS) was
|
|
84
|
+
// empty, so the descriptor path above already covered those. What remains is a
|
|
85
|
+
// credential-only source: a provider reachable via a bare credential env
|
|
86
|
+
// (JIRA_BASE_URL, LINEAR_API_KEY) with no explicit list. Each such provider
|
|
87
|
+
// declares that gate as aliases.catchAllCredentialEnv on its manifest, so this
|
|
88
|
+
// stays descriptor-driven — no provider name, env var, or display prefix is
|
|
89
|
+
// hardcoded here, and a provider whose descriptor omits the gate (slack) is
|
|
90
|
+
// deliberately not advertised without an explicit channel.
|
|
82
91
|
const sources = [];
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
sources.push({ id: short.toLowerCase(), provider: 'github', ref: repo, display: repo });
|
|
90
|
-
// Normalised: "projectiverson", "iverson"
|
|
91
|
-
sources.push({ id: short.replace(/-/g, '').toLowerCase(), provider: 'github', ref: repo, display: repo });
|
|
92
|
-
// Last word: "iverson" from "project-iverson"
|
|
93
|
-
const words = short.split('-');
|
|
94
|
-
if (words.length > 1) {
|
|
95
|
-
sources.push({ id: words[words.length - 1].toLowerCase(), provider: 'github', ref: repo, display: repo });
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Jira projects
|
|
100
|
-
const jiraUrl = (env.JIRA_BASE_URL ?? '').trim();
|
|
101
|
-
if (jiraUrl) {
|
|
102
|
-
// JIRA_PROJECTS=PLAT,INF,ENG — optional explicit list
|
|
103
|
-
const projects = (env.JIRA_PROJECTS ?? '').split(',').map(p => p.trim()).filter(Boolean);
|
|
104
|
-
for (const proj of projects) {
|
|
105
|
-
sources.push({ id: proj.toLowerCase(), provider: 'jira', ref: proj, display: `Jira/${proj}` });
|
|
106
|
-
}
|
|
107
|
-
// Always add a generic "jira" entry
|
|
108
|
-
sources.push({ id: 'jira', provider: 'jira', ref: null, display: 'Jira' });
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Linear
|
|
112
|
-
if (env.LINEAR_API_KEY) {
|
|
113
|
-
sources.push({ id: 'linear', provider: 'linear', ref: null, display: 'Linear' });
|
|
114
|
-
const teams = (env.LINEAR_TEAMS ?? '').split(',').map(t => t.trim()).filter(Boolean);
|
|
115
|
-
for (const team of teams) {
|
|
116
|
-
sources.push({ id: team.toLowerCase(), provider: 'linear', ref: team, display: `Linear/${team}` });
|
|
92
|
+
for (const descriptor of listSourceTargetDescriptors()) {
|
|
93
|
+
const gate = descriptor.aliases?.catchAllCredentialEnv;
|
|
94
|
+
if (!descriptor.aliases?.catchAll || !Array.isArray(gate)) continue;
|
|
95
|
+
const credentialed = gate.some((name) => String(env[name] ?? '').trim());
|
|
96
|
+
if (credentialed) {
|
|
97
|
+
sources.push({ id: descriptor.provider, provider: descriptor.provider, ref: null, display: descriptor.aliases.displayPrefix });
|
|
117
98
|
}
|
|
118
99
|
}
|
|
119
100
|
|
|
@@ -413,9 +394,21 @@ async function demandFetchAll({ query, rootDir, env, cwd } = {}) {
|
|
|
413
394
|
const targets = resolveEffectiveSourceTargetsFromConfig(config, env);
|
|
414
395
|
const scoped = targets.length > 0;
|
|
415
396
|
|
|
397
|
+
// The unscoped fallback ("pull everything fresh") only drives providers that
|
|
398
|
+
// can be discovered from env/poll config — those whose descriptor declares an
|
|
399
|
+
// `embed` or `legacyEnv` block. A target-only provider (directory) has no
|
|
400
|
+
// env-derived default source, so it is excluded here rather than fetched with
|
|
401
|
+
// an empty selector; it participates through the scoped path once a target is
|
|
402
|
+
// configured. Dispatch is on the descriptor, never a provider name.
|
|
403
|
+
|
|
404
|
+
const envFetchable = new Set(
|
|
405
|
+
listSourceTargetDescriptors()
|
|
406
|
+
.filter((d) => d.embed || d.legacyEnv)
|
|
407
|
+
.map((d) => d.provider),
|
|
408
|
+
);
|
|
416
409
|
const providerNames = scoped
|
|
417
410
|
? [...new Set(targets.map((t) => t.provider))].filter((name) => registry.get(name))
|
|
418
|
-
: registry.
|
|
411
|
+
: [...envFetchable].filter((name) => registry.get(name));
|
|
419
412
|
|
|
420
413
|
if (!providerNames.length) {
|
|
421
414
|
return {
|
|
@@ -579,6 +572,7 @@ function buildReadCalls(match, env, cwd = process.cwd()) {
|
|
|
579
572
|
}
|
|
580
573
|
|
|
581
574
|
if (df.queryOptsKind === 'scalar') {
|
|
575
|
+
if (match.ref == null && !df.queryOptsNullable) return [];
|
|
582
576
|
const value = df.queryOptsNullable ? (match.ref ?? null) : match.ref;
|
|
583
577
|
return [{ ref: df.queryRefs[0], opts: { [df.queryOptsField]: value, limit: df.queryDefaultLimit }, display: match.display }];
|
|
584
578
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/embed/providers/directory.mjs — local directory provider for embed mode.
|
|
3
|
+
*
|
|
4
|
+
* Reads markdown/doc files from a registered local directory target so a
|
|
5
|
+
* checked-out repository or docs folder can act as a context source. Purely
|
|
6
|
+
* local: it walks the filesystem and never opens a socket, satisfying the
|
|
7
|
+
* directory-targets-never-touch-the-network invariant (bead construct-760c.1
|
|
8
|
+
* R6). Requires no credentials, so ProviderRegistry always registers it.
|
|
9
|
+
*
|
|
10
|
+
* Supported refs:
|
|
11
|
+
* docs Doc/markdown files under the path (bounded depth, doc extensions)
|
|
12
|
+
* readme The top-level README, when present
|
|
13
|
+
* meta A one-line summary of the directory (path + doc file count)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
17
|
+
import { basename, extname, join, relative } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { expandTilde } from '../../config/source-targets.mjs';
|
|
20
|
+
|
|
21
|
+
const DOC_EXTS = new Set(['.md', '.mdx', '.markdown', '.txt', '.rst', '.adoc']);
|
|
22
|
+
|
|
23
|
+
// Directories that are noise for a docs corpus: version-control internals,
|
|
24
|
+
// dependency trees, and build output dwarf the actual documentation.
|
|
25
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', '.construct', '.cx', 'dist', 'build', 'coverage', '.next', 'vendor']);
|
|
26
|
+
|
|
27
|
+
const DOC_CONTENT_LIMIT = 8_000;
|
|
28
|
+
const MAX_DEPTH = 10;
|
|
29
|
+
|
|
30
|
+
function isReadme(name) {
|
|
31
|
+
return /^readme(\.\w+)?$/i.test(name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function collectDocFiles(rootDir) {
|
|
35
|
+
const files = [];
|
|
36
|
+
const stack = [{ dir: rootDir, depth: 0 }];
|
|
37
|
+
while (stack.length > 0) {
|
|
38
|
+
const { dir, depth } = stack.pop();
|
|
39
|
+
if (depth >= MAX_DEPTH) continue;
|
|
40
|
+
let entries;
|
|
41
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (entry.name.startsWith('.') && entry.isDirectory()) continue;
|
|
44
|
+
const full = join(dir, entry.name);
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
47
|
+
stack.push({ dir: full, depth: depth + 1 });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (entry.isFile() && DOC_EXTS.has(extname(entry.name).toLowerCase())) files.push(full);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return files.sort((a, b) => a.localeCompare(b));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readDoc(rootDir, filePath) {
|
|
57
|
+
const rel = relative(rootDir, filePath);
|
|
58
|
+
let content = '';
|
|
59
|
+
try { content = readFileSync(filePath, 'utf8').slice(0, DOC_CONTENT_LIMIT); } catch { return null; }
|
|
60
|
+
return {
|
|
61
|
+
type: 'doc',
|
|
62
|
+
source: 'directory',
|
|
63
|
+
path: rel,
|
|
64
|
+
content,
|
|
65
|
+
summary: `Doc ${rel}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class DirectoryProvider {
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} ref - 'docs' | 'readme' | 'meta'
|
|
72
|
+
* @param {object} opts - { path, limit }
|
|
73
|
+
* @returns {Promise<Item[]>}
|
|
74
|
+
*/
|
|
75
|
+
async read(ref, opts = {}) {
|
|
76
|
+
const rawPath = opts.path ?? opts.dir;
|
|
77
|
+
if (!rawPath) throw new Error('Directory source requires a "path" field');
|
|
78
|
+
const rootDir = expandTilde(String(rawPath));
|
|
79
|
+
|
|
80
|
+
let stat;
|
|
81
|
+
try { stat = statSync(rootDir); } catch {
|
|
82
|
+
return [{ type: 'error', source: 'directory', path: rootDir, ref, message: `path not found: ${rootDir}` }];
|
|
83
|
+
}
|
|
84
|
+
if (!stat.isDirectory()) {
|
|
85
|
+
return [{ type: 'error', source: 'directory', path: rootDir, ref, message: `not a directory: ${rootDir}` }];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const limit = Number(opts.limit ?? 100);
|
|
89
|
+
|
|
90
|
+
if (ref === 'meta') {
|
|
91
|
+
const count = collectDocFiles(rootDir).length;
|
|
92
|
+
return [{
|
|
93
|
+
type: 'meta',
|
|
94
|
+
source: 'directory',
|
|
95
|
+
path: rootDir,
|
|
96
|
+
description: `Local directory ${basename(rootDir)} (${count} doc file(s))`,
|
|
97
|
+
summary: `Directory ${rootDir}: ${count} doc file(s)`,
|
|
98
|
+
}];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const files = collectDocFiles(rootDir);
|
|
102
|
+
|
|
103
|
+
if (ref === 'readme') {
|
|
104
|
+
const readme = files.find((f) => isReadme(basename(f)));
|
|
105
|
+
if (!readme) return [];
|
|
106
|
+
const rec = readDoc(rootDir, readme);
|
|
107
|
+
return rec ? [rec] : [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const records = [];
|
|
111
|
+
for (const file of files.slice(0, limit)) {
|
|
112
|
+
const rec = readDoc(rootDir, file);
|
|
113
|
+
if (rec) records.push(rec);
|
|
114
|
+
}
|
|
115
|
+
return records;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -37,8 +37,15 @@ import { loadManifestsFromDir, mergeManifests, resolveManifestDirs } from '../..
|
|
|
37
37
|
* `credentials` is one of:
|
|
38
38
|
* { anyOf: [...] } — usable if at least one listed env var is set (GitHub)
|
|
39
39
|
* { allOf: [...] } — usable only if every listed env var is set (Jira)
|
|
40
|
+
* null — no credentials required, always usable (directory)
|
|
40
41
|
*/
|
|
41
42
|
const ADAPTERS = {
|
|
43
|
+
directory: {
|
|
44
|
+
aliases: ['directory', 'dir'],
|
|
45
|
+
credentials: null,
|
|
46
|
+
load: () => import('./directory.mjs'),
|
|
47
|
+
build: (mod) => new mod.DirectoryProvider(),
|
|
48
|
+
},
|
|
42
49
|
github: {
|
|
43
50
|
aliases: ['github', 'gh'],
|
|
44
51
|
credentials: { anyOf: ['GITHUB_TOKEN', 'GITHUB_PERSONAL_ACCESS_TOKEN', 'GH_TOKEN'] },
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"signature": { "template": "jira:project:{value}", "valueMode": "upperOrEmpty" },
|
|
22
22
|
"legacyEnv": { "vars": ["JIRA_PROJECTS"], "parse": "csv", "valueTransform": "upper" },
|
|
23
|
-
"aliases": { "strategy": "simple", "displayPrefix": "Jira", "catchAll": true },
|
|
23
|
+
"aliases": { "strategy": "simple", "displayPrefix": "Jira", "catchAll": true, "catchAllCredentialEnv": ["JIRA_BASE_URL"] },
|
|
24
24
|
"embed": {
|
|
25
25
|
"mode": "perTarget",
|
|
26
26
|
"valueField": "project",
|
|
@@ -8,5 +8,28 @@
|
|
|
8
8
|
"securityClassification": "trusted",
|
|
9
9
|
"owner": "construct-core",
|
|
10
10
|
"compatVersion": 1,
|
|
11
|
-
"installSource": "builtin"
|
|
11
|
+
"installSource": "builtin",
|
|
12
|
+
"sourceTarget": {
|
|
13
|
+
"provider": "directory",
|
|
14
|
+
"selector": {
|
|
15
|
+
"field": "path",
|
|
16
|
+
"hint": "required existing directory path (tilde-expanded)",
|
|
17
|
+
"normalize": "trim",
|
|
18
|
+
"expand": "tilde",
|
|
19
|
+
"existsAs": "directory",
|
|
20
|
+
"existsHint": "path must exist and be a directory"
|
|
21
|
+
},
|
|
22
|
+
"signature": { "template": "directory:path:{value}", "valueMode": "path" },
|
|
23
|
+
"aliases": { "strategy": "path", "displayPrefix": null, "catchAll": false },
|
|
24
|
+
"demandFetch": {
|
|
25
|
+
"targetRefs": ["docs"],
|
|
26
|
+
"targetOptsField": "path",
|
|
27
|
+
"targetOptsKind": "scalar",
|
|
28
|
+
"targetDefaultLimit": 100,
|
|
29
|
+
"queryRefs": ["docs"],
|
|
30
|
+
"queryOptsField": "path",
|
|
31
|
+
"queryOptsKind": "scalar",
|
|
32
|
+
"queryDefaultLimit": 100
|
|
33
|
+
}
|
|
34
|
+
}
|
|
12
35
|
}
|
|
@@ -19,6 +19,16 @@
|
|
|
19
19
|
"normalize": "trim"
|
|
20
20
|
},
|
|
21
21
|
"signature": { "template": "github:repo:{value}", "valueMode": "optionalLower" },
|
|
22
|
+
"content": {
|
|
23
|
+
"field": "content",
|
|
24
|
+
"modeField": "mode",
|
|
25
|
+
"modeValue": "corpus",
|
|
26
|
+
"refField": "ref",
|
|
27
|
+
"defaultRef": "main",
|
|
28
|
+
"remoteField": "remote",
|
|
29
|
+
"remoteTemplate": "https://github.com/{value}.git",
|
|
30
|
+
"clone": "git"
|
|
31
|
+
},
|
|
22
32
|
"legacyEnv": { "vars": ["GITHUB_REPOS", "GITHUB_REPO"], "parse": "csv" },
|
|
23
33
|
"aliases": { "strategy": "repoWords", "displayPrefix": null, "catchAll": false },
|
|
24
34
|
"embed": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"signature": { "template": "linear:team:{value}", "valueMode": "lowerOrEmpty" },
|
|
22
22
|
"legacyEnv": { "vars": ["LINEAR_TEAMS"], "parse": "csv" },
|
|
23
|
-
"aliases": { "strategy": "simple", "displayPrefix": "Linear", "catchAll": true },
|
|
23
|
+
"aliases": { "strategy": "simple", "displayPrefix": "Linear", "catchAll": true, "catchAllCredentialEnv": ["LINEAR_API_KEY"] },
|
|
24
24
|
"embed": {
|
|
25
25
|
"mode": "perTarget",
|
|
26
26
|
"valueField": "team",
|
|
@@ -269,19 +269,26 @@ let embedStatusNote = '';
|
|
|
269
269
|
try {
|
|
270
270
|
const { loadProjectConfig } = await import('../config/project-config.mjs');
|
|
271
271
|
const { resolveEffectiveSourceTargetsFromConfig } = await import('../config/source-targets.mjs');
|
|
272
|
+
const { listSourceTargetDescriptors } = await import('../config/source-target-registry.mjs');
|
|
272
273
|
const { config } = loadProjectConfig(process.cwd(), process.env);
|
|
273
274
|
const targets = resolveEffectiveSourceTargetsFromConfig(config, process.env);
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (
|
|
275
|
+
|
|
276
|
+
// Iterate the manifest-declared descriptors instead of naming providers here,
|
|
277
|
+
// so a newly manifest-registered provider_fetch source appears in the hint
|
|
278
|
+
// automatically rather than being silently dropped (construct-j745). Gate on
|
|
279
|
+
// `embed`: the network provider_fetch targets declare it, while a content-only
|
|
280
|
+
// `directory` target does not — and `provider_fetch` does not apply to it.
|
|
281
|
+
|
|
282
|
+
const parts = [];
|
|
283
|
+
for (const d of listSourceTargetDescriptors()) {
|
|
284
|
+
const field = d.embed ? d.selector?.field : null;
|
|
285
|
+
if (!field) continue;
|
|
286
|
+
const values = targets.filter((t) => t.provider === d.provider).map((t) => t.selector?.[field]).filter(Boolean);
|
|
287
|
+
if (values.length === 0) continue;
|
|
288
|
+
const label = d.aliases?.displayPrefix || (d.provider.charAt(0).toUpperCase() + d.provider.slice(1));
|
|
289
|
+
parts.push(`${label} ${field}s: ${values.join(', ')}`);
|
|
290
|
+
}
|
|
291
|
+
if (parts.length > 0) {
|
|
285
292
|
sourcesNote = '\nProvider sources wired: ' + parts.join(' · ') +
|
|
286
293
|
'. Use `provider_fetch` for any question about them.\n';
|
|
287
294
|
}
|
package/lib/init-unified.mjs
CHANGED
|
@@ -1234,15 +1234,10 @@ async function main() {
|
|
|
1234
1234
|
try {
|
|
1235
1235
|
const { startServices } = await import('./service-manager.mjs');
|
|
1236
1236
|
const { homedir } = await import('node:os');
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
const homeDir = homedir();
|
|
1240
|
-
const composeRunner = detectDockerCompose();
|
|
1241
|
-
|
|
1237
|
+
|
|
1242
1238
|
const { results } = await startServices({
|
|
1243
1239
|
rootDir: target,
|
|
1244
|
-
homeDir,
|
|
1245
|
-
detectDockerComposeFn: () => composeRunner,
|
|
1240
|
+
homeDir: homedir(),
|
|
1246
1241
|
});
|
|
1247
1242
|
|
|
1248
1243
|
if (!quiet) {
|
package/lib/knowledge/rag.mjs
CHANGED
|
@@ -68,27 +68,47 @@ function loadObservationChunks(rootDir) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// The host project is the reserved origin: no target id, a `self` project key.
|
|
72
|
+
// Registered content targets carry their own origin (targetId, provider,
|
|
73
|
+
// projectKey, ref); the corpus builder stamps per-file `relPath` onto both.
|
|
74
|
+
|
|
75
|
+
const SELF_ORIGIN = Object.freeze({ targetId: null, provider: 'self', projectKey: 'self', ref: null });
|
|
76
|
+
|
|
77
|
+
function withOrigin(chunk, origin, relPath) {
|
|
78
|
+
return { ...chunk, origin: { ...origin, relPath: relPath ?? chunk.origin?.relPath ?? null } };
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
/**
|
|
72
|
-
* Load markdown files from a directory tree as indexable chunks.
|
|
82
|
+
* Load markdown files from a directory tree as indexable chunks. `origin`, when
|
|
83
|
+
* given, tags every chunk with its source project and per-file relative path so
|
|
84
|
+
* cross-project retrieval can attribute and filter the result; `baseDir`
|
|
85
|
+
* anchors the relative path (defaults to `dir`).
|
|
73
86
|
*/
|
|
74
|
-
function loadMarkdownChunks(dir, source) {
|
|
87
|
+
function loadMarkdownChunks(dir, source, { origin = null, baseDir = dir } = {}) {
|
|
75
88
|
if (!fs.existsSync(dir)) return [];
|
|
76
89
|
const chunks = [];
|
|
77
90
|
const walk = (d) => {
|
|
78
91
|
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
79
92
|
const full = path.join(d, entry.name);
|
|
80
|
-
if (entry.isDirectory()) {
|
|
93
|
+
if (entry.isDirectory()) {
|
|
94
|
+
if (entry.name === '.git' || entry.name === 'node_modules') continue;
|
|
95
|
+
walk(full);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
81
98
|
if (!entry.name.endsWith('.md')) continue;
|
|
82
99
|
try {
|
|
83
100
|
const content = fs.readFileSync(full, 'utf8');
|
|
84
101
|
const titleMatch = content.match(/^#\s+(.+)/m);
|
|
102
|
+
const relPath = path.relative(baseDir, full);
|
|
103
|
+
const idScope = origin?.targetId ? `${origin.targetId}:` : '';
|
|
85
104
|
chunks.push({
|
|
86
|
-
id: `${source}:${path.relative(process.cwd(), full)}`,
|
|
105
|
+
id: `${source}:${idScope}${path.relative(process.cwd(), full)}`,
|
|
87
106
|
source,
|
|
88
107
|
title: titleMatch ? titleMatch[1].trim() : entry.name,
|
|
89
108
|
body: content,
|
|
90
109
|
filePath: full,
|
|
91
110
|
createdAt: fs.statSync(full).mtime.toISOString(),
|
|
111
|
+
...(origin ? { origin: { ...origin, relPath } } : {}),
|
|
92
112
|
});
|
|
93
113
|
} catch { /* skip unreadable */ }
|
|
94
114
|
}
|
|
@@ -174,15 +194,33 @@ function loadKnowledgeChunks(rootDir) {
|
|
|
174
194
|
|
|
175
195
|
/**
|
|
176
196
|
* Build a full in-memory corpus from all sources.
|
|
177
|
-
* Each chunk gets an embedding vector for cosine scoring.
|
|
197
|
+
* Each chunk gets an embedding vector for cosine scoring and an `origin` tag.
|
|
198
|
+
*
|
|
199
|
+
* The single-`rootDir` signature is preserved for existing callers: passing a
|
|
200
|
+
* string (or nothing) indexes only the host project, whose chunks carry the
|
|
201
|
+
* reserved self origin. Passing `roots` — content-capable target roots resolved
|
|
202
|
+
* via lib/sources/content-roots.mjs — folds each registered project's markdown
|
|
203
|
+
* into the same corpus, every chunk attributed to its source project so
|
|
204
|
+
* retrieval can cite and filter across projects (bead construct-760c.2).
|
|
205
|
+
*
|
|
206
|
+
* @param {string} [rootDir]
|
|
207
|
+
* @param {object} [opts]
|
|
208
|
+
* @param {{dir: string, origin: object}[]} [opts.roots] — extra content roots
|
|
178
209
|
*/
|
|
179
|
-
export function buildCorpus(rootDir = process.cwd()) {
|
|
180
|
-
const
|
|
210
|
+
export function buildCorpus(rootDir = process.cwd(), { roots = [] } = {}) {
|
|
211
|
+
const hostChunks = [
|
|
181
212
|
...loadObservationChunks(rootDir),
|
|
182
213
|
...loadArtifactChunks(rootDir),
|
|
183
214
|
...loadSnapshotChunks(rootDir),
|
|
184
215
|
...loadKnowledgeChunks(rootDir),
|
|
185
|
-
];
|
|
216
|
+
].map((chunk) => withOrigin(chunk, SELF_ORIGIN, chunk.filePath ? path.relative(rootDir, chunk.filePath) : null));
|
|
217
|
+
|
|
218
|
+
const rootChunks = [];
|
|
219
|
+
for (const { dir, origin } of roots) {
|
|
220
|
+
rootChunks.push(...loadMarkdownChunks(dir, 'target', { origin, baseDir: dir }));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const chunks = [...hostChunks, ...rootChunks];
|
|
186
224
|
|
|
187
225
|
// Embed each chunk
|
|
188
226
|
return chunks.map((chunk) => ({
|
|
@@ -261,8 +299,11 @@ export function assembleContext(chunks) {
|
|
|
261
299
|
|
|
262
300
|
for (const chunk of chunks) {
|
|
263
301
|
const preview = chunk.body?.slice(0, CHUNK_PREVIEW) || '';
|
|
302
|
+
const projectKey = chunk.origin?.projectKey;
|
|
264
303
|
const meta = [
|
|
265
304
|
chunk.source && `source:${chunk.source}`,
|
|
305
|
+
projectKey && projectKey !== 'self' && `project:${projectKey}`,
|
|
306
|
+
chunk.origin?.relPath && `path:${chunk.origin.relPath}`,
|
|
266
307
|
chunk.role && `role:${chunk.role}`,
|
|
267
308
|
chunk.category && `category:${chunk.category}`,
|
|
268
309
|
chunk.createdAt && `date:${chunk.createdAt.slice(0, 10)}`,
|
|
@@ -302,7 +343,7 @@ export async function ask(question, { rootDir = process.cwd(), corpus, dryRun =
|
|
|
302
343
|
if (dryRun) {
|
|
303
344
|
return {
|
|
304
345
|
answer: null,
|
|
305
|
-
sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score })),
|
|
346
|
+
sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score, origin: c.origin ?? null })),
|
|
306
347
|
query: question,
|
|
307
348
|
};
|
|
308
349
|
}
|
|
@@ -333,7 +374,7 @@ export async function ask(question, { rootDir = process.cwd(), corpus, dryRun =
|
|
|
333
374
|
.join('\n\n');
|
|
334
375
|
return {
|
|
335
376
|
answer: `[Claude CLI unavailable — showing retrieved context]\n\n${fallback}`,
|
|
336
|
-
sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score })),
|
|
377
|
+
sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score, origin: c.origin ?? null })),
|
|
337
378
|
query: question,
|
|
338
379
|
cliMissing: true,
|
|
339
380
|
};
|
|
@@ -341,7 +382,7 @@ export async function ask(question, { rootDir = process.cwd(), corpus, dryRun =
|
|
|
341
382
|
|
|
342
383
|
return {
|
|
343
384
|
answer: (result.stdout || '').trim(),
|
|
344
|
-
sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score })),
|
|
385
|
+
sources: chunks.map((c) => ({ id: c.id, source: c.source, title: c.title, score: c.score, origin: c.origin ?? null })),
|
|
345
386
|
query: question,
|
|
346
387
|
};
|
|
347
388
|
}
|