@kontourai/flow-agents 2.4.0 → 3.1.0
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/.github/CODEOWNERS +8 -0
- package/.github/workflows/ci.yml +20 -0
- package/.github/workflows/trust-reconcile.yml +62 -4
- package/CHANGELOG.md +38 -0
- package/CONTEXT.md +88 -1
- package/README.md +4 -0
- package/build/src/cli/assignment-provider.d.ts +144 -0
- package/build/src/cli/assignment-provider.js +805 -0
- package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
- package/build/src/cli/effective-assignment-provider-settings.js +125 -0
- package/build/src/cli/validate-workflow-artifacts.js +5 -1
- package/build/src/cli/workflow-sidecar.d.ts +2 -2
- package/build/src/cli/workflow-sidecar.js +549 -145
- package/build/src/cli.js +6 -0
- package/build/src/lib/flow-resolver.d.ts +12 -6
- package/build/src/lib/flow-resolver.js +30 -14
- package/build/src/tools/validate-source-tree.js +2 -0
- package/context/contracts/artifact-contract.md +2 -0
- package/context/contracts/assignment-provider-contract.md +248 -0
- package/context/contracts/builder-kit-workflow-state-contract.md +2 -0
- package/context/contracts/decision-registry-contract.md +2 -0
- package/context/contracts/delivery-contract.md +2 -0
- package/context/contracts/execution-contract.md +25 -0
- package/context/contracts/governance-adapter-contract.md +2 -0
- package/context/contracts/knowledge-store-contract.md +197 -0
- package/context/contracts/planning-contract.md +2 -0
- package/context/contracts/probe-docs-write-contract.md +187 -0
- package/context/contracts/review-contract.md +2 -0
- package/context/contracts/sandbox-policy.md +2 -0
- package/context/contracts/standing-directives.md +13 -0
- package/context/contracts/verification-contract.md +2 -0
- package/context/contracts/work-item-contract.md +2 -0
- package/context/scripts/hooks/config-protection.js +14 -1
- package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/context/scripts/hooks/stop-goal-fit.js +4 -2
- package/context/scripts/hooks/workflow-steering.js +42 -0
- package/context/settings/assignment-provider-settings.json +33 -0
- package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
- package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
- package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
- package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
- package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
- package/docs/adr/0006-typescript-first-source-policy.md +2 -0
- package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
- package/docs/adr/0007-skill-audit.md +2 -0
- package/docs/adr/0008-kit-operation-boundary.md +2 -0
- package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
- package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
- package/docs/adr/0011-mcp-posture.md +2 -0
- package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
- package/docs/adr/0013-context-lifecycle.md +2 -0
- package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
- package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
- package/docs/adr/0016-three-hard-boundary-model.md +2 -0
- package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
- package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
- package/docs/adr/0019-kit-dependency-ownership.md +2 -0
- package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
- package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +182 -0
- package/docs/adr/README.md +49 -0
- package/docs/adr/index.md +34 -0
- package/docs/context-map.md +1 -0
- package/docs/decisions/agent-coordination.md +20 -0
- package/docs/decisions/anti-gaming-trust-security.md +20 -0
- package/docs/decisions/context-lifecycle.md +18 -0
- package/docs/decisions/core-domain-kit-boundary.md +18 -0
- package/docs/decisions/flow-flow-agents-boundary.md +18 -0
- package/docs/decisions/flow-kit.md +20 -0
- package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
- package/docs/decisions/graph-knowledge-provider.md +63 -0
- package/docs/decisions/hook-core-kit-boundary.md +18 -0
- package/docs/decisions/index.md +22 -0
- package/docs/decisions/kit-dependency-ownership.md +18 -0
- package/docs/decisions/kit-operation-boundary.md +18 -0
- package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
- package/docs/decisions/knowledge-store-provider.md +51 -0
- package/docs/decisions/kontour-resource-contract.md +18 -0
- package/docs/decisions/mcp-posture.md +18 -0
- package/docs/decisions/model-routing.md +63 -0
- package/docs/decisions/standing-directives.md +66 -0
- package/docs/decisions/three-hard-boundary-model.md +18 -0
- package/docs/decisions/trust-reconcile.md +20 -0
- package/docs/decisions/typescript-source-policy.md +48 -0
- package/docs/decisions/workflow-enforcement.md +18 -0
- package/docs/decisions/workflow-trust-state.md +20 -0
- package/docs/fixture-ownership.md +1 -0
- package/docs/workflow-shared-contracts.md +2 -1
- package/docs/workflow-usage-guide.md +7 -1
- package/evals/ci/run-baseline.sh +10 -0
- package/evals/fixtures/assignment-provider/actor-a.json +6 -0
- package/evals/fixtures/assignment-provider/actor-b.json +6 -0
- package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
- package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
- package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
- package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
- package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
- package/evals/integration/test_assignment_provider_github.sh +318 -0
- package/evals/integration/test_assignment_provider_local_file.sh +222 -0
- package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
- package/evals/integration/test_current_json_per_actor.sh +516 -0
- package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_gate_lockdown.sh +10 -0
- package/evals/integration/test_publish_delivery.sh +21 -4
- package/evals/integration/test_pull_work_assignment_join.sh +132 -0
- package/evals/integration/test_pull_work_liveness_preflight.sh +20 -7
- package/evals/integration/test_reconcile_soundness.sh +33 -9
- package/evals/integration/test_trust_reconcile.sh +9 -8
- package/evals/integration/test_trust_reconcile_negatives.sh +608 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +91 -3
- package/evals/run.sh +14 -0
- package/evals/static/test_flowdef_codeowners_coverage.sh +6 -0
- package/evals/static/test_knowledge_providers.sh +32 -0
- package/evals/static/test_workflow_skills.sh +15 -2
- package/kits/builder/skills/deliver/SKILL.md +36 -0
- package/kits/builder/skills/design-probe/SKILL.md +37 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
- package/kits/builder/skills/pull-work/SKILL.md +78 -10
- package/kits/knowledge/adapters/default-store/index.js +92 -4
- package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
- package/kits/knowledge/adapters/shared/codec.js +141 -0
- package/kits/knowledge/docs/README.md +121 -2
- package/kits/knowledge/docs/store-contract.md +112 -4
- package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
- package/kits/knowledge/flows/promote.flow.json +84 -0
- package/kits/knowledge/kit.json +50 -0
- package/kits/knowledge/promote/distill.js +96 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
- package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
- package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
- package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
- package/kits/knowledge/promote/fixtures/session/state.json +19 -0
- package/kits/knowledge/promote/health.js +137 -0
- package/kits/knowledge/promote/index.js +176 -0
- package/kits/knowledge/promote/ingest.js +103 -0
- package/kits/knowledge/promote/lib.js +132 -0
- package/kits/knowledge/promote/link.js +84 -0
- package/kits/knowledge/promote/promote.test.js +174 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
- package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
- package/kits/knowledge/providers/conformance/suite.test.js +143 -0
- package/kits/knowledge/providers/git-repo/index.js +236 -0
- package/kits/knowledge/providers/health/health-pass.test.js +99 -0
- package/kits/knowledge/providers/health/index.js +153 -0
- package/kits/knowledge/providers/index.js +25 -0
- package/kits/knowledge/providers/lib/model.js +91 -0
- package/kits/knowledge/providers/lib/schema-validate.js +119 -0
- package/kits/knowledge/providers/markdown-vault/index.js +169 -0
- package/kits/knowledge/providers/neo4j/connection.js +121 -0
- package/kits/knowledge/providers/neo4j/cypher.js +190 -0
- package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
- package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
- package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
- package/kits/knowledge/providers/neo4j/index.js +280 -0
- package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
- package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
- package/kits/knowledge/providers/neo4j/sync.js +235 -0
- package/kits/knowledge/providers/work-item/index.js +204 -0
- package/package.json +7 -3
- package/schemas/assignment-provider-settings.schema.json +125 -0
- package/schemas/knowledge/edge.schema.json +54 -0
- package/schemas/knowledge/health-report.schema.json +45 -0
- package/schemas/knowledge/node.schema.json +49 -0
- package/schemas/knowledge/proposal.schema.json +53 -0
- package/scripts/README.md +1 -0
- package/scripts/ci/trust-reconcile.js +521 -24
- package/scripts/freeze-adrs.mjs +364 -0
- package/scripts/hooks/config-protection.js +14 -1
- package/scripts/hooks/evidence-capture.js +4 -1
- package/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/scripts/hooks/lib/current-pointer.js +123 -0
- package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
- package/scripts/hooks/stop-goal-fit.js +4 -2
- package/scripts/hooks/workflow-steering.js +42 -0
- package/scripts/statusline/flow-agents-statusline.js +3 -1
- package/src/cli/assignment-provider.ts +927 -0
- package/src/cli/effective-assignment-provider-settings.ts +112 -0
- package/src/cli/validate-workflow-artifacts.ts +5 -1
- package/src/cli/workflow-sidecar.ts +577 -139
- package/src/cli.ts +6 -0
- package/src/lib/flow-resolver.ts +35 -14
- package/src/tools/validate-source-tree.ts +2 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, dependency-free JSON-Schema validator.
|
|
3
|
+
*
|
|
4
|
+
* Supports exactly the draft-07 subset used by the Knowledge Kit graph schemas
|
|
5
|
+
* (schemas/knowledge/*.schema.json): type, required, properties,
|
|
6
|
+
* additionalProperties:false, enum, const, items, minLength, minItems, integer,
|
|
7
|
+
* and local `$ref` into `#/$defs/*`. It is intentionally NOT a general validator
|
|
8
|
+
* — it exists so health reports and conformance tests can assert schema validity
|
|
9
|
+
* without pulling an external dependency (the same zero-dep discipline the
|
|
10
|
+
* decision-registry validator follows).
|
|
11
|
+
*
|
|
12
|
+
* @module providers/lib/schema-validate
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function typeOf(value) {
|
|
16
|
+
if (value === null) return "null";
|
|
17
|
+
if (Array.isArray(value)) return "array";
|
|
18
|
+
return typeof value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function matchesType(value, type) {
|
|
22
|
+
if (type === "integer") return typeof value === "number" && Number.isInteger(value);
|
|
23
|
+
if (type === "number") return typeof value === "number";
|
|
24
|
+
if (type === "object") return typeOf(value) === "object";
|
|
25
|
+
if (type === "array") return Array.isArray(value);
|
|
26
|
+
return typeOf(value) === type;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveRef(ref, root) {
|
|
30
|
+
if (!ref.startsWith("#/")) throw new Error(`Unsupported $ref (only local #/ refs): ${ref}`);
|
|
31
|
+
const parts = ref.slice(2).split("/");
|
|
32
|
+
let node = root;
|
|
33
|
+
for (const part of parts) {
|
|
34
|
+
node = node && node[part];
|
|
35
|
+
}
|
|
36
|
+
if (!node) throw new Error(`Unresolved $ref: ${ref}`);
|
|
37
|
+
return node;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validateNode(value, schema, root, pathStr, errors) {
|
|
41
|
+
if (schema.$ref) {
|
|
42
|
+
return validateNode(value, resolveRef(schema.$ref, root), root, pathStr, errors);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if ("const" in schema) {
|
|
46
|
+
if (value !== schema.const) {
|
|
47
|
+
errors.push(`${pathStr}: expected const ${JSON.stringify(schema.const)}, got ${JSON.stringify(value)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (schema.enum) {
|
|
52
|
+
if (!schema.enum.includes(value)) {
|
|
53
|
+
errors.push(`${pathStr}: value ${JSON.stringify(value)} not in enum ${JSON.stringify(schema.enum)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (schema.type) {
|
|
58
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
59
|
+
if (!types.some((t) => matchesType(value, t))) {
|
|
60
|
+
errors.push(`${pathStr}: expected type ${types.join("|")}, got ${typeOf(value)}`);
|
|
61
|
+
return; // further checks assume the type held
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (typeof value === "string") {
|
|
66
|
+
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
|
|
67
|
+
errors.push(`${pathStr}: string shorter than minLength ${schema.minLength}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
if (typeof schema.minItems === "number" && value.length < schema.minItems) {
|
|
73
|
+
errors.push(`${pathStr}: array shorter than minItems ${schema.minItems}`);
|
|
74
|
+
}
|
|
75
|
+
if (schema.items) {
|
|
76
|
+
value.forEach((item, i) => validateNode(item, schema.items, root, `${pathStr}[${i}]`, errors));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (typeOf(value) === "object") {
|
|
81
|
+
const props = schema.properties || {};
|
|
82
|
+
for (const req of schema.required || []) {
|
|
83
|
+
if (!(req in value)) errors.push(`${pathStr}: missing required property '${req}'`);
|
|
84
|
+
}
|
|
85
|
+
if (schema.additionalProperties === false) {
|
|
86
|
+
for (const key of Object.keys(value)) {
|
|
87
|
+
if (!(key in props)) errors.push(`${pathStr}: unexpected property '${key}'`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const [key, propSchema] of Object.entries(props)) {
|
|
91
|
+
if (key in value) {
|
|
92
|
+
validateNode(value[key], propSchema, root, `${pathStr}/${key}`, errors);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Validate `value` against `schema`.
|
|
100
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
101
|
+
*/
|
|
102
|
+
export function validate(value, schema) {
|
|
103
|
+
const errors = [];
|
|
104
|
+
validateNode(value, schema, schema, "$", errors);
|
|
105
|
+
return { valid: errors.length === 0, errors };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Assert validity, throwing a descriptive error when invalid. Handy in tests.
|
|
110
|
+
*/
|
|
111
|
+
export function assertValid(value, schema, label = "value") {
|
|
112
|
+
const { valid, errors } = validate(value, schema);
|
|
113
|
+
if (!valid) {
|
|
114
|
+
const err = new Error(`${label} failed schema validation:\n ${errors.join("\n ")}`);
|
|
115
|
+
err.code = "SCHEMA_INVALID";
|
|
116
|
+
err.errors = errors;
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown-vault knowledge-store provider.
|
|
3
|
+
*
|
|
4
|
+
* Adapts the existing Knowledge Kit store (the markdown-vault behaviour:
|
|
5
|
+
* markdown files + YAML frontmatter + [[wikilinks]] + a JSON graph index) to
|
|
6
|
+
* the knowledge-store PROVIDER contract (typed nodes/edges/provenance). It is a
|
|
7
|
+
* thin READ + PROPOSE wrapper over an existing store adapter instance — it does
|
|
8
|
+
* NOT reimplement or modify the adapter, so existing Knowledge Kit skills are
|
|
9
|
+
* unaffected. Wikilinks/frontmatter remain the native form; proposeWrite renders
|
|
10
|
+
* a new note in exactly that form for a human to file.
|
|
11
|
+
*
|
|
12
|
+
* @module providers/markdown-vault
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { node, edge, proposal, provenance } from "../lib/model.js";
|
|
16
|
+
import DefaultKnowledgeStore from "../../adapters/default-store/index.js";
|
|
17
|
+
|
|
18
|
+
const PROVIDER_ID = "markdown-vault";
|
|
19
|
+
|
|
20
|
+
/** Vault record type -> graph node type. Extensible: unknowns fall back to note. */
|
|
21
|
+
function nodeTypeFor(recordType) {
|
|
22
|
+
return recordType === "person" ? "person" : "note";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Vault link kind -> closed graph edge type. */
|
|
26
|
+
function edgeTypeFor(linkKind) {
|
|
27
|
+
switch (linkKind) {
|
|
28
|
+
case "supersedes":
|
|
29
|
+
case "refines":
|
|
30
|
+
return "supersedes";
|
|
31
|
+
case "source":
|
|
32
|
+
case "example":
|
|
33
|
+
return "evidence-of";
|
|
34
|
+
case "appears-in":
|
|
35
|
+
case "person":
|
|
36
|
+
return "mentions";
|
|
37
|
+
case "proposes":
|
|
38
|
+
case "related":
|
|
39
|
+
default:
|
|
40
|
+
return "relates";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ALL_RECORD_TYPES = ["raw", "compiled", "concept", "snapshot", "person"];
|
|
45
|
+
|
|
46
|
+
export class MarkdownVaultProvider {
|
|
47
|
+
constructor({ store, storeRoot, agent } = {}) {
|
|
48
|
+
if (!store && !storeRoot) {
|
|
49
|
+
throw new Error("MarkdownVaultProvider requires { store } or { storeRoot }");
|
|
50
|
+
}
|
|
51
|
+
this.store = store || new DefaultKnowledgeStore({ storeRoot });
|
|
52
|
+
this.storeRoot = storeRoot || "(injected store)";
|
|
53
|
+
this.agent = agent;
|
|
54
|
+
this.id = PROVIDER_ID;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
capabilities() {
|
|
58
|
+
return {
|
|
59
|
+
id: PROVIDER_ID,
|
|
60
|
+
node_types: ["note", "person"],
|
|
61
|
+
edge_types: ["supersedes", "evidence-of", "mentions", "relates"],
|
|
62
|
+
writable: false,
|
|
63
|
+
write_mode: "proposals-only",
|
|
64
|
+
proposal_targets: ["create-node"],
|
|
65
|
+
source_of_truth: "human-curated markdown vault",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async _allRecords() {
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const t of ALL_RECORD_TYPES) {
|
|
72
|
+
const recs = await this.store.listByType(t, { includeRetired: true });
|
|
73
|
+
for (const r of recs) out.push(r);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async readNodes(options = {}) {
|
|
79
|
+
const records = await this._allRecords();
|
|
80
|
+
const nodes = records.map((r) => {
|
|
81
|
+
const attributes = { record_type: r.type };
|
|
82
|
+
if (r.category) attributes.category = r.category;
|
|
83
|
+
if (r.status) attributes.status = r.status;
|
|
84
|
+
if (Array.isArray(r.tags) && r.tags.length) attributes.tags = r.tags;
|
|
85
|
+
return node({
|
|
86
|
+
id: r.id,
|
|
87
|
+
type: nodeTypeFor(r.type),
|
|
88
|
+
title: r.title,
|
|
89
|
+
body: typeof r.body === "string" ? r.body : "",
|
|
90
|
+
attributes,
|
|
91
|
+
provenance: provenance({
|
|
92
|
+
provider: PROVIDER_ID,
|
|
93
|
+
source: `${this.storeRoot}#${r.id}`,
|
|
94
|
+
locator: r.type,
|
|
95
|
+
agent: this.agent,
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
if (options.type) return nodes.filter((n) => n.type === options.type);
|
|
100
|
+
return nodes;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async readEdges() {
|
|
104
|
+
const records = await this._allRecords();
|
|
105
|
+
const edges = [];
|
|
106
|
+
for (const r of records) {
|
|
107
|
+
const links = await this.store.getLinks(r.id);
|
|
108
|
+
for (const l of links.forward || []) {
|
|
109
|
+
edges.push(
|
|
110
|
+
edge({
|
|
111
|
+
id: `${r.id}--${l.kind}--${l.target_id}`,
|
|
112
|
+
type: edgeTypeFor(l.kind),
|
|
113
|
+
from: r.id,
|
|
114
|
+
to: l.target_id,
|
|
115
|
+
attributes: { vault_link_kind: l.kind },
|
|
116
|
+
provenance: provenance({
|
|
117
|
+
provider: PROVIDER_ID,
|
|
118
|
+
source: `${this.storeRoot}#${r.id}`,
|
|
119
|
+
locator: `links.${l.kind}`,
|
|
120
|
+
agent: this.agent,
|
|
121
|
+
}),
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return edges;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async queryByType(type) {
|
|
130
|
+
return this.readNodes({ type });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async readGraph() {
|
|
134
|
+
const [nodes, edges] = await Promise.all([this.readNodes(), this.readEdges()]);
|
|
135
|
+
return { nodes, edges };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Propose a new vault note. Renders the native markdown-vault form
|
|
140
|
+
* (frontmatter + [[wikilinks]]) WITHOUT touching the store — proposals only.
|
|
141
|
+
*/
|
|
142
|
+
async proposeWrite(intent = {}) {
|
|
143
|
+
const { title, body = "", category = "inbox", tags = [], links = [] } = intent;
|
|
144
|
+
if (!title) throw new Error("markdown-vault proposeWrite requires intent.title");
|
|
145
|
+
const wikilinks = links.map((l) => `[[${l.target_id}${l.label ? `|${l.label}` : ""}]]`).join(" ");
|
|
146
|
+
const fm = [
|
|
147
|
+
"---",
|
|
148
|
+
"type: compiled",
|
|
149
|
+
`title: ${title}`,
|
|
150
|
+
`category: ${category}`,
|
|
151
|
+
tags.length ? `tags: [${tags.join(", ")}]` : "tags: []",
|
|
152
|
+
"---",
|
|
153
|
+
"",
|
|
154
|
+
body,
|
|
155
|
+
wikilinks ? `\n${wikilinks}` : "",
|
|
156
|
+
].join("\n");
|
|
157
|
+
return proposal({
|
|
158
|
+
provider: PROVIDER_ID,
|
|
159
|
+
kind: "create-node",
|
|
160
|
+
target: { store_root: this.storeRoot, category },
|
|
161
|
+
payload: { type: "note", title, body, category, tags, links },
|
|
162
|
+
rendered: fm,
|
|
163
|
+
rationale: intent.rationale || "Proposed vault note (file it via the Knowledge Kit ingest/compile flow).",
|
|
164
|
+
provenance: provenance({ provider: PROVIDER_ID, source: this.storeRoot, agent: this.agent }),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export default MarkdownVaultProvider;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection + selection for the neo4j knowledge-store provider.
|
|
3
|
+
*
|
|
4
|
+
* Credentials are resolved BY REFERENCE, never hardcoded: the standard
|
|
5
|
+
* neo4j-driver environment variables NEO4J_URI / NEO4J_USER / NEO4J_PASSWORD.
|
|
6
|
+
* (This repo does not yet consume @kontourai/datum — PR #365's model-routing
|
|
7
|
+
* adoption stayed config-file based — so we follow the existing env-reference
|
|
8
|
+
* precedent rather than inventing a datum coupling. If/when datum lands, resolve
|
|
9
|
+
* these three names through it without touching call sites.)
|
|
10
|
+
*
|
|
11
|
+
* The driver is a LAZY, OPTIONAL dependency: neo4j-driver is an
|
|
12
|
+
* optionalDependency, imported on first use inside a try/catch. A missing module
|
|
13
|
+
* OR an unreachable server both degrade to the file providers (AC4) — the graph
|
|
14
|
+
* is never a hard dependency of any repo or workflow.
|
|
15
|
+
*
|
|
16
|
+
* @module providers/neo4j/connection
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve connection config by reference. Returns { uri, user, password,
|
|
21
|
+
* database } or null when NEO4J_URI is unset (the "no graph configured" signal).
|
|
22
|
+
* @param {object} [env=process.env]
|
|
23
|
+
*/
|
|
24
|
+
export function resolveNeo4jConfig(env = process.env) {
|
|
25
|
+
const uri = env.NEO4J_URI;
|
|
26
|
+
if (!uri) return null;
|
|
27
|
+
return {
|
|
28
|
+
uri,
|
|
29
|
+
user: env.NEO4J_USER || "neo4j",
|
|
30
|
+
password: env.NEO4J_PASSWORD || "",
|
|
31
|
+
database: env.NEO4J_DATABASE || "neo4j",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Lazily import neo4j-driver and construct a driver. Returns null if the module
|
|
37
|
+
* is not installed (optional dependency absent) — the caller degrades.
|
|
38
|
+
* @param {object} config from resolveNeo4jConfig
|
|
39
|
+
*/
|
|
40
|
+
export async function createDriver(config) {
|
|
41
|
+
if (!config) return null;
|
|
42
|
+
let neo4j;
|
|
43
|
+
try {
|
|
44
|
+
neo4j = (await import("neo4j-driver")).default;
|
|
45
|
+
} catch {
|
|
46
|
+
return null; // optional dependency not installed -> degrade
|
|
47
|
+
}
|
|
48
|
+
const driver = neo4j.driver(config.uri, neo4j.auth.basic(config.user, config.password));
|
|
49
|
+
// Real (non-fake) driver: the live Cypher analytics backend is available.
|
|
50
|
+
driver.supportsCypherAnalytics = true;
|
|
51
|
+
driver._kgDatabase = config.database;
|
|
52
|
+
return driver;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Probe whether a Neo4j server is reachable with these credentials. Never
|
|
57
|
+
* throws — returns false on any failure. Used by selectKnowledgeProvider so a
|
|
58
|
+
* down/absent server degrades cleanly instead of erroring.
|
|
59
|
+
* @param {object} driver
|
|
60
|
+
*/
|
|
61
|
+
export async function isReachable(driver, { timeoutMs = 4000 } = {}) {
|
|
62
|
+
if (!driver) return false;
|
|
63
|
+
try {
|
|
64
|
+
await withTimeout(driver.verifyConnectivity(), timeoutMs);
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function withTimeout(promise, ms) {
|
|
72
|
+
return Promise.race([
|
|
73
|
+
promise,
|
|
74
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms).unref?.()),
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Select the knowledge provider set. The default is the file providers; the
|
|
80
|
+
* neo4j graph is an OPT-IN personal default. Selection is config-driven:
|
|
81
|
+
* `preference` (from repo/user config or KNOWLEDGE_PROVIDER env) chooses neo4j;
|
|
82
|
+
* anything else, or an unreachable/absent Neo4j, falls back to the file
|
|
83
|
+
* providers with a single clear message (AC4).
|
|
84
|
+
*
|
|
85
|
+
* @param {object} options
|
|
86
|
+
* @param {"neo4j"|"file"} [options.preference] explicit preference (default from env)
|
|
87
|
+
* @param {() => object[]|Promise<object[]>} options.fileProviders factory for the file provider set
|
|
88
|
+
* @param {(cfg:object)=>Promise<object>} [options.neo4jFactory] builds the neo4j provider (driver injected)
|
|
89
|
+
* @param {object} [options.env=process.env]
|
|
90
|
+
* @param {(msg:string)=>void} [options.log] one-line status sink (default console.error)
|
|
91
|
+
* @returns {Promise<{ provider:string, providers:object[], graph:object|null, message:string }>}
|
|
92
|
+
*/
|
|
93
|
+
export async function selectKnowledgeProvider(options = {}) {
|
|
94
|
+
const env = options.env || process.env;
|
|
95
|
+
const log = options.log || ((m) => console.error(m));
|
|
96
|
+
const preference = options.preference || env.KNOWLEDGE_PROVIDER || "file";
|
|
97
|
+
|
|
98
|
+
const fallback = async (message) => {
|
|
99
|
+
log(message);
|
|
100
|
+
return { provider: "file", providers: await options.fileProviders(), graph: null, message };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
if (preference !== "neo4j") {
|
|
104
|
+
return { provider: "file", providers: await options.fileProviders(), graph: null, message: "" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const config = resolveNeo4jConfig(env);
|
|
108
|
+
if (!config) {
|
|
109
|
+
return fallback("knowledge: neo4j selected but NEO4J_URI is unset — using file providers.");
|
|
110
|
+
}
|
|
111
|
+
const driver = options.driver || (await createDriver(config));
|
|
112
|
+
if (!driver) {
|
|
113
|
+
return fallback("knowledge: neo4j-driver not installed — using file providers (run `npm install neo4j-driver`).");
|
|
114
|
+
}
|
|
115
|
+
if (!(await isReachable(driver))) {
|
|
116
|
+
await driver.close?.();
|
|
117
|
+
return fallback(`knowledge: no Neo4j reachable at ${config.uri} — using file providers.`);
|
|
118
|
+
}
|
|
119
|
+
const provider = await options.neo4jFactory({ driver, database: config.database });
|
|
120
|
+
return { provider: "neo4j", providers: [provider], graph: provider, message: `knowledge: neo4j provider selected (${config.uri}).` };
|
|
121
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cypher for the neo4j knowledge-store provider.
|
|
3
|
+
*
|
|
4
|
+
* Two responsibilities:
|
|
5
|
+
* 1. The label/relationship mapping between the storage-independent graph model
|
|
6
|
+
* (contract node `type` / edge `type`) and Neo4j's labelled property graph,
|
|
7
|
+
* plus the read/merge statements the provider and sync command issue.
|
|
8
|
+
* 2. The five CANONICAL health/analysis queries from the #318 spike
|
|
9
|
+
* (docs/spikes/graph-provider-2026-07.md §3), written in the Neo4j dialect.
|
|
10
|
+
* The spike found ~80% of Cypher portable and isolated the three divergent
|
|
11
|
+
* areas (existence predicates, string/list stdlib incl. 0- vs 1-indexed
|
|
12
|
+
* substring, shortest-path/path-projection). These strings are the Neo4j
|
|
13
|
+
* spelling; graph-queries.js is the portable JS spelling used in degraded
|
|
14
|
+
* mode. The live integration test asserts the two agree.
|
|
15
|
+
*
|
|
16
|
+
* Nodes are stored one Neo4j label per contract type (Issue, Decision, Note,
|
|
17
|
+
* Session, Person, …) with a flat property bag; nested `attributes`/`provenance`
|
|
18
|
+
* are serialised to `*_json` strings (Neo4j properties cannot nest) and a few
|
|
19
|
+
* scalars (`state`, `status`) are promoted for queryability, matching the
|
|
20
|
+
* property catalogue the spike's Neo4j Browser panel showed.
|
|
21
|
+
*
|
|
22
|
+
* @module providers/neo4j/cypher
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Sanitise a contract node type into a Neo4j label (PascalCase, alnum only). */
|
|
26
|
+
export function typeToLabel(type) {
|
|
27
|
+
const clean = String(type || "node").replace(/[^A-Za-z0-9]+/g, " ").trim();
|
|
28
|
+
if (!clean) return "Node";
|
|
29
|
+
return clean
|
|
30
|
+
.split(/\s+/)
|
|
31
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
32
|
+
.join("");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Sanitise a closed-vocabulary edge type into a Neo4j relationship type. */
|
|
36
|
+
export function edgeTypeToRel(type) {
|
|
37
|
+
return String(type || "relates").toUpperCase().replace(/[^A-Z0-9]+/g, "_");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Reserved label for the sync snapshot metadata node (never part of the graph). */
|
|
41
|
+
export const SNAPSHOT_LABEL = "_KGSnapshot";
|
|
42
|
+
export const SNAPSHOT_ID = "current";
|
|
43
|
+
|
|
44
|
+
/** Serialise a contract node into a flat Neo4j property bag. */
|
|
45
|
+
export function nodeToProps(n, hash) {
|
|
46
|
+
const props = {
|
|
47
|
+
id: n.id,
|
|
48
|
+
type: n.type,
|
|
49
|
+
title: n.title,
|
|
50
|
+
body: n.body || "",
|
|
51
|
+
attributes_json: JSON.stringify(n.attributes || {}),
|
|
52
|
+
provenance_json: JSON.stringify(n.provenance || {}),
|
|
53
|
+
_kg_hash: hash,
|
|
54
|
+
};
|
|
55
|
+
// Promote a few queryable scalars (matches the spike's property catalogue).
|
|
56
|
+
const attrs = n.attributes || {};
|
|
57
|
+
if (attrs.state != null) props.state = String(attrs.state);
|
|
58
|
+
if (attrs.status != null) props.status = String(attrs.status);
|
|
59
|
+
if (attrs.registry_status != null) props.status = String(attrs.registry_status);
|
|
60
|
+
return props;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Rebuild a contract node from a Neo4j property bag. */
|
|
64
|
+
export function propsToNode(props) {
|
|
65
|
+
const n = {
|
|
66
|
+
id: props.id,
|
|
67
|
+
type: props.type,
|
|
68
|
+
title: props.title,
|
|
69
|
+
provenance: safeParse(props.provenance_json, {}),
|
|
70
|
+
};
|
|
71
|
+
if (props.body) n.body = props.body;
|
|
72
|
+
const attrs = safeParse(props.attributes_json, {});
|
|
73
|
+
if (attrs && Object.keys(attrs).length) n.attributes = attrs;
|
|
74
|
+
return n;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Serialise a contract edge into a flat Neo4j property bag. */
|
|
78
|
+
export function edgeToProps(e, hash) {
|
|
79
|
+
const props = {
|
|
80
|
+
id: e.id,
|
|
81
|
+
type: e.type,
|
|
82
|
+
from: e.from,
|
|
83
|
+
to: e.to,
|
|
84
|
+
resolved: e.resolved === false ? false : true,
|
|
85
|
+
attributes_json: JSON.stringify(e.attributes || {}),
|
|
86
|
+
provenance_json: JSON.stringify(e.provenance || {}),
|
|
87
|
+
_kg_hash: hash,
|
|
88
|
+
};
|
|
89
|
+
return props;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Rebuild a contract edge from a Neo4j relationship property bag. */
|
|
93
|
+
export function propsToEdge(props) {
|
|
94
|
+
const e = {
|
|
95
|
+
id: props.id,
|
|
96
|
+
type: props.type,
|
|
97
|
+
from: props.from,
|
|
98
|
+
to: props.to,
|
|
99
|
+
provenance: safeParse(props.provenance_json, {}),
|
|
100
|
+
};
|
|
101
|
+
if (props.resolved === false) e.resolved = false;
|
|
102
|
+
const attrs = safeParse(props.attributes_json, {});
|
|
103
|
+
if (attrs && Object.keys(attrs).length) e.attributes = attrs;
|
|
104
|
+
return e;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function safeParse(s, fallback) {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(s);
|
|
110
|
+
} catch {
|
|
111
|
+
return fallback;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- fixed read statements ------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/** All graph nodes (excludes the snapshot metadata node). */
|
|
118
|
+
export const READ_NODES_CYPHER = `MATCH (n) WHERE NOT n:${SNAPSHOT_LABEL} RETURN properties(n) AS props`;
|
|
119
|
+
|
|
120
|
+
/** All graph relationships as flat property bags. */
|
|
121
|
+
export const READ_EDGES_CYPHER = `MATCH ()-[r]->() RETURN properties(r) AS props`;
|
|
122
|
+
|
|
123
|
+
/** The recorded sync snapshot (digest + timestamp), or null. */
|
|
124
|
+
export const READ_SNAPSHOT_CYPHER = `MATCH (s:${SNAPSHOT_LABEL} {id:$id}) RETURN properties(s) AS props`;
|
|
125
|
+
|
|
126
|
+
// --- canonical queries (Neo4j dialect, from the spike) --------------------
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The five canonical queries as Neo4j Cypher. `blocks` is stored from→to
|
|
130
|
+
* ("from must complete before to"), so a node's transitive blockers are reached
|
|
131
|
+
* by following INCOMING :BLOCKS. Placeholders are $-parameters (never string
|
|
132
|
+
* interpolation — the spike's escaping lesson).
|
|
133
|
+
*/
|
|
134
|
+
export const CANONICAL_CYPHER = Object.freeze({
|
|
135
|
+
// Q1 — transitive blocker closure (spike: variable-length *1..N, portable).
|
|
136
|
+
transitiveBlockers: `MATCH (b:Issue)-[:BLOCKS*1..$maxDepth]->(x:Issue {id:$rootId})
|
|
137
|
+
RETURN DISTINCT b.id AS id, b.state AS state ORDER BY b.id`,
|
|
138
|
+
|
|
139
|
+
// Q2 — contradiction candidates: decisions sharing a subject with divergent
|
|
140
|
+
// status (the ADR-0007 numbering-collision failure mode).
|
|
141
|
+
contradictionCandidates: `MATCH (a:Decision),(b:Decision)
|
|
142
|
+
WHERE a.id < b.id AND a.title = b.title AND coalesce(a.status,'') <> coalesce(b.status,'')
|
|
143
|
+
RETURN a.id AS a_id, b.id AS b_id, a.status AS a_status, b.status AS b_status, a.title AS title`,
|
|
144
|
+
|
|
145
|
+
// Q3 — orphans, node-type aware (spike Q3: leaf types are source-only; the
|
|
146
|
+
// meaningful query is issues/decisions with no inbound edge).
|
|
147
|
+
orphans: `MATCH (n) WHERE NOT n:${SNAPSHOT_LABEL} AND NOT ( ()-->(n) )
|
|
148
|
+
AND NOT n:Session AND NOT n:Pr AND NOT n:Learning
|
|
149
|
+
RETURN labels(n)[0] AS label, n.id AS id ORDER BY id`,
|
|
150
|
+
|
|
151
|
+
// Q4 — duplicate candidates WITH 5-char-prefix stemming (spike Q4: exact
|
|
152
|
+
// tokens missed traverse#14~#8; substring() is 0-indexed in Neo4j).
|
|
153
|
+
duplicateStemmed: `MATCH (a),(b) WHERE a.id < b.id AND labels(a)[0] = labels(b)[0]
|
|
154
|
+
WITH a, b,
|
|
155
|
+
[w IN split(toLower(a.title),' ') WHERE size(w)>=5 | substring(w,0,5)] AS sa,
|
|
156
|
+
[w IN split(toLower(b.title),' ') WHERE size(w)>=5 | substring(w,0,5)] AS sb
|
|
157
|
+
WITH a, b, sa, sb, size([w IN sa WHERE w IN sb]) AS inter
|
|
158
|
+
WHERE inter > 0 AND toFloat(inter)/size(sa + [w IN sb WHERE NOT w IN sa]) >= $threshold
|
|
159
|
+
RETURN a.id AS a_id, b.id AS b_id, a.title AS a_title, b.title AS b_title`,
|
|
160
|
+
|
|
161
|
+
// Q5 — shortest path between two decisions (spike Q5: shortestPath() +
|
|
162
|
+
// list-comprehension projection is the Neo4j spelling).
|
|
163
|
+
shortestPath: `MATCH p=shortestPath((a {id:$fromId})-[*..$maxDepth]-(b {id:$toId}))
|
|
164
|
+
RETURN [n IN nodes(p) | n.id] AS path, length(p) AS length`,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The spike's dialect note, kept with the queries so a future Kuzu/other-engine
|
|
169
|
+
* provider has the divergence map: existence predicates, string/list stdlib
|
|
170
|
+
* (incl. 0- vs 1-indexed substring), and shortest-path/path-projection spelling.
|
|
171
|
+
*/
|
|
172
|
+
export const DIALECT_NOTES = Object.freeze({
|
|
173
|
+
engine: "neo4j-community-5",
|
|
174
|
+
divergent_areas: ["existence-predicates", "string-list-stdlib", "shortest-path-projection"],
|
|
175
|
+
substring_indexing: "0-based",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Neo4j forbids a parameter in a variable-length bound (`*1..$maxDepth`). Bounds
|
|
180
|
+
* must be literals, so we validate maxDepth is a small positive integer and
|
|
181
|
+
* interpolate it — the ONLY interpolation in this module; every value stays a
|
|
182
|
+
* $-parameter (the spike's parameterise-don't-escape lesson).
|
|
183
|
+
*/
|
|
184
|
+
export function renderBounds(cypher, maxDepth) {
|
|
185
|
+
const n = Number(maxDepth);
|
|
186
|
+
if (!Number.isInteger(n) || n < 1 || n > 100) {
|
|
187
|
+
throw new Error(`renderBounds: maxDepth must be an integer in [1,100], got ${maxDepth}`);
|
|
188
|
+
}
|
|
189
|
+
return cypher.replace(/\$maxDepth/g, String(n));
|
|
190
|
+
}
|