@geraldmaron/construct 1.0.14 → 1.0.16
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 +21 -2
- package/bin/construct +146 -83
- package/commands/build/feature.md +0 -6
- package/commands/build/fix.md +0 -6
- package/commands/design/access.md +0 -6
- package/commands/design/flow.md +0 -6
- package/commands/design/ui.md +0 -6
- package/commands/measure/experiment.md +0 -6
- package/commands/measure/metrics.md +0 -6
- package/commands/measure/results.md +0 -6
- package/commands/plan/api.md +0 -6
- package/commands/plan/challenge.md +0 -6
- package/commands/plan/decide.md +0 -6
- package/commands/plan/feature.md +0 -7
- package/commands/plan/requirements.md +0 -6
- package/commands/remember/context.md +0 -6
- package/commands/remember/handoff.md +0 -6
- package/commands/remember/runbook.md +0 -6
- package/commands/review/code.md +0 -6
- package/commands/review/quality.md +0 -6
- package/commands/review/security.md +0 -6
- package/commands/ship/ready.md +0 -6
- package/commands/ship/release.md +0 -6
- package/commands/ship/status.md +0 -6
- package/commands/understand/docs.md +0 -6
- package/commands/understand/research.md +0 -6
- package/commands/understand/this.md +0 -6
- package/commands/understand/why.md +0 -6
- package/commands/work/clean.md +0 -6
- package/commands/work/drive.md +0 -6
- package/commands/work/optimize-prompts.md +0 -6
- package/commands/work/parallel-review.md +0 -6
- package/db/schema/010_cx_scores.sql +51 -0
- package/lib/beads-client.mjs +19 -1
- package/lib/cli-commands.mjs +280 -146
- package/lib/comment-lint.mjs +5 -1
- package/lib/context-state.mjs +15 -3
- package/lib/contracts/validate.mjs +72 -18
- package/lib/contracts/violation-log.mjs +169 -0
- package/lib/document-extract/docling-client.mjs +114 -0
- package/lib/document-extract/docling-sidecar.py +122 -0
- package/lib/document-extract/whisper-client.mjs +79 -0
- package/lib/document-extract.mjs +73 -8
- package/lib/document-ingest.mjs +26 -4
- package/lib/embed/daemon.mjs +6 -1
- package/lib/flavors/loader.mjs +1 -1
- package/lib/handoffs/contract.mjs +2 -2
- package/lib/hooks/session-start.mjs +1 -1
- package/lib/hooks/stop-notify.mjs +7 -0
- package/lib/ingest/chunker.mjs +94 -0
- package/lib/ingest/pipeline.mjs +53 -0
- package/lib/ingest/store.mjs +82 -0
- package/lib/install/first-invocation.mjs +5 -1
- package/lib/intake/prepare.mjs +22 -4
- package/lib/integrations/intake-integrations.mjs +28 -2
- package/lib/knowledge/search.mjs +12 -0
- package/lib/mcp/server.mjs +142 -1
- package/lib/mcp/tools/skills.mjs +6 -3
- package/lib/mcp/tools/telemetry.mjs +30 -0
- package/lib/mcp/tools/workflow.mjs +6 -2
- package/lib/observation-store.mjs +14 -5
- package/lib/ollama-manager.mjs +32 -28
- package/lib/orchestration-policy.mjs +15 -0
- package/lib/prompt-composer.js +11 -1
- package/lib/roles/gateway.mjs +30 -1
- package/lib/runtime/uv-bootstrap.mjs +129 -0
- package/lib/runtime/whisper-bootstrap.mjs +102 -0
- package/lib/scheduler/index.mjs +24 -4
- package/lib/server/index.mjs +22 -1
- package/lib/server/insights.mjs +12 -0
- package/lib/server/static/index.html +1 -1
- package/lib/setup.mjs +54 -9
- package/lib/specialists/postconditions.mjs +1 -1
- package/lib/sync/skill-frontmatter.mjs +113 -21
- package/lib/tracking-surfaces.mjs +2 -0
- package/lib/update.mjs +31 -3
- package/lib/upgrade.mjs +31 -4
- package/lib/validators/skills.mjs +86 -54
- package/package.json +5 -5
- package/personas/construct.md +6 -7
- package/platforms/claude/CLAUDE.md +43 -15
- package/platforms/claude/settings.template.json +1 -1
- package/rules/common/beads-hygiene.md +3 -9
- package/rules/common/code-review.md +3 -6
- package/rules/common/coding-style.md +3 -6
- package/rules/common/comments.md +3 -7
- package/rules/common/commit-approval.md +3 -6
- package/rules/common/cx-agent-routing.md +3 -7
- package/rules/common/cx-skill-routing.md +3 -3
- package/rules/common/doc-ownership.md +3 -8
- package/rules/common/efficiency.md +3 -8
- package/rules/common/framing.md +3 -8
- package/rules/common/git-workflow.md +3 -5
- package/rules/common/no-fabrication.md +4 -12
- package/rules/common/patterns.md +3 -5
- package/rules/common/release-gates.md +3 -8
- package/rules/common/research.md +3 -8
- package/rules/common/review-before-change.md +3 -9
- package/rules/common/security.md +3 -6
- package/rules/common/skill-composition.md +3 -7
- package/rules/common/testing.md +3 -6
- package/rules/golang/coding-style.md +1 -5
- package/rules/golang/hooks.md +1 -5
- package/rules/golang/patterns.md +1 -5
- package/rules/golang/security.md +1 -5
- package/rules/golang/testing.md +1 -5
- package/rules/python/coding-style.md +1 -5
- package/rules/python/hooks.md +1 -5
- package/rules/python/patterns.md +1 -5
- package/rules/python/security.md +1 -5
- package/rules/python/testing.md +1 -5
- package/rules/swift/coding-style.md +1 -5
- package/rules/swift/hooks.md +1 -5
- package/rules/swift/patterns.md +1 -5
- package/rules/swift/security.md +1 -5
- package/rules/swift/testing.md +1 -5
- package/rules/typescript/coding-style.md +1 -5
- package/rules/typescript/hooks.md +1 -5
- package/rules/typescript/patterns.md +1 -5
- package/rules/typescript/security.md +1 -5
- package/rules/typescript/testing.md +1 -5
- package/rules/web/coding-style.md +3 -5
- package/rules/web/design-quality.md +3 -5
- package/rules/web/hooks.md +3 -5
- package/rules/web/patterns.md +3 -5
- package/rules/web/performance.md +3 -5
- package/rules/web/security.md +3 -5
- package/rules/web/testing.md +3 -5
- package/scripts/sync-specialists.mjs +20 -2
- package/skills/ai/agent-dev.md +4 -5
- package/skills/ai/llm-security.md +4 -5
- package/skills/ai/ml-ops.md +4 -5
- package/skills/ai/orchestration-workflow.md +4 -5
- package/skills/ai/prompt-and-eval.md +4 -5
- package/skills/ai/prompt-optimizer.md +4 -6
- package/skills/ai/rag-system.md +4 -5
- package/skills/architecture/api-design.md +4 -5
- package/skills/architecture/caching.md +4 -5
- package/skills/architecture/cloud-native.md +4 -5
- package/skills/architecture/message-queue.md +4 -5
- package/skills/architecture/security-arch.md +4 -5
- package/skills/compliance/ai-disclosure.md +4 -5
- package/skills/compliance/data-privacy.md +4 -5
- package/skills/compliance/license-audit.md +4 -5
- package/skills/compliance/regulatory-review.md +4 -5
- package/skills/development/cpp.md +4 -5
- package/skills/development/go.md +4 -5
- package/skills/development/java.md +4 -5
- package/skills/development/kotlin.md +4 -5
- package/skills/development/mobile-crossplatform.md +4 -5
- package/skills/development/python.md +4 -5
- package/skills/development/rust.md +4 -5
- package/skills/development/shell.md +4 -5
- package/skills/development/swift.md +4 -5
- package/skills/development/typescript.md +4 -5
- package/skills/devops/ci-cd.md +4 -5
- package/skills/devops/containerization.md +4 -5
- package/skills/devops/cost-optimization.md +4 -5
- package/skills/devops/data-engineering.md +4 -5
- package/skills/devops/database.md +4 -5
- package/skills/devops/dependency-management.md +4 -5
- package/skills/devops/devsecops.md +4 -5
- package/skills/devops/git-workflow.md +4 -5
- package/skills/devops/incident-response.md +4 -5
- package/skills/devops/monorepo.md +4 -5
- package/skills/devops/observability.md +4 -5
- package/skills/devops/performance.md +4 -5
- package/skills/devops/testing.md +4 -5
- package/skills/docs/adr-workflow.md +4 -7
- package/skills/docs/backlog-proposal-workflow.md +4 -3
- package/skills/docs/customer-profile-workflow.md +4 -3
- package/skills/docs/document-ingest-workflow.md +4 -3
- package/skills/docs/evidence-ingest-workflow.md +4 -3
- package/skills/docs/init-docs.md +4 -5
- package/skills/docs/init-project.md +4 -5
- package/skills/docs/prd-workflow.md +4 -7
- package/skills/docs/prfaq-workflow.md +4 -3
- package/skills/docs/product-intelligence-review.md +4 -3
- package/skills/docs/product-intelligence-workflow.md +4 -6
- package/skills/docs/product-signal-workflow.md +4 -5
- package/skills/docs/research-workflow.md +4 -5
- package/skills/docs/runbook-workflow.md +4 -5
- package/skills/docs/strategy-workflow.md +4 -5
- package/skills/exploration/dependency-graph-reading.md +4 -7
- package/skills/exploration/repo-map.md +4 -5
- package/skills/exploration/tracer-bullet-method.md +4 -7
- package/skills/exploration/unknown-codebase-onboarding.md +4 -7
- package/skills/frameworks/django.md +4 -5
- package/skills/frameworks/nextjs.md +4 -5
- package/skills/frameworks/react.md +4 -5
- package/skills/frameworks/spring-boot.md +4 -5
- package/skills/frontend-design/accessibility.md +4 -5
- package/skills/frontend-design/component-patterns.md +4 -5
- package/skills/frontend-design/engineering.md +4 -5
- package/skills/frontend-design/state-management.md +4 -5
- package/skills/frontend-design/ui-aesthetics.md +4 -5
- package/skills/frontend-design/ux-principles.md +4 -5
- package/skills/operating/change-management.md +4 -8
- package/skills/operating/incident-response.md +4 -8
- package/skills/operating/oncall-rotation.md +4 -7
- package/skills/operating/orchestration-reference.md +4 -10
- package/skills/quality-gates/review-work.md +4 -5
- package/skills/quality-gates/verify-change.md +4 -5
- package/skills/quality-gates/verify-module.md +4 -5
- package/skills/quality-gates/verify-quality.md +4 -5
- package/skills/quality-gates/verify-security.md +4 -5
- package/skills/roles/architect.ai-systems.md +6 -9
- package/skills/roles/architect.data.md +6 -9
- package/skills/roles/architect.enterprise.md +6 -9
- package/skills/roles/architect.integration.md +6 -9
- package/skills/roles/architect.md +7 -14
- package/skills/roles/architect.platform.md +6 -9
- package/skills/roles/data-analyst.experiment.md +6 -9
- package/skills/roles/data-analyst.md +6 -9
- package/skills/roles/data-analyst.product-intelligence.md +7 -9
- package/skills/roles/data-analyst.product.md +6 -9
- package/skills/roles/data-analyst.telemetry.md +7 -9
- package/skills/roles/data-engineer.pipeline.md +6 -9
- package/skills/roles/data-engineer.vector-retrieval.md +7 -9
- package/skills/roles/data-engineer.warehouse.md +6 -9
- package/skills/roles/debugger.md +6 -9
- package/skills/roles/designer.accessibility.md +6 -9
- package/skills/roles/designer.md +7 -9
- package/skills/roles/engineer.ai.md +6 -9
- package/skills/roles/engineer.data.md +6 -9
- package/skills/roles/engineer.md +9 -9
- package/skills/roles/engineer.platform.md +6 -9
- package/skills/roles/operator.docs.md +6 -9
- package/skills/roles/operator.md +9 -9
- package/skills/roles/operator.release.md +6 -9
- package/skills/roles/operator.sre.md +6 -9
- package/skills/roles/orchestrator.md +6 -9
- package/skills/roles/product-manager.ai-product.md +6 -9
- package/skills/roles/product-manager.business-strategy.md +6 -9
- package/skills/roles/product-manager.enterprise.md +6 -9
- package/skills/roles/product-manager.growth.md +7 -9
- package/skills/roles/product-manager.md +7 -9
- package/skills/roles/product-manager.platform.md +6 -9
- package/skills/roles/product-manager.product.md +6 -9
- package/skills/roles/qa.ai-eval.md +8 -9
- package/skills/roles/qa.api-contract.md +7 -9
- package/skills/roles/qa.data-pipeline.md +7 -9
- package/skills/roles/qa.md +7 -9
- package/skills/roles/qa.test-automation.md +6 -9
- package/skills/roles/qa.web-ui.md +7 -9
- package/skills/roles/researcher.explorer.md +6 -9
- package/skills/roles/researcher.md +8 -9
- package/skills/roles/researcher.ux.md +6 -9
- package/skills/roles/reviewer.devil-advocate.md +6 -9
- package/skills/roles/reviewer.evaluator.md +6 -9
- package/skills/roles/reviewer.md +9 -9
- package/skills/roles/reviewer.trace.md +6 -9
- package/skills/roles/security.ai.md +7 -9
- package/skills/roles/security.appsec.md +6 -9
- package/skills/roles/security.cloud.md +6 -9
- package/skills/roles/security.legal-compliance.md +6 -9
- package/skills/roles/security.md +7 -9
- package/skills/roles/security.privacy.md +7 -9
- package/skills/roles/security.supply-chain.md +7 -9
- package/skills/security/blue-team.md +4 -5
- package/skills/security/code-audit.md +4 -5
- package/skills/security/pentest.md +4 -5
- package/skills/security/red-team.md +4 -5
- package/skills/security/threat-intel.md +4 -5
- package/skills/security/vuln-research.md +4 -5
- package/skills/strategy/competitive-landscape.md +4 -8
- package/skills/strategy/market-research-methods.md +4 -8
- package/skills/strategy/narrative-arc.md +4 -8
- package/skills/strategy/pricing-positioning.md +4 -8
- package/skills/utility/clean-code.md +4 -5
- package/specialists/policy-inventory.json +14 -0
- package/lib/specialist-contracts-enforce.mjs +0 -172
- package/rules/common/agents.md +0 -28
- package/rules/common/development-workflow.md +0 -32
- package/rules/common/performance.md +0 -55
package/lib/document-extract.mjs
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/document-extract.mjs — shared local document extraction for retrieval and MCP reads.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Two extraction paths:
|
|
5
|
+
* - Async (preferred): `extractDocumentTextAsync` routes PDF/Office/HTML
|
|
6
|
+
* through the docling Python sidecar and audio/video through whisper.cpp.
|
|
7
|
+
* Returns { text, markdown, metadata, droppedInfo, ... }.
|
|
8
|
+
* - Sync (legacy): `extractDocumentText` keeps regex/CLI extractors.
|
|
9
|
+
* PDF/Office output is lower fidelity (tables flattened, formulas
|
|
10
|
+
* dropped, scanned pages return empty). Audio/video throws
|
|
11
|
+
* { code: 'ASR_REQUIRED' } on this path.
|
|
8
12
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
+
* `extractDocumentMetadata` returns frontmatter + title/authors/dates.
|
|
14
|
+
*
|
|
15
|
+
* All paths return droppedInfo as { kind, count, reason, recoverable }[] so
|
|
16
|
+
* info loss is observable rather than silent.
|
|
13
17
|
*/
|
|
14
18
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
15
19
|
import { basename, extname, resolve } from 'node:path';
|
|
@@ -689,3 +693,64 @@ export function extractDocumentText(filePath, { maxChars = null } = {}) {
|
|
|
689
693
|
if (extracted.skipped) result.skipped = extracted.skipped;
|
|
690
694
|
return result;
|
|
691
695
|
}
|
|
696
|
+
|
|
697
|
+
// Async high-fidelity extraction path. Routes PDF/Office/HTML through the
|
|
698
|
+
// docling Python sidecar (layout-aware markdown) and audio/video through
|
|
699
|
+
// whisper.cpp (Metal-accelerated on macOS). Text/transcript/calendar/email
|
|
700
|
+
// paths reuse the sync extraction since regex-stripping is the right tool
|
|
701
|
+
// for those formats. Returns the same { text, droppedInfo, ... } shape as
|
|
702
|
+
// the sync extractor plus a `markdown` field for paths that produce it.
|
|
703
|
+
//
|
|
704
|
+
// 2026-06 best practice: this is the path new callers should use. The sync
|
|
705
|
+
// extractDocumentText is preserved for backwards compatibility with
|
|
706
|
+
// pre-async callers; PDF/Office output via that path is lower fidelity
|
|
707
|
+
// (tables flattened, formulas dropped) and emits a droppedInfo notice.
|
|
708
|
+
|
|
709
|
+
export async function extractDocumentTextAsync(filePath, { maxChars = null } = {}) {
|
|
710
|
+
const resolvedPath = resolve(filePath);
|
|
711
|
+
if (!existsSync(resolvedPath)) throw new Error(`File not found: ${resolvedPath}`);
|
|
712
|
+
const extension = extname(resolvedPath).toLowerCase();
|
|
713
|
+
if (!EXTRACTABLE_DOCUMENT_EXTS.has(extension)) {
|
|
714
|
+
throw new Error(`Unsupported document type: ${extension || 'unknown'}`);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (AUDIO_VIDEO_EXTS.has(extension)) {
|
|
718
|
+
const { extractViaWhisper } = await import('./document-extract/whisper-client.mjs');
|
|
719
|
+
const out = await extractViaWhisper(resolvedPath);
|
|
720
|
+
return finalizeAsyncResult({ resolvedPath, extension, extractionMethod: 'whisper', extracted: out, maxChars });
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const doclingFormats = new Set([
|
|
724
|
+
'.pdf',
|
|
725
|
+
...ZIP_DOCUMENT_EXTS,
|
|
726
|
+
...RICH_TEXT_EXTS,
|
|
727
|
+
...MDLS_DOCUMENT_EXTS,
|
|
728
|
+
]);
|
|
729
|
+
if (doclingFormats.has(extension)) {
|
|
730
|
+
const { extractViaDocling } = await import('./document-extract/docling-client.mjs');
|
|
731
|
+
const out = await extractViaDocling(resolvedPath);
|
|
732
|
+
return finalizeAsyncResult({ resolvedPath, extension, extractionMethod: 'docling', extracted: out, maxChars });
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return extractDocumentText(resolvedPath, { maxChars });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function finalizeAsyncResult({ resolvedPath, extension, extractionMethod, extracted, maxChars }) {
|
|
739
|
+
const text = extracted.markdown ?? extracted.text ?? '';
|
|
740
|
+
const limit = Number.isFinite(Number(maxChars)) && Number(maxChars) > 0
|
|
741
|
+
? Math.min(Number(maxChars), 200_000)
|
|
742
|
+
: null;
|
|
743
|
+
const truncated = limit !== null && text.length > limit;
|
|
744
|
+
return {
|
|
745
|
+
filePath: resolvedPath,
|
|
746
|
+
extension,
|
|
747
|
+
extractionMethod,
|
|
748
|
+
text: truncated ? `${text.slice(0, limit)}\n` : text,
|
|
749
|
+
markdown: extracted.markdown ?? null,
|
|
750
|
+
metadata: extracted.metadata ?? null,
|
|
751
|
+
truncated,
|
|
752
|
+
characters: text.length,
|
|
753
|
+
droppedInfo: extracted.droppedInfo ?? [],
|
|
754
|
+
structured: extracted.structured ?? null,
|
|
755
|
+
};
|
|
756
|
+
}
|
package/lib/document-ingest.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve } from 'node:path';
|
|
10
|
-
import { extractDocumentText, extractDocumentMetadata, isExtractableDocumentPath } from './document-extract.mjs';
|
|
10
|
+
import { extractDocumentText, extractDocumentTextAsync, extractDocumentMetadata, isExtractableDocumentPath } from './document-extract.mjs';
|
|
11
11
|
import { syncFileStateToSql } from './storage/sync.mjs';
|
|
12
12
|
import { stampFrontmatter } from './doc-stamp.mjs';
|
|
13
13
|
import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS } from './knowledge/layout.mjs';
|
|
@@ -127,6 +127,8 @@ export async function ingestDocuments(inputPaths, {
|
|
|
127
127
|
outputDir = null,
|
|
128
128
|
target = 'knowledge/internal',
|
|
129
129
|
sync = false,
|
|
130
|
+
strict = false,
|
|
131
|
+
highFidelity = true,
|
|
130
132
|
env = process.env,
|
|
131
133
|
} = {}) {
|
|
132
134
|
if (!Array.isArray(inputPaths) || inputPaths.length === 0) {
|
|
@@ -142,8 +144,12 @@ export async function ingestDocuments(inputPaths, {
|
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
const results = [];
|
|
147
|
+
let totalDrops = 0;
|
|
145
148
|
for (const sourcePath of files) {
|
|
146
|
-
const extracted =
|
|
149
|
+
const extracted = highFidelity
|
|
150
|
+
? await extractDocumentTextAsync(sourcePath, { maxChars: 200_000 })
|
|
151
|
+
: extractDocumentText(sourcePath, { maxChars: 200_000 });
|
|
152
|
+
totalDrops += (extracted.droppedInfo ?? []).reduce((a, d) => a + (Number(d.count) || 1), 0);
|
|
147
153
|
const metadata = extractDocumentMetadata(sourcePath);
|
|
148
154
|
const targetPath = resolveOutputPath(sourcePath, { cwd, outputPath, outputDir, target });
|
|
149
155
|
ensureDir(dirname(targetPath));
|
|
@@ -184,6 +190,13 @@ export async function ingestDocuments(inputPaths, {
|
|
|
184
190
|
});
|
|
185
191
|
}
|
|
186
192
|
|
|
193
|
+
if (strict && totalDrops > 0) {
|
|
194
|
+
const err = new Error(`--strict: ${totalDrops} dropped items across ${results.filter(r => r.droppedInfo.length).length} files`);
|
|
195
|
+
err.code = 'INGEST_DROPS';
|
|
196
|
+
err.droppedInfo = results.flatMap((r) => r.droppedInfo.map((d) => ({ ...d, sourcePath: r.sourcePath })));
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
|
|
187
200
|
return {
|
|
188
201
|
status: 'ok',
|
|
189
202
|
target: outputPath ? 'custom' : target,
|
|
@@ -197,6 +210,9 @@ export async function ingestDocuments(inputPaths, {
|
|
|
197
210
|
indexedLocally: true,
|
|
198
211
|
storageSync: syncResult,
|
|
199
212
|
files: results,
|
|
213
|
+
droppedInfo: results.flatMap((r) => r.droppedInfo.map((d) => ({ ...d, sourcePath: r.sourcePath }))),
|
|
214
|
+
droppedCount: totalDrops,
|
|
215
|
+
highFidelity,
|
|
200
216
|
};
|
|
201
217
|
}
|
|
202
218
|
|
|
@@ -206,19 +222,25 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
206
222
|
let outputDir = null;
|
|
207
223
|
let target = 'knowledge/internal';
|
|
208
224
|
let sync = false;
|
|
225
|
+
let strict = false;
|
|
226
|
+
let highFidelity = true;
|
|
209
227
|
|
|
210
228
|
for (const arg of argv) {
|
|
211
229
|
if (arg.startsWith('--out=')) outputPath = arg.split('=').slice(1).join('=');
|
|
212
230
|
else if (arg.startsWith('--out-dir=')) outputDir = arg.split('=').slice(1).join('=');
|
|
213
231
|
else if (arg.startsWith('--target=')) target = arg.split('=').slice(1).join('=');
|
|
214
232
|
else if (arg === '--sync') sync = true;
|
|
233
|
+
else if (arg === '--strict') strict = true;
|
|
234
|
+
else if (arg === '--legacy-extractor') highFidelity = false;
|
|
215
235
|
else inputs.push(arg);
|
|
216
236
|
}
|
|
217
237
|
|
|
218
238
|
if (inputs.length === 0) {
|
|
219
239
|
throw new Error(
|
|
220
240
|
`Usage: construct ingest <file-or-dir> [more paths] [--out=FILE] [--out-dir=DIR] ` +
|
|
221
|
-
`[--target=sibling|knowledge/<subdir>] [--sync]\n` +
|
|
241
|
+
`[--target=sibling|knowledge/<subdir>] [--sync] [--strict] [--legacy-extractor]\n` +
|
|
242
|
+
` --strict: exit non-zero if any extraction drops occur\n` +
|
|
243
|
+
` --legacy-extractor: use the pre-docling regex extractor (lower fidelity)\n` +
|
|
222
244
|
` knowledge subdirs: ${KNOWLEDGE_SUBDIRS.join(', ')}`,
|
|
223
245
|
);
|
|
224
246
|
}
|
|
@@ -229,5 +251,5 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
|
|
|
229
251
|
);
|
|
230
252
|
}
|
|
231
253
|
|
|
232
|
-
return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, env });
|
|
254
|
+
return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, env });
|
|
233
255
|
}
|
package/lib/embed/daemon.mjs
CHANGED
|
@@ -1095,7 +1095,12 @@ this.#scheduler.register(
|
|
|
1095
1095
|
outputTokens = data.usage?.completion_tokens || 0;
|
|
1096
1096
|
}
|
|
1097
1097
|
}
|
|
1098
|
-
} catch {
|
|
1098
|
+
} catch (probeErr) {
|
|
1099
|
+
// Probe call failed; record the timing entry but surface the
|
|
1100
|
+
// failure to the daemon log so operators can distinguish a 0ms
|
|
1101
|
+
// success from a hard error. Don't crash the daemon.
|
|
1102
|
+
process.stderr.write(`[embed] Probe call failed: ${probeErr.message}\n`);
|
|
1103
|
+
}
|
|
1099
1104
|
|
|
1100
1105
|
const endTime = Date.now();
|
|
1101
1106
|
|
package/lib/flavors/loader.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - validateFlavor(path) — frontmatter conformance check against the schema
|
|
7
7
|
* - perRoleFlavorCount(profile) — flavor counts per role for cap enforcement
|
|
8
8
|
*
|
|
9
|
-
* The cap is enforced at sync time and in lint-
|
|
9
|
+
* The cap is enforced at sync time and in lint-profiles.
|
|
10
10
|
* Six flavors per role per profile is the current ceiling; bumping requires
|
|
11
11
|
* an ADR. Existing roles like qa already have 6, so the cap is set tight.
|
|
12
12
|
*/
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
*
|
|
39
39
|
* `parseHandoff` is intentionally permissive — pre-contract handoffs with
|
|
40
40
|
* no frontmatter still parse with status='legacy' so they aren't lost.
|
|
41
|
-
* `
|
|
41
|
+
* `validateHandoffFile` is the strict version, used when writing.
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
44
|
import { readFileSync } from 'node:fs';
|
|
@@ -109,7 +109,7 @@ export function parseHandoffFile(filePath) {
|
|
|
109
109
|
return parseHandoff(text);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
export function
|
|
112
|
+
export function validateHandoffFile(parsed) {
|
|
113
113
|
const errors = [];
|
|
114
114
|
const fm = parsed?.frontmatter || {};
|
|
115
115
|
if (fm.schema !== HANDOFF_SCHEMA_VERSION) errors.push(`schema must be '${HANDOFF_SCHEMA_VERSION}'`);
|
|
@@ -271,7 +271,7 @@ try {
|
|
|
271
271
|
if (hasSources) {
|
|
272
272
|
const parts = [];
|
|
273
273
|
if (repos.length > 0) parts.push(`GitHub repos: ${repos.join(', ')}`);
|
|
274
|
-
if (jiraConfigured) parts.push(
|
|
274
|
+
if (jiraConfigured) parts.push('Jira: configured');
|
|
275
275
|
if (linearConfigured) parts.push('Linear: configured');
|
|
276
276
|
// Surface which sources are wired so `provider_fetch` is the obvious
|
|
277
277
|
// tool for questions about them. The "fetch first, don't answer from
|
|
@@ -26,6 +26,7 @@ import { listSessions, loadSession, updateSession } from '../session-store.mjs';
|
|
|
26
26
|
import { captureSessionArtifacts } from '../artifact-capture.mjs';
|
|
27
27
|
import { appendSessionStats } from '../memory-stats.mjs';
|
|
28
28
|
import { estimateUsageCost } from '../telemetry/model-pricing-catalog.mjs';
|
|
29
|
+
import { flushReadTrackerDeltas } from '../read-tracker-store.mjs';
|
|
29
30
|
|
|
30
31
|
function loadTranscriptCheckpoints(checkpointPath) {
|
|
31
32
|
try {
|
|
@@ -140,6 +141,12 @@ let raw = '';
|
|
|
140
141
|
try { raw = readFileSync(0, 'utf8'); } catch { /* non-critical */ }
|
|
141
142
|
if (raw) process.stdout.write(raw);
|
|
142
143
|
|
|
144
|
+
// PostToolUse Read hooks append delta lines instead of rewriting the full
|
|
145
|
+
// read-tracker JSON on every call. Stop fires once per session end; this
|
|
146
|
+
// is the canonical point to fold accumulated deltas back into the JSON.
|
|
147
|
+
// Already wired into pre-compact + audit-reads; Stop is the third anchor.
|
|
148
|
+
try { flushReadTrackerDeltas({ env: process.env }); } catch { /* non-critical */ }
|
|
149
|
+
|
|
143
150
|
const home = homedir();
|
|
144
151
|
const tsResultPath = join(home, '.cx', 'ts-result.txt');
|
|
145
152
|
const warnFlagsPath = join(home, '.cx', 'warn-flags.txt');
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/ingest/chunker.mjs — paragraph-boundary chunker with sentence overlap.
|
|
3
|
+
*
|
|
4
|
+
* 2026-06 best practice notes:
|
|
5
|
+
* - Recursive/sentence chunking remains the high-recall default (~85-90%
|
|
6
|
+
* on 2026 RAG benchmarks). Semantic chunking gains only ~2-3% recall at
|
|
7
|
+
* ~14× the embedding cost and only matters above ~5k token docs.
|
|
8
|
+
* - Parent-document retrieval is the meaningful 2026 upgrade beyond
|
|
9
|
+
* paragraph chunking — track separately if recall plateaus.
|
|
10
|
+
*
|
|
11
|
+
* Strategy:
|
|
12
|
+
* 1. Split markdown by blank-line paragraph boundaries.
|
|
13
|
+
* 2. Pack paragraphs into chunks under maxChars (default 1500 ≈ 375 tokens).
|
|
14
|
+
* 3. Add 1-2 sentence overlap between consecutive chunks to preserve
|
|
15
|
+
* cross-chunk anchors.
|
|
16
|
+
* 4. Preserve markdown structure: never split inside a fenced code block.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const DEFAULT_MAX_CHARS = 1500;
|
|
20
|
+
const DEFAULT_OVERLAP_SENTENCES = 2;
|
|
21
|
+
const SENTENCE_RE = /[^.!?]+[.!?]+["')\]]*\s*/g;
|
|
22
|
+
|
|
23
|
+
export function splitSentences(text) {
|
|
24
|
+
const matches = text.match(SENTENCE_RE);
|
|
25
|
+
if (matches && matches.length) return matches.map((s) => s.trim()).filter(Boolean);
|
|
26
|
+
return text.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function tailSentences(text, count) {
|
|
30
|
+
const sentences = splitSentences(text);
|
|
31
|
+
return sentences.slice(-count).join(' ');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function splitParagraphs(markdown) {
|
|
35
|
+
const blocks = [];
|
|
36
|
+
let buffer = '';
|
|
37
|
+
let inFence = false;
|
|
38
|
+
for (const line of markdown.split('\n')) {
|
|
39
|
+
if (/^```/.test(line.trim())) inFence = !inFence;
|
|
40
|
+
if (!inFence && line.trim() === '' && buffer.trim()) {
|
|
41
|
+
blocks.push(buffer.trim());
|
|
42
|
+
buffer = '';
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
buffer += line + '\n';
|
|
46
|
+
}
|
|
47
|
+
if (buffer.trim()) blocks.push(buffer.trim());
|
|
48
|
+
return blocks;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function chunkMarkdown(markdown, { maxChars = DEFAULT_MAX_CHARS, overlapSentences = DEFAULT_OVERLAP_SENTENCES } = {}) {
|
|
52
|
+
const paragraphs = splitParagraphs(markdown || '');
|
|
53
|
+
if (paragraphs.length === 0) return [];
|
|
54
|
+
|
|
55
|
+
const chunks = [];
|
|
56
|
+
let current = '';
|
|
57
|
+
let chunkIndex = 0;
|
|
58
|
+
const flushChunk = () => {
|
|
59
|
+
if (!current.trim()) return;
|
|
60
|
+
const text = current.trim();
|
|
61
|
+
chunks.push({
|
|
62
|
+
index: chunkIndex++,
|
|
63
|
+
text,
|
|
64
|
+
chars: text.length,
|
|
65
|
+
});
|
|
66
|
+
const overlap = overlapSentences > 0 ? tailSentences(text, overlapSentences) : '';
|
|
67
|
+
current = overlap ? overlap + '\n\n' : '';
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
for (const para of paragraphs) {
|
|
71
|
+
if (current.length + para.length + 2 > maxChars && current.trim()) {
|
|
72
|
+
flushChunk();
|
|
73
|
+
}
|
|
74
|
+
if (para.length > maxChars) {
|
|
75
|
+
flushChunk();
|
|
76
|
+
const sentences = splitSentences(para);
|
|
77
|
+
let buf = '';
|
|
78
|
+
for (const s of sentences) {
|
|
79
|
+
if (buf.length + s.length + 1 > maxChars && buf.trim()) {
|
|
80
|
+
chunks.push({ index: chunkIndex++, text: buf.trim(), chars: buf.trim().length });
|
|
81
|
+
buf = (overlapSentences > 0 ? tailSentences(buf, overlapSentences) : '') + (overlapSentences > 0 ? ' ' : '');
|
|
82
|
+
}
|
|
83
|
+
buf += s + ' ';
|
|
84
|
+
}
|
|
85
|
+
if (buf.trim()) {
|
|
86
|
+
current = buf.trim() + '\n\n';
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
current += (current ? '\n\n' : '') + para;
|
|
91
|
+
}
|
|
92
|
+
flushChunk();
|
|
93
|
+
return chunks;
|
|
94
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/ingest/pipeline.mjs — orchestrate the content-hashed ingest pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Flow: hash → de-dup → extract via docling/whisper → chunk → write to
|
|
5
|
+
* .cx/ingest/<sha>/. Returns a record describing what was stored along
|
|
6
|
+
* with any droppedInfo surfaced by the extractor.
|
|
7
|
+
*
|
|
8
|
+
* Idempotent: re-ingesting the same content (by sha256) returns the
|
|
9
|
+
* existing record without re-extracting.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { extractDocumentTextAsync } from '../document-extract.mjs';
|
|
14
|
+
import { hashFile, defaultIngestRoot, readRecord, writeRecord } from './store.mjs';
|
|
15
|
+
import { chunkMarkdown } from './chunker.mjs';
|
|
16
|
+
|
|
17
|
+
export async function ingestFile(filePath, { cwd = process.cwd(), force = false } = {}) {
|
|
18
|
+
const absPath = path.resolve(filePath);
|
|
19
|
+
const stat = await fs.stat(absPath);
|
|
20
|
+
if (!stat.isFile()) throw new Error(`not a regular file: ${absPath}`);
|
|
21
|
+
|
|
22
|
+
const root = defaultIngestRoot(cwd);
|
|
23
|
+
const sha256 = await hashFile(absPath);
|
|
24
|
+
|
|
25
|
+
if (!force) {
|
|
26
|
+
const existing = await readRecord(root, sha256);
|
|
27
|
+
if (existing) return { ...existing, status: 'cached' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const extracted = await extractDocumentTextAsync(absPath);
|
|
31
|
+
const markdown = extracted.markdown ?? extracted.text ?? '';
|
|
32
|
+
const chunks = chunkMarkdown(markdown);
|
|
33
|
+
|
|
34
|
+
const source = {
|
|
35
|
+
sourcePath: absPath,
|
|
36
|
+
fileName: path.basename(absPath),
|
|
37
|
+
extension: extracted.extension,
|
|
38
|
+
bytes: stat.size,
|
|
39
|
+
sha256,
|
|
40
|
+
ingestedAt: new Date().toISOString(),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const meta = {
|
|
44
|
+
extractionMethod: extracted.extractionMethod,
|
|
45
|
+
extractorMetadata: extracted.metadata ?? null,
|
|
46
|
+
droppedInfo: extracted.droppedInfo ?? [],
|
|
47
|
+
chunkCount: chunks.length,
|
|
48
|
+
chunkChars: chunks.reduce((a, c) => a + c.chars, 0),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const record = await writeRecord(root, { sha256, source, meta, markdown });
|
|
52
|
+
return { ...record, status: 'ingested', droppedInfo: meta.droppedInfo, chunks };
|
|
53
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/ingest/store.mjs — content-addressed ingest store at .cx/ingest/<hash>/.
|
|
3
|
+
*
|
|
4
|
+
* Each ingested document is stored under its SHA-256 content hash:
|
|
5
|
+
* .cx/ingest/<sha256>/source.json — original path, size, sha256, ingestedAt
|
|
6
|
+
* .cx/ingest/<sha256>/markdown.md — extracted markdown body
|
|
7
|
+
* .cx/ingest/<sha256>/meta.json — extractor metadata, droppedInfo, chunks
|
|
8
|
+
*
|
|
9
|
+
* Idempotent: re-ingesting a file with the same content is a no-op (returns
|
|
10
|
+
* the existing record). Re-running ingestion is the safe default.
|
|
11
|
+
*
|
|
12
|
+
* 2026-06 best practice notes:
|
|
13
|
+
* - SHA-256 is still the universal content-addressing primitive. BLAKE3 is
|
|
14
|
+
* ~2.3× faster on M-series and worth considering at multi-GB scale,
|
|
15
|
+
* but is not the industry default and adds an unfamiliar dependency.
|
|
16
|
+
* Revisit if ingest throughput becomes a measured bottleneck.
|
|
17
|
+
* - Storing markdown body separately from metadata keeps the embedding
|
|
18
|
+
* and search paths simple: they only read markdown.md and look up
|
|
19
|
+
* provenance from meta.json on demand.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { createReadStream } from 'node:fs';
|
|
23
|
+
import fs from 'node:fs/promises';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
export function defaultIngestRoot(cwd = process.cwd()) {
|
|
27
|
+
return path.join(cwd, '.cx', 'ingest');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function hashFile(filePath) {
|
|
31
|
+
return new Promise((resolveHash, reject) => {
|
|
32
|
+
const hash = createHash('sha256');
|
|
33
|
+
const stream = createReadStream(filePath);
|
|
34
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
35
|
+
stream.on('error', reject);
|
|
36
|
+
stream.on('end', () => resolveHash(hash.digest('hex')));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function recordDirFor(root, sha256) {
|
|
41
|
+
return path.join(root, sha256);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function readRecord(root, sha256) {
|
|
45
|
+
const dir = recordDirFor(root, sha256);
|
|
46
|
+
try {
|
|
47
|
+
const [source, meta, markdown] = await Promise.all([
|
|
48
|
+
fs.readFile(path.join(dir, 'source.json'), 'utf8').then(JSON.parse),
|
|
49
|
+
fs.readFile(path.join(dir, 'meta.json'), 'utf8').then(JSON.parse),
|
|
50
|
+
fs.readFile(path.join(dir, 'markdown.md'), 'utf8'),
|
|
51
|
+
]);
|
|
52
|
+
return { sha256, source, meta, markdown, dir };
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function writeRecord(root, { sha256, source, meta, markdown }) {
|
|
59
|
+
const dir = recordDirFor(root, sha256);
|
|
60
|
+
await fs.mkdir(dir, { recursive: true });
|
|
61
|
+
await Promise.all([
|
|
62
|
+
fs.writeFile(path.join(dir, 'source.json'), JSON.stringify(source, null, 2) + '\n', 'utf8'),
|
|
63
|
+
fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8'),
|
|
64
|
+
fs.writeFile(path.join(dir, 'markdown.md'), markdown, 'utf8'),
|
|
65
|
+
]);
|
|
66
|
+
return { sha256, dir, source, meta, markdown };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function listRecords(root) {
|
|
70
|
+
let entries;
|
|
71
|
+
try { entries = await fs.readdir(root, { withFileTypes: true }); }
|
|
72
|
+
catch { return []; }
|
|
73
|
+
const records = [];
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (!entry.isDirectory()) continue;
|
|
76
|
+
const sha = entry.name;
|
|
77
|
+
if (!/^[a-f0-9]{64}$/.test(sha)) continue;
|
|
78
|
+
const record = await readRecord(root, sha);
|
|
79
|
+
if (record) records.push(record);
|
|
80
|
+
}
|
|
81
|
+
return records;
|
|
82
|
+
}
|
|
@@ -24,8 +24,12 @@ import { parseEnvFile, writeEnvValues, getUserEnvPath } from '../env-config.mjs'
|
|
|
24
24
|
import { probeAll, formatProbe } from '../bootstrap/resources.mjs';
|
|
25
25
|
|
|
26
26
|
const BOOTSTRAP_CHECKED_KEY = 'BOOTSTRAP_CHECKED';
|
|
27
|
+
// 'install' is the actual handler name in bin/construct's dispatch map;
|
|
28
|
+
// 'setup' is kept for backwards-compat in case anything still routes via
|
|
29
|
+
// that key. Without 'install' the probe will re-prompt the user while
|
|
30
|
+
// they're already running the very command meant to satisfy it.
|
|
27
31
|
const SKIP_COMMANDS = new Set([
|
|
28
|
-
'hook', 'setup', 'uninstall', 'version', 'help', 'completions', 'doctor',
|
|
32
|
+
'hook', 'install', 'setup', 'uninstall', 'version', 'help', 'completions', 'doctor', 'upgrade', 'update',
|
|
29
33
|
]);
|
|
30
34
|
|
|
31
35
|
export function shouldSkipProbe({ command, env = process.env, stdin = process.stdin } = {}) {
|
package/lib/intake/prepare.mjs
CHANGED
|
@@ -32,12 +32,13 @@ import path from 'node:path';
|
|
|
32
32
|
import { buildHybridSearchResultsAsync } from '../storage/hybrid-query.mjs';
|
|
33
33
|
import { suggestDocsLaneForFile } from '../docs-routing.mjs';
|
|
34
34
|
import { createIntakeQueue } from './queue.mjs';
|
|
35
|
-
import { classifyRdIntake } from './classify.mjs';
|
|
35
|
+
import { classifyRdIntake, suggestTags } from './classify.mjs';
|
|
36
36
|
import { detectCustomerMentions, linkSignalToCustomer, updateCustomerProfile } from '../embed/customer-profiles.mjs';
|
|
37
37
|
import { resolveActiveProfile } from '../profiles/loader.mjs';
|
|
38
38
|
import { emitBestEffort as emitRoleEvent } from '../roles/event-bus.mjs';
|
|
39
39
|
import { gatherAttribution, stampAttribution } from './attribution.mjs';
|
|
40
40
|
import { MANIFEST_REL_PATH } from './manifest.mjs';
|
|
41
|
+
import { loadVocabulary } from '../tags/vocabulary.mjs';
|
|
41
42
|
|
|
42
43
|
const DEFAULT_RELATED_LIMIT = 5;
|
|
43
44
|
const EXCERPT_CHARS = 800;
|
|
@@ -66,12 +67,18 @@ export async function prepareIntakeForIngestedFile({
|
|
|
66
67
|
|
|
67
68
|
let related = [];
|
|
68
69
|
try {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
const raw = await hybridSearchFn(rootDir, query, { limit: relatedLimit, env });
|
|
71
|
+
// Test mocks return an array directly; production wrapper returns
|
|
72
|
+
// { results: [...] }. Accept both shapes so suggestTags sees the
|
|
73
|
+
// same `related` regardless of caller. Preserve `tags` for the
|
|
74
|
+
// related-inherit suggestion path.
|
|
75
|
+
const hits = Array.isArray(raw) ? raw : (raw?.results || []);
|
|
76
|
+
related = hits.map((hit) => ({
|
|
77
|
+
path: hit.source_path || hit.path || hit.id,
|
|
72
78
|
title: hit.title || hit.id,
|
|
73
79
|
score: hit.score,
|
|
74
80
|
summary: hit.summary || '',
|
|
81
|
+
tags: Array.isArray(hit.tags) ? hit.tags : undefined,
|
|
75
82
|
}));
|
|
76
83
|
} catch (err) {
|
|
77
84
|
related = [];
|
|
@@ -98,6 +105,16 @@ export async function prepareIntakeForIngestedFile({
|
|
|
98
105
|
|
|
99
106
|
const droppedInfo = ingestedFile.droppedInfo ?? [];
|
|
100
107
|
|
|
108
|
+
// Tag auto-attribution: deterministic suggestions from triage + related
|
|
109
|
+
// doc inheritance, filtered against the project tag vocabulary. Vocab
|
|
110
|
+
// load failures fall through with empty suggestions — never block the
|
|
111
|
+
// packet write on a missing/malformed vocab file.
|
|
112
|
+
|
|
113
|
+
let vocab = null;
|
|
114
|
+
try { vocab = loadVocabulary(rootDir); }
|
|
115
|
+
catch { vocab = null; }
|
|
116
|
+
const tagSuggestions = suggestTags(triage, related, vocab);
|
|
117
|
+
|
|
101
118
|
const baseEntry = {
|
|
102
119
|
intake: {
|
|
103
120
|
sourcePath: ingestedFile.sourcePath,
|
|
@@ -112,6 +129,7 @@ export async function prepareIntakeForIngestedFile({
|
|
|
112
129
|
excerpt: extracted.slice(0, EXCERPT_CHARS),
|
|
113
130
|
query,
|
|
114
131
|
customers: customers.length ? customers : undefined,
|
|
132
|
+
tags: tagSuggestions.length > 0 ? tagSuggestions : undefined,
|
|
115
133
|
};
|
|
116
134
|
|
|
117
135
|
// Capability detection mirrors inbox.mjs: stamp provenance onto the packet
|
|
@@ -88,6 +88,22 @@ function resolveConfluenceAuth(homeDir) {
|
|
|
88
88
|
|
|
89
89
|
// ── GitHub Issues ────────────────────────────────────────────────────────
|
|
90
90
|
|
|
91
|
+
// A packet that originated from a demo/fixture/inbox-test source must not
|
|
92
|
+
// be published to a real GitHub repo. The caller can override via
|
|
93
|
+
// `publishDemo: true` when the demo run is intentional (e.g. an explicit
|
|
94
|
+
// integration test that creates and then deletes the issue). Default-safe.
|
|
95
|
+
|
|
96
|
+
export function isDemoIntakePacket(packet) {
|
|
97
|
+
if (process.env.CONSTRUCT_DEMO === '1') return true;
|
|
98
|
+
if (process.env.CONSTRUCT_INTAKE_DEMO === '1') return true;
|
|
99
|
+
const sourcePath = String(packet?.intake?.sourcePath || packet?.sourcePath || '');
|
|
100
|
+
if (!sourcePath) return false;
|
|
101
|
+
if (sourcePath.includes('/tests/fixtures/')) return true;
|
|
102
|
+
if (sourcePath.includes('/.cx/intake/demo/')) return true;
|
|
103
|
+
if (sourcePath.includes('/cx-intake-demo')) return true;
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
91
107
|
/**
|
|
92
108
|
* Create a GitHub issue from an intake packet.
|
|
93
109
|
*
|
|
@@ -98,9 +114,19 @@ function resolveConfluenceAuth(homeDir) {
|
|
|
98
114
|
* @param {string} [opts.apiUrl] - GitHub API URL (default: api.github.com)
|
|
99
115
|
* @param {string} [opts.host] - GitHub host (default: github.com)
|
|
100
116
|
* @param {object} [opts.fetchImpl]
|
|
101
|
-
* @
|
|
117
|
+
* @param {boolean} [opts.publishDemo] - Override the demo-source gate
|
|
118
|
+
* @returns {Promise<{ ok: boolean, externalUrl: string|null, externalId: string|null, error?: string, skipped?: string }>}
|
|
102
119
|
*/
|
|
103
|
-
export async function createGitHubIssue(packet, { repo, token, apiUrl, host, fetchImpl = globalThis.fetch } = {}) {
|
|
120
|
+
export async function createGitHubIssue(packet, { repo, token, apiUrl, host, fetchImpl = globalThis.fetch, publishDemo = false } = {}) {
|
|
121
|
+
if (!publishDemo && isDemoIntakePacket(packet)) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
externalUrl: null,
|
|
125
|
+
externalId: null,
|
|
126
|
+
skipped: 'demo-source',
|
|
127
|
+
error: 'Refused to publish: packet originated from a demo/fixture path (set CONSTRUCT_DEMO=0 and re-run with --publish-issues to override).',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
104
130
|
const hd = homedir();
|
|
105
131
|
const auth = resolveGitHubAuth(hd);
|
|
106
132
|
const resolvedRepo = repo || auth.repo || process.env.GITHUB_REPO;
|
package/lib/knowledge/search.mjs
CHANGED
|
@@ -80,6 +80,18 @@ function buildSourceList(repoRoot = REPO_ROOT) {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// User-ingested documents (PDF/Office/audio routed through docling + whisper)
|
|
84
|
+
const ingestRoot = join(repoRoot, '.cx', 'ingest');
|
|
85
|
+
if (existsSync(ingestRoot)) {
|
|
86
|
+
for (const entry of readdirSync(ingestRoot)) {
|
|
87
|
+
const dir = join(ingestRoot, entry);
|
|
88
|
+
const markdown = join(dir, 'markdown.md');
|
|
89
|
+
if (!/^[a-f0-9]{64}$/.test(entry)) continue;
|
|
90
|
+
if (!existsSync(markdown)) continue;
|
|
91
|
+
sources.push({ path: markdown, rel: relative(repoRoot, markdown), priority: 4, source: 'ingest' });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
83
95
|
return sources;
|
|
84
96
|
}
|
|
85
97
|
|