@geraldmaron/construct 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 +39 -34
- package/bin/construct +1403 -96
- package/commands/build/feature.md +1 -1
- package/commands/build/fix.md +1 -1
- package/commands/design/access.md +1 -1
- package/commands/design/flow.md +1 -1
- package/commands/design/ui.md +1 -1
- package/commands/measure/experiment.md +1 -1
- package/commands/measure/metrics.md +1 -1
- package/commands/measure/results.md +1 -1
- package/commands/plan/api.md +1 -1
- package/commands/plan/challenge.md +1 -1
- package/commands/plan/decide.md +1 -1
- package/commands/plan/feature.md +1 -1
- package/commands/plan/requirements.md +1 -1
- package/commands/remember/context.md +1 -1
- package/commands/remember/handoff.md +1 -1
- package/commands/remember/runbook.md +1 -1
- package/commands/review/code.md +1 -1
- package/commands/review/quality.md +1 -1
- package/commands/review/security.md +1 -1
- package/commands/ship/ready.md +1 -1
- package/commands/ship/release.md +1 -1
- package/commands/ship/status.md +1 -1
- package/commands/understand/docs.md +1 -1
- package/commands/understand/this.md +1 -1
- package/commands/understand/why.md +1 -1
- package/commands/work/clean.md +1 -1
- package/commands/work/drive.md +1 -1
- package/commands/work/optimize-prompts.md +2 -2
- package/commands/work/parallel-review.md +1 -1
- package/db/schema/007_tags.sql +30 -0
- package/db/schema/008_skill_usage.sql +24 -0
- package/db/schema/009_scheduler.sql +14 -0
- package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
- package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
- package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
- package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
- package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
- package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
- package/examples/seed-observations/decisions.md +1 -1
- package/lib/audit-skills.mjs +3 -3
- package/lib/auto-docs.mjs +8 -8
- package/lib/boundary.mjs +126 -0
- package/lib/cache-strategy-google.js +26 -31
- package/lib/cli-commands.mjs +276 -122
- package/lib/comment-lint.mjs +139 -1
- package/lib/completions.mjs +1 -1
- package/lib/config/schema.mjs +15 -1
- package/lib/contracts/validate.mjs +371 -0
- package/lib/daemons/contract.mjs +210 -0
- package/lib/distill.mjs +22 -4
- package/lib/doc-stamp.mjs +48 -0
- package/lib/docs-verify.mjs +59 -6
- package/lib/doctor/cli.mjs +16 -1
- package/lib/doctor/index.mjs +3 -1
- package/lib/doctor/watchers/consistency.mjs +360 -0
- package/lib/doctor/watchers/mcp-protocol.mjs +232 -0
- package/lib/document-extract.mjs +420 -17
- package/lib/document-ingest.mjs +2 -0
- package/lib/embed/cli.mjs +124 -3
- package/lib/embed/daemon.mjs +43 -4
- package/lib/embed/docs-lifecycle.mjs +1 -1
- package/lib/embed/inbox.mjs +50 -3
- package/lib/embed/role-framing.mjs +3 -3
- package/lib/embed/scheduler.mjs +33 -5
- package/lib/env-config.mjs +9 -3
- package/lib/evaluator-optimizer.mjs +2 -3
- package/lib/extractors/calendar.mjs +173 -0
- package/lib/extractors/shared/drop-info.mjs +22 -0
- package/lib/extractors/transcript.mjs +0 -0
- package/lib/gates-audit.mjs +8 -2
- package/lib/git-hooks/prepare-commit-msg +1 -1
- package/lib/headhunt.mjs +2 -2
- package/lib/hooks/agent-tracker.mjs +6 -11
- package/lib/hooks/comment-lint.mjs +16 -0
- package/lib/hooks/guard-bash.mjs +35 -14
- package/lib/hooks/mcp-audit.mjs +14 -1
- package/lib/hooks/pre-compact.mjs +92 -5
- package/lib/hooks/pre-push-gate.mjs +41 -8
- package/lib/hooks/proactive-activation.mjs +0 -14
- package/lib/hooks/registry-sync.mjs +2 -2
- package/lib/hooks/rule-verifier.mjs +217 -0
- package/lib/hooks/session-optimize.mjs +2 -1
- package/lib/hooks/stop-notify.mjs +7 -6
- package/lib/host-capabilities.mjs +10 -1
- package/lib/hygiene/scan.mjs +141 -0
- package/lib/init-unified.mjs +73 -65
- package/lib/install/stage-project.mjs +4 -4
- package/lib/intake/classify.mjs +309 -58
- package/lib/intake/daemon.mjs +121 -0
- package/lib/intake/filesystem-queue.mjs +31 -6
- package/lib/intake/intake-config.mjs +2 -1
- package/lib/intake/postgres-queue.mjs +45 -3
- package/lib/intake/prepare.mjs +3 -1
- package/lib/intake/quarantine.mjs +205 -0
- package/lib/intake/session-prelude.mjs +7 -1
- package/lib/intake/traceability.mjs +90 -0
- package/lib/knowledge/postgres-search.mjs +132 -0
- package/lib/knowledge/research-store.mjs +2 -0
- package/lib/knowledge/search.mjs +14 -4
- package/lib/maintenance/cleanup.mjs +315 -0
- package/lib/mcp/memory-bridge.mjs +276 -0
- package/lib/mcp/server.mjs +11 -3
- package/lib/mcp/tools/skills.mjs +32 -11
- package/lib/mcp/tools/workflow.mjs +25 -0
- package/lib/mcp-catalog.json +12 -8
- package/lib/mcp-platform-config.mjs +16 -8
- package/lib/migrations/index.mjs +106 -0
- package/lib/migrations/v1-baseline.mjs +33 -0
- package/lib/model-registry.mjs +1 -1
- package/lib/observation-store.mjs +9 -4
- package/lib/opencode-runtime-plugin.mjs +1 -1
- package/lib/orchestration-policy.mjs +4 -4
- package/lib/outcomes/record.mjs +2 -0
- package/lib/overrides/resolver.mjs +3 -3
- package/lib/parity.mjs +4 -4
- package/lib/policy/engine.mjs +2 -2
- package/lib/profiles/lifecycle.mjs +1 -1
- package/lib/profiles/rebrand.mjs +46 -0
- package/lib/project-init-shared.mjs +12 -0
- package/lib/prompt-metadata.mjs +4 -4
- package/lib/provider-capabilities.js +20 -7
- package/lib/providers/auth-manager.mjs +58 -17
- package/lib/providers/circuit-breaker.mjs +14 -0
- package/lib/providers/contract.mjs +67 -3
- package/lib/providers/creds.mjs +219 -0
- package/lib/providers/scaffold.mjs +107 -0
- package/lib/reflect.mjs +49 -12
- package/lib/role-preload.mjs +5 -0
- package/lib/roles/catalog.mjs +1 -1
- package/lib/roles/manifest.mjs +2 -2
- package/lib/scheduler/index.mjs +112 -0
- package/lib/scheduler/solo.mjs +183 -0
- package/lib/server/index.mjs +29 -29
- package/lib/server/insights.mjs +14 -12
- package/lib/server/langfuse-login.mjs +58 -0
- package/lib/server/static/assets/index-ab25c707.js +1 -1
- package/lib/session-store.mjs +6 -4
- package/lib/setup.mjs +14 -3
- package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
- package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
- package/lib/{agents → specialists}/postconditions.mjs +3 -3
- package/lib/{agents → specialists}/schema.mjs +6 -6
- package/lib/status.mjs +13 -11
- package/lib/storage/backup.mjs +2 -2
- package/lib/tags/lifecycle.mjs +278 -0
- package/lib/tags/vocabulary.mjs +140 -0
- package/lib/telemetry/client.mjs +5 -1
- package/lib/telemetry/otel-tracer.mjs +184 -0
- package/lib/telemetry/skill-calls.mjs +73 -12
- package/lib/uninstall/uninstall.mjs +1 -1
- package/lib/update.mjs +1 -1
- package/lib/validator.mjs +57 -56
- package/lib/version.mjs +51 -0
- package/lib/worker/trace.mjs +5 -1
- package/lib/workflows/instantiate.mjs +320 -0
- package/package.json +16 -3
- package/personas/construct.md +5 -3
- package/platforms/claude/CLAUDE.md +1 -1
- package/rules/common/development-workflow.md +2 -1
- package/rules/common/no-fabrication.md +69 -0
- package/rules/common/review-before-change.md +58 -0
- package/scripts/{sync-agents.mjs → sync-specialists.mjs} +171 -68
- package/skills/ai/prompt-optimizer.md +3 -3
- package/skills/exploration/dependency-graph-reading.md +98 -0
- package/skills/exploration/tracer-bullet-method.md +71 -0
- package/skills/exploration/unknown-codebase-onboarding.md +91 -0
- package/skills/operating/change-management.md +91 -0
- package/skills/operating/incident-response.md +75 -0
- package/skills/operating/oncall-rotation.md +95 -0
- package/skills/operating/orchestration-reference.md +2 -2
- package/skills/strategy/competitive-landscape.md +75 -0
- package/skills/strategy/market-research-methods.md +87 -0
- package/skills/strategy/narrative-arc.md +77 -0
- package/skills/strategy/pricing-positioning.md +94 -0
- package/specialists/contracts.json +1002 -0
- package/specialists/contracts.schema.json +83 -0
- package/specialists/policy-inventory.json +160 -0
- package/{agents → specialists}/prompts/cx-accessibility.md +3 -1
- package/{agents → specialists}/prompts/cx-ai-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-architect.md +3 -1
- package/{agents → specialists}/prompts/cx-business-strategist.md +3 -1
- package/{agents → specialists}/prompts/cx-data-analyst.md +3 -1
- package/{agents → specialists}/prompts/cx-data-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-debugger.md +3 -1
- package/{agents → specialists}/prompts/cx-designer.md +3 -1
- package/{agents → specialists}/prompts/cx-devil-advocate.md +3 -1
- package/{agents → specialists}/prompts/cx-docs-keeper.md +4 -2
- package/{agents → specialists}/prompts/cx-engineer.md +4 -2
- package/{agents → specialists}/prompts/cx-evaluator.md +3 -1
- package/{agents → specialists}/prompts/cx-explorer.md +3 -1
- package/{agents → specialists}/prompts/cx-legal-compliance.md +3 -1
- package/{agents → specialists}/prompts/cx-operations.md +3 -1
- package/{agents → specialists}/prompts/cx-orchestrator.md +4 -2
- package/{agents → specialists}/prompts/cx-platform-engineer.md +3 -1
- package/{agents → specialists}/prompts/cx-product-manager.md +3 -1
- package/{agents → specialists}/prompts/cx-qa.md +3 -1
- package/{agents → specialists}/prompts/cx-rd-lead.md +3 -1
- package/{agents → specialists}/prompts/cx-release-manager.md +3 -1
- package/{agents → specialists}/prompts/cx-researcher.md +3 -1
- package/{agents → specialists}/prompts/cx-reviewer.md +4 -2
- package/{agents → specialists}/prompts/cx-security.md +3 -1
- package/{agents → specialists}/prompts/cx-sre.md +4 -2
- package/{agents → specialists}/prompts/cx-test-automation.md +3 -1
- package/{agents → specialists}/prompts/cx-trace-reviewer.md +5 -3
- package/{agents → specialists}/prompts/cx-ux-researcher.md +3 -1
- package/{agents → specialists}/registry.json +651 -418
- package/{agents → specialists}/role-manifests.json +8 -8
- package/templates/docs/construct_guide.md +6 -6
- package/templates/docs/skill-artifact.md +1 -1
- package/templates/provider-scaffold/health.test.mjs +30 -0
- package/templates/provider-scaffold/index.mjs +48 -0
- package/templates/workflows/cross-team-handoff.yml +85 -0
- package/templates/workflows/engineering-onboarding.yml +77 -0
- package/templates/workflows/new-feature.yml +53 -0
- package/agents/contracts.json +0 -387
- /package/{agents → specialists}/teams.json +0 -0
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import path from 'node:path';
|
|
18
|
+
import { shouldQuarantine } from './quarantine.mjs';
|
|
18
19
|
|
|
19
20
|
function slugify(value) {
|
|
20
21
|
return String(value || 'untitled')
|
|
@@ -69,21 +70,62 @@ export class PostgresIntakeQueue {
|
|
|
69
70
|
const id = `${ts}-${slug}`;
|
|
70
71
|
const triage = entry.triage || {};
|
|
71
72
|
|
|
73
|
+
// Quarantine routing: low-confidence packets land in status='quarantined'
|
|
74
|
+
// instead of 'pending' so worker claim() never picks them up. Human
|
|
75
|
+
// reroute via rerouteQuarantined() promotes to 'pending'.
|
|
76
|
+
const quarantineDecision = shouldQuarantine(triage);
|
|
77
|
+
const status = quarantineDecision.quarantine ? 'quarantined' : 'pending';
|
|
78
|
+
const augmentedEntry = quarantineDecision.quarantine
|
|
79
|
+
? { ...entry, quarantineReason: quarantineDecision.reason }
|
|
80
|
+
: entry;
|
|
81
|
+
|
|
72
82
|
await this.sql`
|
|
73
83
|
INSERT INTO construct_intake_items (
|
|
74
84
|
id, project, tenant_id, status,
|
|
75
85
|
intake_type, rd_stage, primary_owner, recommended_action,
|
|
76
86
|
risk, requires_approval, confidence, payload
|
|
77
87
|
) VALUES (
|
|
78
|
-
${id}, ${this.project}, ${this.tenantId},
|
|
88
|
+
${id}, ${this.project}, ${this.tenantId}, ${status},
|
|
79
89
|
${triage.intakeType || null}, ${triage.rdStage || null},
|
|
80
90
|
${triage.primaryOwner || null}, ${triage.recommendedAction || null},
|
|
81
91
|
${triage.risk || null}, ${triage.requiresApproval || false},
|
|
82
92
|
${typeof triage.confidence === 'number' ? triage.confidence : null},
|
|
83
|
-
${this.sql.json(
|
|
93
|
+
${this.sql.json(augmentedEntry)}
|
|
84
94
|
)
|
|
85
95
|
`;
|
|
86
|
-
return { id };
|
|
96
|
+
return { id, route: quarantineDecision.quarantine ? 'quarantine' : 'pending', reason: quarantineDecision.reason };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async listQuarantine({ limit = 100 } = {}) {
|
|
100
|
+
const rows = await this.sql`
|
|
101
|
+
SELECT * FROM construct_intake_items
|
|
102
|
+
WHERE project = ${this.project}
|
|
103
|
+
AND (${this.tenantId}::text IS NULL OR tenant_id IS NOT DISTINCT FROM ${this.tenantId})
|
|
104
|
+
AND status = 'quarantined'
|
|
105
|
+
ORDER BY created_at ASC
|
|
106
|
+
LIMIT ${limit}
|
|
107
|
+
`;
|
|
108
|
+
return rows.map(rowToEntry);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async rerouteQuarantined(id, newType, { reroutedBy = 'unknown', reason = '' } = {}) {
|
|
112
|
+
const result = await this.sql`
|
|
113
|
+
UPDATE construct_intake_items
|
|
114
|
+
SET status = 'pending',
|
|
115
|
+
intake_type = ${newType},
|
|
116
|
+
payload = jsonb_set(
|
|
117
|
+
jsonb_set(payload, '{triage,originalIntakeType}', to_jsonb(intake_type::text)),
|
|
118
|
+
'{triage,intakeType}', to_jsonb(${newType}::text)
|
|
119
|
+
),
|
|
120
|
+
updated_at = now(),
|
|
121
|
+
notes = ${`rerouted by ${reroutedBy}${reason ? `: ${reason}` : ''}`}
|
|
122
|
+
WHERE id = ${id}
|
|
123
|
+
AND project = ${this.project}
|
|
124
|
+
AND status = 'quarantined'
|
|
125
|
+
RETURNING id
|
|
126
|
+
`;
|
|
127
|
+
if (result.count === 0) throw new Error(`rerouteQuarantined: no quarantined entry ${id}`);
|
|
128
|
+
return { id, route: 'pending' };
|
|
87
129
|
}
|
|
88
130
|
|
|
89
131
|
async listPending({ limit = 100 } = {}) {
|
package/lib/intake/prepare.mjs
CHANGED
|
@@ -77,19 +77,21 @@ export async function prepareIntakeForIngestedFile({
|
|
|
77
77
|
extractedText: extracted,
|
|
78
78
|
related,
|
|
79
79
|
profile: activeProfile?.id,
|
|
80
|
-
cwd: rootDir,
|
|
81
80
|
});
|
|
82
81
|
|
|
83
82
|
// Detect customer mentions and update profiles
|
|
84
83
|
const customerMentions = detectCustomerMentions(extracted.slice(0, 2000));
|
|
85
84
|
const customers = customerMentions.map((c) => c.customerId);
|
|
86
85
|
|
|
86
|
+
const droppedInfo = ingestedFile.droppedInfo ?? [];
|
|
87
|
+
|
|
87
88
|
const entry = {
|
|
88
89
|
intake: {
|
|
89
90
|
sourcePath: ingestedFile.sourcePath,
|
|
90
91
|
outputPath: ingestedFile.outputPath,
|
|
91
92
|
characters: ingestedFile.characters,
|
|
92
93
|
knowledgeSubdir: ingestedFile.knowledgeSubdir,
|
|
94
|
+
extractor: droppedInfo.length > 0 ? { droppedInfo } : undefined,
|
|
93
95
|
},
|
|
94
96
|
triage,
|
|
95
97
|
suggestion: lane ? { lane, source: 'docs-routing.suggestDocsLaneForFile' } : null,
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/intake/quarantine.mjs — low-confidence intake quarantine + learned-fixture loop.
|
|
3
|
+
*
|
|
4
|
+
* When the classifier is uncertain (low confidence or close margin between
|
|
5
|
+
* top-2 candidates), the packet should not auto-route to pending where a
|
|
6
|
+
* downstream specialist might act on a confident-looking-but-wrong label.
|
|
7
|
+
* Instead it lands in `.cx/intake/quarantine/` and waits for human review
|
|
8
|
+
* via `construct intake quarantine show <id>` + `construct intake reroute`.
|
|
9
|
+
*
|
|
10
|
+
* The reroute path writes a fixture into tests/fixtures/intake/learned/
|
|
11
|
+
* keyed by content hash. CI loads these fixtures alongside the golden
|
|
12
|
+
* corpus and asserts the classifier handles them correctly. The override
|
|
13
|
+
* thus becomes durable — the same wrong call cannot happen twice without
|
|
14
|
+
* also failing CI.
|
|
15
|
+
*
|
|
16
|
+
* Determinism contract preserved: the daemon never reads the learned
|
|
17
|
+
* fixtures at runtime; only tests do. Classification stays a pure function
|
|
18
|
+
* of (sourcePath, extractedText, related, profile).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
const QUEUE_SUBDIR = '.cx/intake';
|
|
26
|
+
|
|
27
|
+
// Quarantine thresholds. A packet with a winning candidate still lands in
|
|
28
|
+
// quarantine when either bar fails. Conservative defaults: 0.60 confidence
|
|
29
|
+
// and 0.20 margin cover the demonstrated failure cases without flooding
|
|
30
|
+
// quarantine for borderline-but-defensible classifications.
|
|
31
|
+
export const QUARANTINE_CONFIDENCE_THRESHOLD = 0.60;
|
|
32
|
+
export const QUARANTINE_MARGIN_THRESHOLD = 0.20;
|
|
33
|
+
|
|
34
|
+
export function quarantineDir(rootDir) {
|
|
35
|
+
return path.join(rootDir, QUEUE_SUBDIR, 'quarantine');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Decide whether a triage result should be routed to quarantine.
|
|
40
|
+
* Returns { quarantine: boolean, reason?: string }.
|
|
41
|
+
*/
|
|
42
|
+
export function shouldQuarantine(triage) {
|
|
43
|
+
if (!triage || triage.intakeType === 'unknown') {
|
|
44
|
+
return { quarantine: false }; // unknown is its own bucket; not quarantine
|
|
45
|
+
}
|
|
46
|
+
const confidence = typeof triage.confidence === 'number' ? triage.confidence : 1;
|
|
47
|
+
if (confidence < QUARANTINE_CONFIDENCE_THRESHOLD) {
|
|
48
|
+
return { quarantine: true, reason: `confidence ${confidence.toFixed(2)} < ${QUARANTINE_CONFIDENCE_THRESHOLD}` };
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(triage.candidates) && triage.candidates.length >= 2) {
|
|
51
|
+
const margin = triage.candidates[0].score - triage.candidates[1].score;
|
|
52
|
+
if (margin < QUARANTINE_MARGIN_THRESHOLD) {
|
|
53
|
+
return { quarantine: true, reason: `margin ${margin.toFixed(2)} < ${QUARANTINE_MARGIN_THRESHOLD}` };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { quarantine: false };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Hash content for fixture deduplication. Uses the same input the classifier
|
|
61
|
+
* sees so two packets with identical signal produce one fixture.
|
|
62
|
+
*/
|
|
63
|
+
export function fixtureHash(sourcePath, extractedText) {
|
|
64
|
+
const h = createHash('sha256');
|
|
65
|
+
h.update(String(sourcePath || ''));
|
|
66
|
+
h.update('\n--\n');
|
|
67
|
+
h.update(String(extractedText || '').slice(0, 8000));
|
|
68
|
+
return h.digest('hex').slice(0, 16);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Write a learned-fixture file recording the human-chosen classification.
|
|
73
|
+
* Called by `construct intake reroute`. Idempotent — overwrites with the
|
|
74
|
+
* latest reroute decision (the most recent human call is authoritative).
|
|
75
|
+
*
|
|
76
|
+
* @param {string} rootDir - repo root where tests/fixtures/intake/learned lives
|
|
77
|
+
* @param {object} packet - the quarantined packet being rerouted
|
|
78
|
+
* @param {string} expectedType - intakeType the human picked
|
|
79
|
+
* @returns {{ filePath: string, hash: string }}
|
|
80
|
+
*/
|
|
81
|
+
export function writeLearnedFixture(rootDir, packet, expectedType) {
|
|
82
|
+
const sourcePath = packet?.intake?.sourcePath || packet?.sourcePath || '';
|
|
83
|
+
const text = packet?.excerpt || '';
|
|
84
|
+
const hash = fixtureHash(sourcePath, text);
|
|
85
|
+
const dir = path.join(rootDir, 'tests', 'fixtures', 'intake', 'learned');
|
|
86
|
+
mkdirSync(dir, { recursive: true });
|
|
87
|
+
const filePath = path.join(dir, `${hash}.json`);
|
|
88
|
+
const fixture = {
|
|
89
|
+
content_hash: `sha256:${hash}`,
|
|
90
|
+
source_path: sourcePath,
|
|
91
|
+
text_snippet: text.slice(0, 500),
|
|
92
|
+
expected: { intakeType: expectedType },
|
|
93
|
+
origin: 'user-reroute',
|
|
94
|
+
created_at: new Date().toISOString(),
|
|
95
|
+
packet_id: packet?.id || null,
|
|
96
|
+
};
|
|
97
|
+
writeFileSync(filePath, JSON.stringify(fixture, null, 2) + '\n', 'utf8');
|
|
98
|
+
return { filePath, hash };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* List all learned fixtures (for the calibration test). Returns an array
|
|
103
|
+
* of { content_hash, source_path, text_snippet, expected, ... } objects.
|
|
104
|
+
*/
|
|
105
|
+
export function loadLearnedFixtures(rootDir) {
|
|
106
|
+
const dir = path.join(rootDir, 'tests', 'fixtures', 'intake', 'learned');
|
|
107
|
+
if (!existsSync(dir)) return [];
|
|
108
|
+
return readdirSync(dir)
|
|
109
|
+
.filter((name) => name.endsWith('.json'))
|
|
110
|
+
.map((name) => {
|
|
111
|
+
try { return JSON.parse(readFileSync(path.join(dir, name), 'utf8')); }
|
|
112
|
+
catch { return null; }
|
|
113
|
+
})
|
|
114
|
+
.filter(Boolean);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Write a quarantined packet to disk. Mirrors the structure of pending/
|
|
119
|
+
* packets so `intake show` / `intake reroute` can read either location.
|
|
120
|
+
*/
|
|
121
|
+
export function writeQuarantinePacket(rootDir, packet, quarantineReason) {
|
|
122
|
+
const dir = quarantineDir(rootDir);
|
|
123
|
+
mkdirSync(dir, { recursive: true });
|
|
124
|
+
const id = packet.id;
|
|
125
|
+
if (!id) throw new Error('writeQuarantinePacket: packet.id required');
|
|
126
|
+
const filePath = path.join(dir, `${id}.json`);
|
|
127
|
+
const payload = {
|
|
128
|
+
...packet,
|
|
129
|
+
status: 'quarantined',
|
|
130
|
+
quarantinedAt: new Date().toISOString(),
|
|
131
|
+
quarantineReason: quarantineReason || null,
|
|
132
|
+
};
|
|
133
|
+
writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
134
|
+
return { id, filePath };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* List packets currently in quarantine. Same shape as listPending.
|
|
139
|
+
*/
|
|
140
|
+
export function listQuarantine(rootDir) {
|
|
141
|
+
const dir = quarantineDir(rootDir);
|
|
142
|
+
if (!existsSync(dir)) return [];
|
|
143
|
+
return readdirSync(dir)
|
|
144
|
+
.filter((name) => name.endsWith('.json'))
|
|
145
|
+
.map((name) => {
|
|
146
|
+
const filePath = path.join(dir, name);
|
|
147
|
+
try {
|
|
148
|
+
const data = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
149
|
+
return { ...data, filePath };
|
|
150
|
+
} catch { return null; }
|
|
151
|
+
})
|
|
152
|
+
.filter(Boolean)
|
|
153
|
+
.sort((a, b) => (a.quarantinedAt || '').localeCompare(b.quarantinedAt || ''));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Read a single quarantined packet by id. Returns null if not found.
|
|
158
|
+
*/
|
|
159
|
+
export function readQuarantine(rootDir, id) {
|
|
160
|
+
const filePath = path.join(quarantineDir(rootDir), `${id}.json`);
|
|
161
|
+
if (!existsSync(filePath)) return null;
|
|
162
|
+
try {
|
|
163
|
+
const data = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
164
|
+
return { ...data, filePath };
|
|
165
|
+
} catch { return null; }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Move a quarantined packet to pending after a human reroute. Updates the
|
|
170
|
+
* triage.intakeType + records the reroute history. Writes the learned
|
|
171
|
+
* fixture as a side effect.
|
|
172
|
+
*
|
|
173
|
+
* Returns { id, filePath, fixturePath, fixtureHash } on success.
|
|
174
|
+
*/
|
|
175
|
+
export function rerouteQuarantined(rootDir, id, newType, { reroutedBy = 'unknown', reason = '' } = {}) {
|
|
176
|
+
const packet = readQuarantine(rootDir, id);
|
|
177
|
+
if (!packet) throw new Error(`reroute: no quarantined packet ${id}`);
|
|
178
|
+
|
|
179
|
+
// Write learned fixture BEFORE moving — fixture creation must succeed for
|
|
180
|
+
// the reroute to commit. If fixture write fails, the packet stays put so
|
|
181
|
+
// the human can retry.
|
|
182
|
+
const { filePath: fixturePath, hash } = writeLearnedFixture(rootDir, packet, newType);
|
|
183
|
+
|
|
184
|
+
const updated = {
|
|
185
|
+
...packet,
|
|
186
|
+
status: 'pending',
|
|
187
|
+
triage: {
|
|
188
|
+
...(packet.triage || {}),
|
|
189
|
+
intakeType: newType,
|
|
190
|
+
originalIntakeType: packet.triage?.intakeType,
|
|
191
|
+
reroutedAt: new Date().toISOString(),
|
|
192
|
+
reroutedBy,
|
|
193
|
+
rerouteReason: reason || null,
|
|
194
|
+
rerouteFixtureHash: hash,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
delete updated.quarantinedAt;
|
|
198
|
+
delete updated.quarantineReason;
|
|
199
|
+
|
|
200
|
+
const pendingPath = path.join(rootDir, QUEUE_SUBDIR, 'pending', `${id}.json`);
|
|
201
|
+
mkdirSync(path.dirname(pendingPath), { recursive: true });
|
|
202
|
+
writeFileSync(pendingPath, JSON.stringify(updated, null, 2) + '\n', 'utf8');
|
|
203
|
+
rmSync(packet.filePath);
|
|
204
|
+
return { id, filePath: pendingPath, fixturePath, fixtureHash: hash };
|
|
205
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { createIntakeQueue } from './queue.mjs';
|
|
14
14
|
import { formatTriageLine } from './classify.mjs';
|
|
15
15
|
import { isBrokered } from '../mcp/broker.mjs';
|
|
16
|
+
import { getRebrand } from '../profiles/rebrand.mjs';
|
|
16
17
|
|
|
17
18
|
export function buildIntakePrelude({ cwd, env = process.env } = {}) {
|
|
18
19
|
if (!cwd) return '';
|
|
@@ -20,11 +21,16 @@ export function buildIntakePrelude({ cwd, env = process.env } = {}) {
|
|
|
20
21
|
const queue = createIntakeQueue(cwd, env);
|
|
21
22
|
const pending = queue.listPending();
|
|
22
23
|
if (!pending.length) return '';
|
|
24
|
+
const { intakeQueueLabel, signalNoun } = getRebrand(cwd);
|
|
23
25
|
const recent = pending.slice(-3).map((p) => {
|
|
24
26
|
const src = p.intake?.sourcePath || p.id;
|
|
25
27
|
return `- ${formatTriageLine(src, p.triage)}`;
|
|
26
28
|
});
|
|
27
|
-
|
|
29
|
+
// Strip a trailing " queue" / " intake" so the heading reads
|
|
30
|
+
// "## Pending R&D intake (N)" rather than the redundant
|
|
31
|
+
// "## Pending R&D intake queue queue (N)".
|
|
32
|
+
const heading = intakeQueueLabel.replace(/\s+queue$/i, '');
|
|
33
|
+
return `\n## Pending ${heading} (${pending.length})\n${recent.join('\n')}\nEach packet at \`.cx/intake/pending/<id>.json\` carries the new ${signalNoun}, a triage block (intakeType, rdStage, primaryOwner, recommendedChain, recommendedAction, risk), related existing docs, and an excerpt. Process via the recommended chain, then close via \`construct intake done <id>\`.\n`;
|
|
28
34
|
} catch {
|
|
29
35
|
return '';
|
|
30
36
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/intake/traceability.mjs — stamps intake provenance into artifact frontmatter.
|
|
3
|
+
*
|
|
4
|
+
* Backs `construct intake done <id> --output=<path>`: reads the intake packet,
|
|
5
|
+
* extracts (id, confidence, rationale), and writes them into the artifact's
|
|
6
|
+
* YAML frontmatter. Refuses to overwrite a different intake_id (an artifact
|
|
7
|
+
* can be linked to exactly one intake source). Idempotent on re-stamping.
|
|
8
|
+
*
|
|
9
|
+
* Read by docs:verify checkIntakeTraceability to surface artifacts in
|
|
10
|
+
* intake-fed locations that lack a reference.
|
|
11
|
+
*
|
|
12
|
+
* Field schema (in artifact YAML frontmatter):
|
|
13
|
+
* intake_id: construct-xxx
|
|
14
|
+
* intake_confidence: 0.7
|
|
15
|
+
* intake_rationale: "Matched 3 keywords: dashboard, latency, p95"
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
19
|
+
|
|
20
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
|
|
21
|
+
|
|
22
|
+
export function parseArtifactFrontmatter(content) {
|
|
23
|
+
const m = content.match(FRONTMATTER_RE);
|
|
24
|
+
if (!m) return { frontmatter: {}, body: content, hasBlock: false };
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const line of m[1].split('\n')) {
|
|
27
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
28
|
+
const idx = line.indexOf(':');
|
|
29
|
+
if (idx === -1) continue;
|
|
30
|
+
const key = line.slice(0, idx).trim();
|
|
31
|
+
const rawValue = line.slice(idx + 1).trim();
|
|
32
|
+
if (!key) continue;
|
|
33
|
+
const unquoted = rawValue.replace(/^["']|["']$/g, '');
|
|
34
|
+
if (/^-?\d+(\.\d+)?$/.test(unquoted)) out[key] = Number(unquoted);
|
|
35
|
+
else out[key] = unquoted;
|
|
36
|
+
}
|
|
37
|
+
return { frontmatter: out, body: content.slice(m[0].length), hasBlock: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function serializeFrontmatter(fm) {
|
|
41
|
+
const lines = ['---'];
|
|
42
|
+
for (const [k, v] of Object.entries(fm)) {
|
|
43
|
+
if (v === null || v === undefined) continue;
|
|
44
|
+
if (typeof v === 'number' || typeof v === 'boolean') {
|
|
45
|
+
lines.push(`${k}: ${v}`);
|
|
46
|
+
} else {
|
|
47
|
+
const s = String(v);
|
|
48
|
+
const needsQuoting = /[:#"'\n]|^\s|\s$/.test(s);
|
|
49
|
+
lines.push(`${k}: ${needsQuoting ? JSON.stringify(s) : s}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
lines.push('---');
|
|
53
|
+
return lines.join('\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function stampIntakeProvenance(artifactPath, { intakeId, confidence, rationale }) {
|
|
57
|
+
if (!existsSync(artifactPath)) {
|
|
58
|
+
throw new Error(`Output artifact not found: ${artifactPath}`);
|
|
59
|
+
}
|
|
60
|
+
if (!intakeId) throw new Error('intakeId required for stamping');
|
|
61
|
+
const content = readFileSync(artifactPath, 'utf8');
|
|
62
|
+
const { frontmatter, body, hasBlock } = parseArtifactFrontmatter(content);
|
|
63
|
+
|
|
64
|
+
const existing = frontmatter.intake_id;
|
|
65
|
+
if (existing && existing !== intakeId) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Refusing to overwrite intake_id: artifact already references ${existing}, not ${intakeId}. Resolve manually or supersede.`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const next = {
|
|
72
|
+
...frontmatter,
|
|
73
|
+
intake_id: intakeId,
|
|
74
|
+
...(confidence !== undefined && confidence !== null ? { intake_confidence: confidence } : {}),
|
|
75
|
+
...(rationale ? { intake_rationale: rationale } : {}),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const fmText = serializeFrontmatter(next);
|
|
79
|
+
const out = hasBlock ? `${fmText}\n${body}` : `${fmText}\n\n${content}`;
|
|
80
|
+
writeFileSync(artifactPath, out, 'utf8');
|
|
81
|
+
return { intake_id: intakeId, intake_confidence: next.intake_confidence, intake_rationale: next.intake_rationale };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function hasIntakeReference(artifactPath) {
|
|
85
|
+
if (!existsSync(artifactPath)) return false;
|
|
86
|
+
const { frontmatter } = parseArtifactFrontmatter(readFileSync(artifactPath, 'utf8'));
|
|
87
|
+
if (frontmatter.intake_id) return true;
|
|
88
|
+
if (frontmatter.intake === 'none') return true;
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/knowledge/postgres-search.mjs — Tag-aware vector search via pgvector + GIN.
|
|
3
|
+
*
|
|
4
|
+
* Runs when DATABASE_URL is configured. Chooses between two execution paths
|
|
5
|
+
* based on tag selectivity:
|
|
6
|
+
*
|
|
7
|
+
* - High selectivity (tag matches <5% of corpus): GIN prefilter first, then
|
|
8
|
+
* HNSW ANN on the filtered set.
|
|
9
|
+
* - Low selectivity (tag matches ≥5% of corpus): HNSW ANN first with
|
|
10
|
+
* hnsw.iterative_scan=relaxed_order, postfilter by tag.
|
|
11
|
+
*
|
|
12
|
+
* Falls back gracefully when DATABASE_URL is absent or the pgvector extension
|
|
13
|
+
* is not installed; callers should use knowledgeSearch() from search.mjs in
|
|
14
|
+
* that case.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Selectivity threshold: if the tagged fraction exceeds this, use postfilter.
|
|
18
|
+
const HIGH_SELECTIVITY_THRESHOLD = 0.05;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Execute a tag-filtered vector search against Postgres + pgvector.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} opts
|
|
24
|
+
* @param {Float32Array|number[]} opts.queryEmbedding — query vector
|
|
25
|
+
* @param {string} opts.project — project id (construct_documents.project)
|
|
26
|
+
* @param {string[]} [opts.tags] — tag filter
|
|
27
|
+
* @param {'any'|'all'} [opts.tagMatch] — 'any' (default) or 'all'
|
|
28
|
+
* @param {number} [opts.topK] — result limit (default 20)
|
|
29
|
+
* @param {object} opts.db — postgres client (from lib/db.mjs or postgres pkg)
|
|
30
|
+
* @returns {Promise<Array<{id, title, score}>>}
|
|
31
|
+
*/
|
|
32
|
+
export async function postgresTagSearch({ queryEmbedding, project, tags = [], tagMatch = 'any', topK = 20, db }) {
|
|
33
|
+
if (!db) throw new Error('postgres-search: db client required');
|
|
34
|
+
if (!queryEmbedding?.length) throw new Error('postgres-search: queryEmbedding required');
|
|
35
|
+
|
|
36
|
+
const vecLiteral = `[${Array.from(queryEmbedding).join(',')}]`;
|
|
37
|
+
|
|
38
|
+
if (!tags.length) {
|
|
39
|
+
// No tag filter — plain ANN.
|
|
40
|
+
const rows = await db`
|
|
41
|
+
select d.id, d.title, e.embedding <=> ${vecLiteral}::vector as distance
|
|
42
|
+
from construct_embeddings e
|
|
43
|
+
join construct_documents d on d.id = e.document_id
|
|
44
|
+
where d.project = ${project}
|
|
45
|
+
order by e.embedding <=> ${vecLiteral}::vector
|
|
46
|
+
limit ${topK}
|
|
47
|
+
`;
|
|
48
|
+
return rows.map((r) => ({ id: r.id, title: r.title, score: 1 - parseFloat(r.distance) }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Estimate selectivity for this tag set.
|
|
52
|
+
const tagJsonb = JSON.stringify(tags);
|
|
53
|
+
const [{ total }] = await db`select count(*) as total from construct_documents where project = ${project}`;
|
|
54
|
+
const totalCount = parseInt(total, 10) || 1;
|
|
55
|
+
|
|
56
|
+
let taggedCount;
|
|
57
|
+
if (tagMatch === 'all') {
|
|
58
|
+
const [{ cnt }] = await db`
|
|
59
|
+
select count(*) as cnt from construct_documents
|
|
60
|
+
where project = ${project} and tags @> ${tagJsonb}::jsonb
|
|
61
|
+
`;
|
|
62
|
+
taggedCount = parseInt(cnt, 10);
|
|
63
|
+
} else {
|
|
64
|
+
// 'any' — count docs with at least one matching tag.
|
|
65
|
+
const [{ cnt }] = await db`
|
|
66
|
+
select count(*) as cnt from construct_documents
|
|
67
|
+
where project = ${project}
|
|
68
|
+
and (${db.unsafe(tags.map((_, i) => `tags ? $${i + 2}`).join(' or '))})
|
|
69
|
+
`;
|
|
70
|
+
taggedCount = parseInt(cnt, 10);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const selectivity = taggedCount / totalCount;
|
|
74
|
+
|
|
75
|
+
if (selectivity < HIGH_SELECTIVITY_THRESHOLD) {
|
|
76
|
+
// High selectivity: GIN prefilter → ANN on filtered set.
|
|
77
|
+
if (tagMatch === 'all') {
|
|
78
|
+
const rows = await db`
|
|
79
|
+
with filtered as (
|
|
80
|
+
select id from construct_documents
|
|
81
|
+
where project = ${project} and tags @> ${tagJsonb}::jsonb
|
|
82
|
+
)
|
|
83
|
+
select d.id, d.title, e.embedding <=> ${vecLiteral}::vector as distance
|
|
84
|
+
from construct_embeddings e
|
|
85
|
+
join filtered f on f.id = e.document_id
|
|
86
|
+
join construct_documents d on d.id = e.document_id
|
|
87
|
+
order by e.embedding <=> ${vecLiteral}::vector
|
|
88
|
+
limit ${topK}
|
|
89
|
+
`;
|
|
90
|
+
return rows.map((r) => ({ id: r.id, title: r.title, score: 1 - parseFloat(r.distance) }));
|
|
91
|
+
}
|
|
92
|
+
// 'any' — OR-expand tag list.
|
|
93
|
+
const tagConditions = tags.map((t) => `tags ? '${t.replace(/'/g, "''")}'`).join(' or ');
|
|
94
|
+
const rows = await db.unsafe(`
|
|
95
|
+
with filtered as (
|
|
96
|
+
select id from construct_documents
|
|
97
|
+
where project = $1 and (${tagConditions})
|
|
98
|
+
)
|
|
99
|
+
select d.id, d.title, e.embedding <=> $2::vector as distance
|
|
100
|
+
from construct_embeddings e
|
|
101
|
+
join filtered f on f.id = e.document_id
|
|
102
|
+
join construct_documents d on d.id = e.document_id
|
|
103
|
+
order by e.embedding <=> $2::vector
|
|
104
|
+
limit $3
|
|
105
|
+
`, [project, vecLiteral, topK]);
|
|
106
|
+
return rows.map((r) => ({ id: r.id, title: r.title, score: 1 - parseFloat(r.distance) }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Low selectivity: HNSW iterative scan → postfilter.
|
|
110
|
+
await db`set hnsw.iterative_scan = relaxed_order`;
|
|
111
|
+
if (tagMatch === 'all') {
|
|
112
|
+
const rows = await db`
|
|
113
|
+
select d.id, d.title, e.embedding <=> ${vecLiteral}::vector as distance
|
|
114
|
+
from construct_embeddings e
|
|
115
|
+
join construct_documents d on d.id = e.document_id
|
|
116
|
+
where d.project = ${project} and d.tags @> ${tagJsonb}::jsonb
|
|
117
|
+
order by e.embedding <=> ${vecLiteral}::vector
|
|
118
|
+
limit ${topK}
|
|
119
|
+
`;
|
|
120
|
+
return rows.map((r) => ({ id: r.id, title: r.title, score: 1 - parseFloat(r.distance) }));
|
|
121
|
+
}
|
|
122
|
+
const tagConditions = tags.map((t) => `d.tags ? '${t.replace(/'/g, "''")}'`).join(' or ');
|
|
123
|
+
const rows = await db.unsafe(`
|
|
124
|
+
select d.id, d.title, e.embedding <=> $1::vector as distance
|
|
125
|
+
from construct_embeddings e
|
|
126
|
+
join construct_documents d on d.id = e.document_id
|
|
127
|
+
where d.project = $2 and (${tagConditions})
|
|
128
|
+
order by e.embedding <=> $1::vector
|
|
129
|
+
limit $3
|
|
130
|
+
`, [vecLiteral, project, topK]);
|
|
131
|
+
return rows.map((r) => ({ id: r.id, title: r.title, score: 1 - parseFloat(r.distance) }));
|
|
132
|
+
}
|
|
@@ -19,6 +19,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
|
19
19
|
import { dirname, join } from 'node:path';
|
|
20
20
|
import { syncFileStateToSql } from '../storage/sync.mjs';
|
|
21
21
|
import { resolveActiveProfile } from '../profiles/loader.mjs';
|
|
22
|
+
import { ensureCxDir } from '../project-init-shared.mjs';
|
|
22
23
|
|
|
23
24
|
const ROOT = '.cx/knowledge/external/research';
|
|
24
25
|
const MAX_BYTES = 50 * 1024;
|
|
@@ -68,6 +69,7 @@ export async function addResearchFinding({
|
|
|
68
69
|
const expiresAt = new Date(now.getTime() + ttlDays * 24 * 60 * 60 * 1000).toISOString();
|
|
69
70
|
|
|
70
71
|
const outDir = join(cwd, ROOT);
|
|
72
|
+
ensureCxDir(cwd);
|
|
71
73
|
mkdirSync(outDir, { recursive: true });
|
|
72
74
|
const outPath = join(outDir, `${slug}.md`);
|
|
73
75
|
|
package/lib/knowledge/search.mjs
CHANGED
|
@@ -253,7 +253,7 @@ function buildObservationChunks(rootDir) {
|
|
|
253
253
|
* @param {string} [opts.rootDir] — data dir where .cx/observations/ lives (default: homedir())
|
|
254
254
|
* @returns {KnowledgeSearchResult}
|
|
255
255
|
*/
|
|
256
|
-
export function knowledgeSearch({ query, topK = 5, minScore = 0.1, repoRoot, rootDir } = {}) {
|
|
256
|
+
export function knowledgeSearch({ query, topK = 5, minScore = 0.1, repoRoot, rootDir, tags, tagMatch = 'any' } = {}) {
|
|
257
257
|
if (!query || typeof query !== 'string') {
|
|
258
258
|
return { ok: false, query: query ?? '', totalChunks: 0, hits: [], sources: [], message: 'query is required' };
|
|
259
259
|
}
|
|
@@ -279,14 +279,24 @@ export function knowledgeSearch({ query, topK = 5, minScore = 0.1, repoRoot, roo
|
|
|
279
279
|
return { ok: false, query, totalChunks: 0, hits: [], sources: [], message: 'No documentation or observation sources found' };
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
// Tag filter (optional): applied before scoring to restrict the candidate set.
|
|
283
|
+
const filteredChunks = tags?.length
|
|
284
|
+
? allChunks.filter((c) => {
|
|
285
|
+
const chunkTags = new Set(Array.isArray(c.tags) ? c.tags : []);
|
|
286
|
+
const required = tags;
|
|
287
|
+
if (tagMatch === 'all') return required.every((t) => chunkTags.has(t));
|
|
288
|
+
return required.some((t) => chunkTags.has(t));
|
|
289
|
+
})
|
|
290
|
+
: allChunks;
|
|
291
|
+
|
|
282
292
|
const queryTokens = tokenise(query);
|
|
283
293
|
if (!queryTokens.length) {
|
|
284
|
-
return { ok: false, query, totalChunks:
|
|
294
|
+
return { ok: false, query, totalChunks: filteredChunks.length, hits: [], sources: [], message: 'Query contains no searchable terms after stop-word removal' };
|
|
285
295
|
}
|
|
286
296
|
|
|
287
|
-
const idf = buildIdf(queryTokens,
|
|
297
|
+
const idf = buildIdf(queryTokens, filteredChunks);
|
|
288
298
|
|
|
289
|
-
const scored =
|
|
299
|
+
const scored = filteredChunks
|
|
290
300
|
.map(chunk => ({ chunk, score: scoreChunk(chunk, queryTokens, idf) }))
|
|
291
301
|
.filter(({ score }) => score >= minScore)
|
|
292
302
|
.sort((a, b) => b.score - a.score)
|