@kognai/tool-router 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +47 -2
- package/dist/adapters/awesome-list.d.ts +10 -0
- package/dist/adapters/awesome-list.js +68 -0
- package/dist/adapters/mcp-registry.d.ts +12 -0
- package/dist/adapters/mcp-registry.js +51 -0
- package/dist/adapters/mcp-tools.d.ts +49 -0
- package/dist/adapters/mcp-tools.js +60 -0
- package/dist/adapters/openapi.d.ts +25 -0
- package/dist/adapters/openapi.js +107 -0
- package/dist/adapters/pay-skills.d.ts +13 -0
- package/dist/adapters/pay-skills.js +119 -0
- package/dist/adapters/raw.d.ts +82 -0
- package/dist/adapters/raw.js +5 -0
- package/dist/adapters/x402-discovery.d.ts +6 -0
- package/dist/adapters/x402-discovery.js +100 -0
- package/dist/catalog/file-store.d.ts +27 -0
- package/dist/catalog/file-store.js +102 -0
- package/dist/catalog/ingest.d.ts +53 -0
- package/dist/catalog/ingest.js +276 -0
- package/dist/catalog/lifecycle.d.ts +24 -0
- package/dist/catalog/lifecycle.js +51 -0
- package/dist/catalog/memory-store.d.ts +49 -0
- package/dist/catalog/memory-store.js +92 -0
- package/dist/catalog/risk.d.ts +22 -0
- package/dist/catalog/risk.js +73 -0
- package/dist/catalog/sanitise.d.ts +17 -0
- package/dist/catalog/sanitise.js +109 -0
- package/dist/classify/classifier.d.ts +12 -0
- package/dist/classify/classifier.js +52 -0
- package/dist/classify/decompose.d.ts +10 -0
- package/dist/classify/decompose.js +107 -0
- package/dist/classify/rules.d.ts +46 -0
- package/dist/classify/rules.js +249 -0
- package/dist/classify/tagger.d.ts +5 -0
- package/dist/classify/tagger.js +29 -0
- package/dist/classify/taxonomy.d.ts +31 -0
- package/dist/classify/taxonomy.js +221 -0
- package/dist/classify/text.d.ts +9 -0
- package/dist/classify/text.js +38 -0
- package/dist/eligibility/engine.d.ts +11 -0
- package/dist/eligibility/engine.js +19 -0
- package/dist/eligibility/rules.d.ts +33 -0
- package/dist/eligibility/rules.js +121 -0
- package/dist/flags.d.ts +33 -0
- package/dist/flags.js +34 -0
- package/dist/hash.d.ts +8 -0
- package/dist/hash.js +30 -0
- package/dist/ids.d.ts +17 -0
- package/dist/ids.js +54 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +95 -0
- package/dist/money.d.ts +10 -0
- package/dist/money.js +41 -0
- package/dist/rank/confidence.d.ts +15 -0
- package/dist/rank/confidence.js +48 -0
- package/dist/rank/ranker.d.ts +34 -0
- package/dist/rank/ranker.js +81 -0
- package/dist/rank/weights.d.ts +40 -0
- package/dist/rank/weights.js +46 -0
- package/dist/retrieve/bm25.d.ts +21 -0
- package/dist/retrieve/bm25.js +59 -0
- package/dist/retrieve/capability.d.ts +1 -0
- package/dist/retrieve/capability.js +13 -0
- package/dist/retrieve/hash-embed.d.ts +4 -0
- package/dist/retrieve/hash-embed.js +41 -0
- package/dist/retrieve/hybrid.d.ts +74 -0
- package/dist/retrieve/hybrid.js +125 -0
- package/dist/retrieve/schema-fit.d.ts +9 -0
- package/dist/retrieve/schema-fit.js +38 -0
- package/dist/retrieve/tokenise.d.ts +2 -0
- package/dist/retrieve/tokenise.js +51 -0
- package/dist/retrieve/vector.d.ts +18 -0
- package/dist/retrieve/vector.js +47 -0
- package/dist/router.d.ts +37 -0
- package/dist/router.js +114 -0
- package/dist/schema/tool.schema.json +117 -0
- package/dist/schema/validate.d.ts +9 -0
- package/dist/schema/validate.js +92 -0
- package/dist/shortlist/generate.d.ts +21 -0
- package/dist/shortlist/generate.js +75 -0
- package/dist/types/ports.d.ts +71 -0
- package/dist/types/ports.js +2 -0
- package/dist/types/route.d.ts +121 -0
- package/dist/types/route.js +5 -0
- package/dist/types/tool.d.ts +123 -0
- package/dist/types/tool.js +17 -0
- package/package.json +39 -4
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust state and lifecycle transitions (plan.md S1, threat T2). quarantined -> reviewed -> verified; any state ->
|
|
3
|
+
* revoked; revoked is terminal. A hash change on a reviewed or verified external row forces it back to quarantined.
|
|
4
|
+
* Native Kognai rows start verified at ingest (code owned and reviewed in the repo); that is an initial state, not a
|
|
5
|
+
* transition. Who may move an outside tool to verified is the CTO desk (decision D10): `by` is recorded on every event.
|
|
6
|
+
*/
|
|
7
|
+
import type { Lifecycle, ToolRecord, TrustState } from '../types/tool';
|
|
8
|
+
export declare function canTransition(from: TrustState, to: TrustState): boolean;
|
|
9
|
+
export declare function canMoveLifecycle(from: Lifecycle, to: Lifecycle): boolean;
|
|
10
|
+
/** The authority allowed to move an outside tool into reviewed or verified (decision D10). */
|
|
11
|
+
export declare const VERIFY_AUTHORITY = "cto-desk";
|
|
12
|
+
/** Checks a requested trust move, including who asks. Returns null when allowed, else the reason. */
|
|
13
|
+
export declare function trustMoveProblem(rec: Pick<ToolRecord, 'origin' | 'trust_state'>, to: TrustState, by: string): string | null;
|
|
14
|
+
/**
|
|
15
|
+
* Hash drift: when a reviewed or verified row from outside Kognai arrives with a different schema or implementation
|
|
16
|
+
* hash, the new row is quarantined. Native Kognai rows keep their state (their code is in the repo).
|
|
17
|
+
*/
|
|
18
|
+
export declare function trustAfterDrift(prev: Pick<ToolRecord, 'origin' | 'trust_state' | 'schema_hash' | 'implementation_hash'>, next: {
|
|
19
|
+
schema_hash: string;
|
|
20
|
+
implementation_hash: string | null;
|
|
21
|
+
}): {
|
|
22
|
+
trust: TrustState;
|
|
23
|
+
drifted: boolean;
|
|
24
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VERIFY_AUTHORITY = void 0;
|
|
4
|
+
exports.canTransition = canTransition;
|
|
5
|
+
exports.canMoveLifecycle = canMoveLifecycle;
|
|
6
|
+
exports.trustMoveProblem = trustMoveProblem;
|
|
7
|
+
exports.trustAfterDrift = trustAfterDrift;
|
|
8
|
+
const TRUST = {
|
|
9
|
+
quarantined: ['reviewed', 'revoked'],
|
|
10
|
+
reviewed: ['verified', 'quarantined', 'deprecated', 'revoked'],
|
|
11
|
+
verified: ['quarantined', 'deprecated', 'revoked'],
|
|
12
|
+
deprecated: ['revoked'],
|
|
13
|
+
revoked: [],
|
|
14
|
+
};
|
|
15
|
+
function canTransition(from, to) {
|
|
16
|
+
return TRUST[from]?.includes(to) ?? false;
|
|
17
|
+
}
|
|
18
|
+
const LIFE = {
|
|
19
|
+
draft: ['active', 'retired'],
|
|
20
|
+
active: ['deprecated', 'retired'],
|
|
21
|
+
deprecated: ['retired', 'active'],
|
|
22
|
+
retired: [],
|
|
23
|
+
};
|
|
24
|
+
function canMoveLifecycle(from, to) {
|
|
25
|
+
return LIFE[from]?.includes(to) ?? false;
|
|
26
|
+
}
|
|
27
|
+
/** The authority allowed to move an outside tool into reviewed or verified (decision D10). */
|
|
28
|
+
exports.VERIFY_AUTHORITY = 'cto-desk';
|
|
29
|
+
/** Checks a requested trust move, including who asks. Returns null when allowed, else the reason. */
|
|
30
|
+
function trustMoveProblem(rec, to, by) {
|
|
31
|
+
if (!canTransition(rec.trust_state, to))
|
|
32
|
+
return `${rec.trust_state} -> ${to} is not an allowed transition`;
|
|
33
|
+
if ((to === 'verified' || to === 'reviewed') && rec.origin !== 'kognai' && by !== exports.VERIFY_AUTHORITY) {
|
|
34
|
+
return `only ${exports.VERIFY_AUTHORITY} may mark an outside tool ${to} (decision D10)`;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Hash drift: when a reviewed or verified row from outside Kognai arrives with a different schema or implementation
|
|
40
|
+
* hash, the new row is quarantined. Native Kognai rows keep their state (their code is in the repo).
|
|
41
|
+
*/
|
|
42
|
+
function trustAfterDrift(prev, next) {
|
|
43
|
+
const drifted = prev.schema_hash !== next.schema_hash || (prev.implementation_hash ?? null) !== (next.implementation_hash ?? null);
|
|
44
|
+
if (prev.trust_state === 'revoked')
|
|
45
|
+
return { trust: 'revoked', drifted };
|
|
46
|
+
if (!drifted)
|
|
47
|
+
return { trust: prev.trust_state, drifted };
|
|
48
|
+
if (prev.origin === 'kognai')
|
|
49
|
+
return { trust: prev.trust_state, drifted };
|
|
50
|
+
return { trust: 'quarantined', drifted };
|
|
51
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CatalogStore, TrustEvent } from '../types/ports';
|
|
2
|
+
import type { ToolRecord, TrustState } from '../types/tool';
|
|
3
|
+
export interface IndexEntry {
|
|
4
|
+
id: string;
|
|
5
|
+
version: string;
|
|
6
|
+
schema_hash: string;
|
|
7
|
+
content_sha: string;
|
|
8
|
+
trust_state: TrustState;
|
|
9
|
+
lifecycle: string;
|
|
10
|
+
latest: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Hash of what routing reads from a row: everything but ingestion and retrieval times. */
|
|
13
|
+
export declare const contentSha: (r: ToolRecord) => string;
|
|
14
|
+
export declare class MemoryCatalogStore implements CatalogStore {
|
|
15
|
+
protected rows: Map<string, ToolRecord>;
|
|
16
|
+
protected latestVersion: Map<string, string>;
|
|
17
|
+
protected trustEvents: TrustEvent[];
|
|
18
|
+
protected vecs: Map<string, {
|
|
19
|
+
dims: number;
|
|
20
|
+
ids: string[];
|
|
21
|
+
data: Float32Array;
|
|
22
|
+
}>;
|
|
23
|
+
private snap;
|
|
24
|
+
constructor(rows?: ToolRecord[]);
|
|
25
|
+
protected key: (id: string, version: string) => string;
|
|
26
|
+
index(): IndexEntry[];
|
|
27
|
+
snapshotId(): string;
|
|
28
|
+
all(): Iterable<ToolRecord>;
|
|
29
|
+
latest(): Iterable<ToolRecord>;
|
|
30
|
+
get(id: string, version?: string): ToolRecord | null;
|
|
31
|
+
upsert(rows: ToolRecord[]): {
|
|
32
|
+
added: number;
|
|
33
|
+
versioned: number;
|
|
34
|
+
unchanged: number;
|
|
35
|
+
};
|
|
36
|
+
setTrust(id: string, version: string, to: TrustState, by: string, why: string): void;
|
|
37
|
+
recordEvent(e: TrustEvent): void;
|
|
38
|
+
events(): TrustEvent[];
|
|
39
|
+
vectors(model: string): {
|
|
40
|
+
dims: number;
|
|
41
|
+
ids: string[];
|
|
42
|
+
data: Float32Array;
|
|
43
|
+
} | null;
|
|
44
|
+
setVectors(model: string, v: {
|
|
45
|
+
dims: number;
|
|
46
|
+
ids: string[];
|
|
47
|
+
data: Float32Array;
|
|
48
|
+
}): void;
|
|
49
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MemoryCatalogStore = exports.contentSha = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* In memory CatalogStore: tests, the benchmark, and the base of the file store. Rows are keyed by id and version;
|
|
6
|
+
* `latest` follows ingestion order. The snapshot id hashes the index (id, version, schema hash, content hash, trust,
|
|
7
|
+
* lifecycle), never a timestamp, so the same inputs give the same snapshot id and any change routing can see (a new
|
|
8
|
+
* capability tag, a sanitised description) gives a new one.
|
|
9
|
+
*/
|
|
10
|
+
const hash_1 = require("../hash");
|
|
11
|
+
const lifecycle_1 = require("./lifecycle");
|
|
12
|
+
/** Hash of what routing reads from a row: everything but ingestion and retrieval times. */
|
|
13
|
+
const contentSha = (r) => (0, hash_1.sha256)((0, hash_1.canonical)({ ...r, ingested_at: '', provenance: r.provenance.map((p) => ({ ...p, retrieved_at: '' })) })).slice(0, 16);
|
|
14
|
+
exports.contentSha = contentSha;
|
|
15
|
+
class MemoryCatalogStore {
|
|
16
|
+
rows = new Map();
|
|
17
|
+
latestVersion = new Map();
|
|
18
|
+
trustEvents = [];
|
|
19
|
+
vecs = new Map();
|
|
20
|
+
snap = null;
|
|
21
|
+
constructor(rows = []) { if (rows.length)
|
|
22
|
+
this.upsert(rows); }
|
|
23
|
+
key = (id, version) => `${id}@${version}`;
|
|
24
|
+
index() {
|
|
25
|
+
return [...this.rows.values()]
|
|
26
|
+
.map((r) => ({ id: r.id, version: r.version, schema_hash: r.schema_hash, content_sha: (0, exports.contentSha)(r), trust_state: r.trust_state, lifecycle: r.lifecycle, latest: this.latestVersion.get(r.id) === r.version }))
|
|
27
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : a.version < b.version ? -1 : a.version > b.version ? 1 : 0));
|
|
28
|
+
}
|
|
29
|
+
snapshotId() {
|
|
30
|
+
if (!this.snap)
|
|
31
|
+
this.snap = `cat_${(0, hash_1.sha256)((0, hash_1.canonical)(this.index())).slice(0, 16)}`;
|
|
32
|
+
return this.snap;
|
|
33
|
+
}
|
|
34
|
+
all() { return this.rows.values(); }
|
|
35
|
+
*latest() {
|
|
36
|
+
for (const [id, v] of this.latestVersion) {
|
|
37
|
+
const r = this.rows.get(this.key(id, v));
|
|
38
|
+
if (r)
|
|
39
|
+
yield r;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
get(id, version) {
|
|
43
|
+
const v = version ?? this.latestVersion.get(id);
|
|
44
|
+
return v === undefined ? null : this.rows.get(this.key(id, v)) || null;
|
|
45
|
+
}
|
|
46
|
+
upsert(rows) {
|
|
47
|
+
let added = 0;
|
|
48
|
+
let versioned = 0;
|
|
49
|
+
let unchanged = 0;
|
|
50
|
+
for (const r of rows) {
|
|
51
|
+
const k = this.key(r.id, r.version);
|
|
52
|
+
const existing = this.rows.get(k);
|
|
53
|
+
if (existing) {
|
|
54
|
+
if ((0, hash_1.canonical)({ ...existing, ingested_at: '' }) === (0, hash_1.canonical)({ ...r, ingested_at: '' })) {
|
|
55
|
+
unchanged++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
this.rows.set(k, r);
|
|
59
|
+
unchanged++; // same id and version: metadata refreshed in place (trust, links)
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (this.latestVersion.has(r.id))
|
|
63
|
+
versioned++;
|
|
64
|
+
else
|
|
65
|
+
added++;
|
|
66
|
+
this.rows.set(k, r);
|
|
67
|
+
this.latestVersion.set(r.id, r.version);
|
|
68
|
+
}
|
|
69
|
+
this.snap = null;
|
|
70
|
+
return { added, versioned, unchanged };
|
|
71
|
+
}
|
|
72
|
+
setTrust(id, version, to, by, why) {
|
|
73
|
+
const r = this.get(id, version);
|
|
74
|
+
if (!r)
|
|
75
|
+
throw new Error(`no row ${id}@${version}`);
|
|
76
|
+
const problem = (0, lifecycle_1.trustMoveProblem)(r, to, by);
|
|
77
|
+
if (problem)
|
|
78
|
+
throw new Error(problem);
|
|
79
|
+
this.rows.set(this.key(id, version), { ...r, trust_state: to, last_validated: to === 'verified' ? new Date().toISOString() : r.last_validated });
|
|
80
|
+
this.recordEvent({ at: new Date().toISOString(), id, version, from: r.trust_state, to, by, why });
|
|
81
|
+
this.snap = null;
|
|
82
|
+
}
|
|
83
|
+
recordEvent(e) { this.trustEvents.push(e); }
|
|
84
|
+
events() { return [...this.trustEvents]; }
|
|
85
|
+
vectors(model) { return this.vecs.get(model) || null; }
|
|
86
|
+
setVectors(model, v) {
|
|
87
|
+
if (v.data.length !== v.dims * v.ids.length)
|
|
88
|
+
throw new Error('vector data length does not match ids x dims');
|
|
89
|
+
this.vecs.set(model, v);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
exports.MemoryCatalogStore = MemoryCatalogStore;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Risk classes of an operation, derived at ingestion (plan.md S1 rule 3). Evidence only ever adds classes: unknown
|
|
3
|
+
* side effects add network and data_modification; cue words in the text add the stricter class; nothing in provider
|
|
4
|
+
* text can remove one. read_only is given only when every known side effect is a read and no stricter class applies.
|
|
5
|
+
*/
|
|
6
|
+
import type { JSONSchema, Origin, RiskClass, SideEffect } from '../types/tool';
|
|
7
|
+
export declare const RISK_CUES: Array<[RiskClass, RegExp]>;
|
|
8
|
+
export declare function urlFromUser(input: JSONSchema | null | undefined): boolean;
|
|
9
|
+
export declare function takesDestination(input: JSONSchema | null | undefined): boolean;
|
|
10
|
+
export interface RiskInput {
|
|
11
|
+
origin: Origin;
|
|
12
|
+
side_effects: SideEffect[] | null | undefined;
|
|
13
|
+
scopes?: string[];
|
|
14
|
+
text: string;
|
|
15
|
+
input_schema?: JSONSchema | null;
|
|
16
|
+
external_network: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function deriveRisk(r: RiskInput): {
|
|
19
|
+
risk: RiskClass[];
|
|
20
|
+
side_effects: SideEffect[];
|
|
21
|
+
url_from_user: boolean;
|
|
22
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RISK_CUES = void 0;
|
|
4
|
+
exports.urlFromUser = urlFromUser;
|
|
5
|
+
exports.takesDestination = takesDestination;
|
|
6
|
+
exports.deriveRisk = deriveRisk;
|
|
7
|
+
const fold = (s) => String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
|
8
|
+
exports.RISK_CUES = [
|
|
9
|
+
['payment', /\b(transfer (funds|money|usdc|sol|eth|btc|tokens?|crypto)|send (funds|money|usdc|sol|eth|btc|crypto|tokens?|payments?)|withdraw|make (a )?payments?|pay (an? )?(invoice|bill|merchant)|(buy|sell|swap|trade) (tokens?|crypto|coins?|shares|stocks?|nfts?|usdc|sol|eth|btc)|execute (a )?(trade|swap|order|payment)|place (an? )?(trade|order)|token swaps?|trading bot)\b/],
|
|
10
|
+
['destructive', /\b(delete|deletes|deleting|remove (files?|records?|rows?|users?|repos?)|erase|wipe|destroy|drop (table|database|schema)|purge|truncate|rm -rf)\b/],
|
|
11
|
+
['publish_or_message', /\b(send (an? )?(emails?|messages?|sms|texts?|dms?|tweets?|notifications?)|post (to|on) |publish(es|ing)? (to|on|a|posts?)|tweet(s|ing)? |reply to (emails?|messages?|comments?|tweets?)|send(s|ing)? emails|outbound (email|sms|calls?))/],
|
|
12
|
+
['privileged_infra', /\b(deploy(s|ing|ment)?|ssh|shell commands?|execute (code|commands?|scripts?)|run (code|commands?|scripts?|shell)|kubernetes|kubectl|terraform|docker|root access|sudo|dns records?|firewall rules?|provision (servers?|vms?|infrastructure)|cloud infrastructure)\b/],
|
|
13
|
+
['sensitive_data', /\b(emails?|inbox|gmail|outlook|mailbox|calendar|google drive|onedrive|contacts|passwords?|credentials?|medical records?|health records?|patient data|bank accounts?|iban|passport|social security|private keys?|seed phrase|direct messages?)\b/],
|
|
14
|
+
];
|
|
15
|
+
/** Input property names that mean "a URL the caller supplies" (no fetch from chat). */
|
|
16
|
+
const URL_PROP = /^(url|uri|link|href|page_url|target_url|website|site|source_url|image_url|endpoint|webhook|callback_url)$/i;
|
|
17
|
+
/** Input property names that mean "where money goes" (security.txt rule 1). */
|
|
18
|
+
const DEST_PROP = /^(to|recipient|recipient_address|destination|destination_address|to_address|address|payto|pay_to|receiver|wallet_address|beneficiary)$/i;
|
|
19
|
+
const props = (s) => {
|
|
20
|
+
const p = s && typeof s === 'object' ? s.properties : null;
|
|
21
|
+
return p && typeof p === 'object' ? p : {};
|
|
22
|
+
};
|
|
23
|
+
function urlFromUser(input) {
|
|
24
|
+
for (const [k, v] of Object.entries(props(input))) {
|
|
25
|
+
if (URL_PROP.test(k))
|
|
26
|
+
return true;
|
|
27
|
+
if (v && typeof v === 'object' && (v.format === 'uri' || v.format === 'url'))
|
|
28
|
+
return true;
|
|
29
|
+
if (k === 'body' && urlFromUser(v))
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
function takesDestination(input) {
|
|
35
|
+
for (const [k, v] of Object.entries(props(input))) {
|
|
36
|
+
if (DEST_PROP.test(k))
|
|
37
|
+
return true;
|
|
38
|
+
if (k === 'body' && takesDestination(v))
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const SIDE_RISK = {
|
|
44
|
+
none: [], read: [], write: ['data_modification'], publish: ['publish_or_message'], message: ['publish_or_message'],
|
|
45
|
+
payment: ['payment'], delete: ['destructive', 'data_modification'], infra: ['privileged_infra'],
|
|
46
|
+
};
|
|
47
|
+
function deriveRisk(r) {
|
|
48
|
+
const out = new Set();
|
|
49
|
+
const known = Array.isArray(r.side_effects) && r.side_effects.length > 0;
|
|
50
|
+
const side = known ? [...new Set(r.side_effects)] : [];
|
|
51
|
+
if (!known) {
|
|
52
|
+
out.add('network');
|
|
53
|
+
out.add('data_modification');
|
|
54
|
+
}
|
|
55
|
+
for (const s of side)
|
|
56
|
+
for (const c of SIDE_RISK[s] || [])
|
|
57
|
+
out.add(c);
|
|
58
|
+
const t = fold(r.text);
|
|
59
|
+
for (const [cls, re] of exports.RISK_CUES)
|
|
60
|
+
if (re.test(t))
|
|
61
|
+
out.add(cls);
|
|
62
|
+
for (const sc of r.scopes || [])
|
|
63
|
+
if (/mail|gmail|calendar|drive|contacts|message|inbox/i.test(sc))
|
|
64
|
+
out.add('sensitive_data');
|
|
65
|
+
const url = urlFromUser(r.input_schema);
|
|
66
|
+
if (url || r.external_network)
|
|
67
|
+
out.add('network');
|
|
68
|
+
const strict = ['data_modification', 'publish_or_message', 'payment', 'destructive', 'privileged_infra'];
|
|
69
|
+
if (known && side.every((s) => s === 'read' || s === 'none') && !strict.some((c) => out.has(c)))
|
|
70
|
+
out.add('read_only');
|
|
71
|
+
const order = ['read_only', 'network', 'data_modification', 'publish_or_message', 'payment', 'destructive', 'sensitive_data', 'privileged_infra'];
|
|
72
|
+
return { risk: order.filter((c) => out.has(c)), side_effects: side, url_from_user: url };
|
|
73
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Untrusted provider text to description_safe (threat T1). External descriptions are data (security.txt rule 13):
|
|
3
|
+
* this strips control, zero width and bidi characters, HTML, markdown links and images, collapses whitespace, caps the
|
|
4
|
+
* length at 512, drops sentences that read as instructions to a model or a ranker, and thins keyword stuffing. The raw
|
|
5
|
+
* text is kept outside the record by hash only and is never shown to a model.
|
|
6
|
+
*/
|
|
7
|
+
export type SanitiseFlag = 'control_chars' | 'zero_width' | 'bidi' | 'html' | 'md_link' | 'md_image' | 'url' | 'injection' | 'repetition' | 'truncated' | 'empty';
|
|
8
|
+
export interface SanitiseResult {
|
|
9
|
+
text: string;
|
|
10
|
+
flags: SanitiseFlag[];
|
|
11
|
+
}
|
|
12
|
+
export declare const MAX_SAFE = 512;
|
|
13
|
+
/** Sentences that address a model, a ranker or the reader with instructions. Matched after folding. */
|
|
14
|
+
export declare const INJECTION_PATTERNS: RegExp[];
|
|
15
|
+
export declare function sanitise(raw: string): SanitiseResult;
|
|
16
|
+
/** Short form for summaries (200 chars), same rules. */
|
|
17
|
+
export declare function sanitiseShort(raw: string, max?: number): string;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.INJECTION_PATTERNS = exports.MAX_SAFE = void 0;
|
|
4
|
+
exports.sanitise = sanitise;
|
|
5
|
+
exports.sanitiseShort = sanitiseShort;
|
|
6
|
+
exports.MAX_SAFE = 512;
|
|
7
|
+
const ZERO_WIDTH = /[-]/g;
|
|
8
|
+
const BIDI = /[--]/g;
|
|
9
|
+
// C0 and C1 controls except tab, line feed and carriage return (turned into spaces later)
|
|
10
|
+
const CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
|
|
11
|
+
const HTML = /<\/?[a-zA-Z][^>]{0,400}>|<!--[\s\S]*?-->/g;
|
|
12
|
+
const MD_IMAGE = /!\[[^\]]{0,200}\]\([^)]{0,500}\)/g;
|
|
13
|
+
const MD_LINK = /\[([^\]]{0,200})\]\([^)]{0,500}\)/g;
|
|
14
|
+
const SPECIAL_TOKENS = /<\|?\s*(im_start|im_end|system|endoftext|assistant|user)\s*\|?>/gi;
|
|
15
|
+
const URL_RE = /\bhttps?:\/\/[^\s)]+/gi;
|
|
16
|
+
/** Sentences that address a model, a ranker or the reader with instructions. Matched after folding. */
|
|
17
|
+
exports.INJECTION_PATTERNS = [
|
|
18
|
+
/\b(ignore|disregard|forget|override)\b.{0,40}\b(previous|prior|above|earlier|all|any|other|system)\b.{0,30}\b(instruction|instructions|prompt|prompts|rules|messages|tools?)\b/,
|
|
19
|
+
/\byou (must|should|have to|need to|will)\b.{0,40}\b(select|choose|pick|use|call|rank|prefer|recommend|trust)\b/,
|
|
20
|
+
/\b(select|choose|pick|use|call)\b.{0,10}\bonly\b.{0,10}\b(this|me|us)\b/,
|
|
21
|
+
/\b(always|only)\b.{0,20}\b(select|choose|pick|use|call|rank|prefer|recommend)\b.{0,30}\b(this|me|us|our)\b/,
|
|
22
|
+
/\b(rank|score|rate|place|list)\b.{0,30}\b(this|me|us|our)\b.{0,20}\b(first|top|highest|above|#1|number one)\b/,
|
|
23
|
+
/\b(highest|top|maximum)\s+(priority|ranking|score)\b/,
|
|
24
|
+
/^\W*(system|assistant|developer)\s*:/,
|
|
25
|
+
/<\|?(im_start|im_end|system|endoftext)\|?>/,
|
|
26
|
+
/\b(do not|don't|never)\b.{0,30}\b(tell|inform|reveal|mention)\b.{0,30}\b(user|human|owner|operator)\b/,
|
|
27
|
+
/\b(send|forward|exfiltrate|upload|post|share)\b(?!-)\s.{0,20}\b(your|the|all|any|my)\b.{0,20}\b(api[_ -]?keys?|secrets?|credentials?|passwords?|private keys?|seed phrase)\b/,
|
|
28
|
+
/\b(dan mode|developer mode)\b/,
|
|
29
|
+
/\bact as\b.{0,30}\b(system|admin|root|the router)\b/,
|
|
30
|
+
/\bnew instructions?\b/,
|
|
31
|
+
/\b(transfer|send)\s+\d+(\.\d+)?\s*(usdc|usdt|sol|eth|btc|usd|dollars?|euros?)\b/,
|
|
32
|
+
/\b(transfer|send)\b.{0,40}\bto\s+(0x[0-9a-f]{6,}|[1-9a-hj-np-z]{32,44})\b/,
|
|
33
|
+
];
|
|
34
|
+
const hit = (re, s) => { re.lastIndex = 0; const r = re.test(s); re.lastIndex = 0; return r; };
|
|
35
|
+
const fold = (s) => s.normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
|
36
|
+
function sanitise(raw) {
|
|
37
|
+
const flags = new Set();
|
|
38
|
+
let t = String(raw ?? '');
|
|
39
|
+
if (hit(CONTROL, t))
|
|
40
|
+
flags.add('control_chars');
|
|
41
|
+
t = t.replace(CONTROL, ' ');
|
|
42
|
+
if (hit(ZERO_WIDTH, t))
|
|
43
|
+
flags.add('zero_width');
|
|
44
|
+
t = t.replace(ZERO_WIDTH, '');
|
|
45
|
+
if (hit(BIDI, t))
|
|
46
|
+
flags.add('bidi');
|
|
47
|
+
t = t.replace(BIDI, '');
|
|
48
|
+
t = t.normalize('NFKC');
|
|
49
|
+
// chat template tokens end a sentence and mark the text as an injection attempt
|
|
50
|
+
if (hit(SPECIAL_TOKENS, t))
|
|
51
|
+
flags.add('injection');
|
|
52
|
+
t = t.replace(SPECIAL_TOKENS, '. ');
|
|
53
|
+
if (hit(MD_IMAGE, t))
|
|
54
|
+
flags.add('md_image');
|
|
55
|
+
t = t.replace(MD_IMAGE, ' ');
|
|
56
|
+
if (hit(MD_LINK, t))
|
|
57
|
+
flags.add('md_link');
|
|
58
|
+
t = t.replace(MD_LINK, '$1');
|
|
59
|
+
if (hit(HTML, t))
|
|
60
|
+
flags.add('html');
|
|
61
|
+
t = t.replace(HTML, ' ');
|
|
62
|
+
if (hit(URL_RE, t))
|
|
63
|
+
flags.add('url');
|
|
64
|
+
t = t.replace(URL_RE, ' ');
|
|
65
|
+
t = t.replace(/[`*_~#>|]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
66
|
+
// drop sentences that carry instructions
|
|
67
|
+
const sentences = t.split(/(?<=[.!?;])\s+|\s+[-–—]\s+/);
|
|
68
|
+
const kept = [];
|
|
69
|
+
for (const s of sentences) {
|
|
70
|
+
const f = fold(s);
|
|
71
|
+
if (exports.INJECTION_PATTERNS.some((re) => re.test(f))) {
|
|
72
|
+
flags.add('injection');
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
kept.push(s);
|
|
76
|
+
}
|
|
77
|
+
t = kept.join(' ');
|
|
78
|
+
// thin keyword stuffing: a word may appear at most 3 times
|
|
79
|
+
const counts = new Map();
|
|
80
|
+
const words = t.split(' ');
|
|
81
|
+
const thinned = [];
|
|
82
|
+
for (const w of words) {
|
|
83
|
+
const k = fold(w).replace(/[^\p{L}\p{N}]+/gu, '');
|
|
84
|
+
if (!k) {
|
|
85
|
+
thinned.push(w);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const n = (counts.get(k) || 0) + 1;
|
|
89
|
+
counts.set(k, n);
|
|
90
|
+
if (n > 3) {
|
|
91
|
+
flags.add('repetition');
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
thinned.push(w);
|
|
95
|
+
}
|
|
96
|
+
t = thinned.join(' ').replace(/\s+/g, ' ').trim();
|
|
97
|
+
if ([...t].length > exports.MAX_SAFE) {
|
|
98
|
+
flags.add('truncated');
|
|
99
|
+
t = [...t].slice(0, exports.MAX_SAFE - 1).join('').trimEnd() + '…';
|
|
100
|
+
}
|
|
101
|
+
if (!t)
|
|
102
|
+
flags.add('empty');
|
|
103
|
+
return { text: t, flags: [...flags].sort() };
|
|
104
|
+
}
|
|
105
|
+
/** Short form for summaries (200 chars), same rules. */
|
|
106
|
+
function sanitiseShort(raw, max = 200) {
|
|
107
|
+
const { text } = sanitise(raw);
|
|
108
|
+
return [...text].length > max ? [...text].slice(0, max - 1).join('').trimEnd() + '…' : text;
|
|
109
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* classify(): rules first; the ClassifierPort (ClawRouter, local rung, $0) only when all of these hold: the rules'
|
|
3
|
+
* confidence is under 0.5, TOOL_ROUTER_MODEL_FALLBACK is on, a port is wired, and the rules did not already decide
|
|
4
|
+
* no_tool. Only the request text is sent, never tool text (T1). The answer is validated against the taxonomy: unknown
|
|
5
|
+
* capability ids and unknown labels are dropped, malformed answers ignored. Risk classes stay the rules' classes plus
|
|
6
|
+
* the default risk of any capability the model added: a model answer can add risk, never remove it.
|
|
7
|
+
*/
|
|
8
|
+
import type { KtrFlags } from '../flags';
|
|
9
|
+
import type { ClassifierPort } from '../types/ports';
|
|
10
|
+
import type { CallerContext, Classification } from '../types/route';
|
|
11
|
+
export declare const FALLBACK_THRESHOLD = 0.5;
|
|
12
|
+
export declare function classify(text: string, ctx: Partial<CallerContext>, flags: Pick<KtrFlags, 'modelFallback' | 'stopped'>, port?: ClassifierPort | null): Promise<Classification>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FALLBACK_THRESHOLD = void 0;
|
|
4
|
+
exports.classify = classify;
|
|
5
|
+
const route_1 = require("../types/route");
|
|
6
|
+
const rules_1 = require("./rules");
|
|
7
|
+
const taxonomy_1 = require("./taxonomy");
|
|
8
|
+
exports.FALLBACK_THRESHOLD = 0.5;
|
|
9
|
+
async function classify(text, ctx, flags, port) {
|
|
10
|
+
const r = (0, rules_1.classifyRules)(text, ctx);
|
|
11
|
+
const base = {
|
|
12
|
+
task_complexity: r.task_complexity, risk_classes: r.risk_classes, requirement: r.requirement,
|
|
13
|
+
capabilities: r.capabilities.map((c) => c.id), hits: r.capabilities, confidence: r.confidence, method: 'rules',
|
|
14
|
+
language: r.language, args_known: r.args_known, reasoning_cues: r.reasoning_cues, data_spans: r.data_spans,
|
|
15
|
+
};
|
|
16
|
+
const eligible = r.confidence < exports.FALLBACK_THRESHOLD && flags.modelFallback && !flags.stopped && !!port && r.requirement !== 'no_tool';
|
|
17
|
+
if (!eligible)
|
|
18
|
+
return base;
|
|
19
|
+
let answer = null;
|
|
20
|
+
try {
|
|
21
|
+
answer = await port.classify(String(text).slice(0, 600), { capabilities: taxonomy_1.CAPABILITY_IDS, requirements: route_1.REQUIREMENTS, complexities: route_1.TASK_COMPLEXITIES });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
answer = null;
|
|
25
|
+
}
|
|
26
|
+
if (!answer || !Array.isArray(answer.capabilities))
|
|
27
|
+
return base;
|
|
28
|
+
const caps = [...new Set(answer.capabilities.map(String).filter((c) => taxonomy_1.CAPABILITY_IDS.includes(c)))];
|
|
29
|
+
const requirement = route_1.REQUIREMENTS.includes(answer.requirement) ? answer.requirement : null;
|
|
30
|
+
const complexity = route_1.TASK_COMPLEXITIES.includes(answer.task_complexity) ? answer.task_complexity : null;
|
|
31
|
+
if (!caps.length && !requirement)
|
|
32
|
+
return base;
|
|
33
|
+
const merged = [...new Set([...base.capabilities, ...caps])];
|
|
34
|
+
const risk = new Set(base.risk_classes);
|
|
35
|
+
for (const c of caps)
|
|
36
|
+
for (const x of (0, taxonomy_1.capabilityById)(c).default_risk)
|
|
37
|
+
risk.add(x);
|
|
38
|
+
const needsTool = merged.some((c) => (0, taxonomy_1.capabilityById)(c).needs_tool);
|
|
39
|
+
const finalReq = requirement === 'no_eligible_tool' ? 'tool' : requirement ?? (needsTool ? 'tool' : merged.length ? 'no_tool' : base.requirement);
|
|
40
|
+
const order = ['read_only', 'network', 'data_modification', 'publish_or_message', 'payment', 'destructive', 'sensitive_data', 'privileged_infra'];
|
|
41
|
+
const conf = typeof answer.confidence === 'number' && answer.confidence >= 0 && answer.confidence <= 1 ? answer.confidence : 0.5;
|
|
42
|
+
return {
|
|
43
|
+
...base,
|
|
44
|
+
capabilities: merged,
|
|
45
|
+
hits: [...base.hits, ...caps.filter((c) => !base.capabilities.includes(c)).map((id) => ({ id, confidence: conf, span: '(model)' }))],
|
|
46
|
+
requirement: finalReq === 'tool' && !needsTool ? 'no_tool' : finalReq,
|
|
47
|
+
task_complexity: base.task_complexity === 'REASONING' ? 'REASONING' : complexity ?? base.task_complexity,
|
|
48
|
+
risk_classes: order.filter((c) => risk.has(c)),
|
|
49
|
+
confidence: Number(Math.max(base.confidence, Math.min(conf, 0.7)).toFixed(2)),
|
|
50
|
+
method: 'model',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* decompose(): a classification to a DAG of atomic operations (plan.md S2). Known chains use templates; anything else
|
|
3
|
+
* becomes one node per capability in mention order, sequential when the text chains the steps ("then", "and send
|
|
4
|
+
* it"), parallel otherwise. Templates are fixed acyclic shapes and generic edges only point to earlier nodes, so a
|
|
5
|
+
* cycle cannot be built; topoOrder() checks it anyway.
|
|
6
|
+
*/
|
|
7
|
+
import type { Classification, PlanNode } from '../types/route';
|
|
8
|
+
export declare function decompose(c: Classification, text?: string): PlanNode[];
|
|
9
|
+
/** Topological order; throws on a cycle or an unknown dependency. */
|
|
10
|
+
export declare function topoOrder(nodes: PlanNode[]): string[];
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.decompose = decompose;
|
|
4
|
+
exports.topoOrder = topoOrder;
|
|
5
|
+
const taxonomy_1 = require("./taxonomy");
|
|
6
|
+
const text_1 = require("./text");
|
|
7
|
+
const rules_1 = require("./rules");
|
|
8
|
+
const VIDEO_CHAIN = ['script.write', 'character.design', 'video.generate', 'voice.speak', 'video.edit'];
|
|
9
|
+
const RICH_VIDEO = /(?<![\p{L}])(cinematic|characters?|consistent|dialogue|scenes?|story|talking|conversation|personnages?|\d{2,3} ?(s|sec|secondes?|seconds?)|minutes?)(?![\p{L}])/u;
|
|
10
|
+
const SEARCHES = ['literature.search', 'trial.search', 'patent.search', 'web.search', 'news.search'];
|
|
11
|
+
const READS_FOR_EXTRACTION = ['mail.read', 'mail.search', 'drive.search', 'doc.read', 'calendar.read'];
|
|
12
|
+
function node(i, cap, depends, c) {
|
|
13
|
+
const def = (0, taxonomy_1.capabilityById)(cap);
|
|
14
|
+
const needs = def.args || [];
|
|
15
|
+
const known = needs.filter((a) => c.args_known.includes(a));
|
|
16
|
+
// a chained node gets its inputs from the node before it: nothing is missing
|
|
17
|
+
const missing = depends.length ? [] : needs.filter((a) => !c.args_known.includes(a));
|
|
18
|
+
return { node_id: `n${i}`, capability: cap, depends_on: depends, risk_classes: [...def.default_risk], args_known: known.length ? known : (c.args_known.includes('query') ? ['query'] : []), args_missing: missing, needs_tool: def.needs_tool };
|
|
19
|
+
}
|
|
20
|
+
function decompose(c, text = '') {
|
|
21
|
+
if (c.requirement === 'clarify' || !c.capabilities.length)
|
|
22
|
+
return [];
|
|
23
|
+
const caps = c.capabilities.filter((x) => (0, taxonomy_1.capabilityById)(x));
|
|
24
|
+
const norm = (0, text_1.normalise)(text);
|
|
25
|
+
const out = [];
|
|
26
|
+
const add = (cap, deps) => { const n = node(out.length + 1, cap, deps, c); out.push(n); return n.node_id; };
|
|
27
|
+
// video chain (Demo C): a produced piece with characters, dialogue, a story or a duration
|
|
28
|
+
if (caps.includes('video.generate') && (caps.includes('character.design') || caps.includes('script.write') || RICH_VIDEO.test(norm))) {
|
|
29
|
+
let prev = [];
|
|
30
|
+
for (const cap of VIDEO_CHAIN)
|
|
31
|
+
prev = [add(cap, prev)];
|
|
32
|
+
if (caps.includes('music.generate')) {
|
|
33
|
+
const m = add('music.generate', [out[0].node_id]);
|
|
34
|
+
out[out.length - 2].depends_on.push(m);
|
|
35
|
+
}
|
|
36
|
+
for (const cap of caps)
|
|
37
|
+
if (!out.some((n) => n.capability === cap))
|
|
38
|
+
add(cap, []);
|
|
39
|
+
return fixEditDeps(out);
|
|
40
|
+
}
|
|
41
|
+
// research (Demo B): searches in parallel, synthesis after all of them
|
|
42
|
+
const searches = caps.filter((x) => SEARCHES.includes(x));
|
|
43
|
+
if (searches.length && (caps.includes('evidence.synthesise') || c.task_complexity === 'REASONING') && searches.some((s) => s !== 'web.search' && s !== 'news.search')) {
|
|
44
|
+
const ids = searches.map((s) => add(s, []));
|
|
45
|
+
add('evidence.synthesise', ids);
|
|
46
|
+
for (const cap of caps)
|
|
47
|
+
if (!out.some((n) => n.capability === cap))
|
|
48
|
+
add(cap, []);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
// extraction from the owner's data (Demo D): read first, model work after
|
|
52
|
+
if (caps.includes('mail.extract_actions') || caps.includes('text.summarise')) {
|
|
53
|
+
const reads = caps.filter((x) => READS_FOR_EXTRACTION.includes(x));
|
|
54
|
+
if (reads.length) {
|
|
55
|
+
const ids = reads.map((r) => add(r, []));
|
|
56
|
+
for (const cap of caps)
|
|
57
|
+
if (!reads.includes(cap))
|
|
58
|
+
add(cap, (0, taxonomy_1.capabilityById)(cap).needs_tool ? [] : ids);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// generic: sequential when the text chains the steps, otherwise parallel
|
|
63
|
+
const chained = rules_1.CHAIN_HINT.test(norm);
|
|
64
|
+
let prev = [];
|
|
65
|
+
for (const cap of caps) {
|
|
66
|
+
const id = add(cap, chained ? prev : []);
|
|
67
|
+
prev = [id];
|
|
68
|
+
}
|
|
69
|
+
// model work (summarise, synthesise) after the tool nodes it follows
|
|
70
|
+
for (let i = 0; i < out.length; i++) {
|
|
71
|
+
const n = out[i];
|
|
72
|
+
if (n.needs_tool || n.depends_on.length)
|
|
73
|
+
continue;
|
|
74
|
+
n.depends_on = out.slice(0, i).filter((x) => x.needs_tool).map((x) => x.node_id);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
function fixEditDeps(nodes) {
|
|
79
|
+
for (const n of nodes)
|
|
80
|
+
n.depends_on = [...new Set(n.depends_on)];
|
|
81
|
+
return nodes;
|
|
82
|
+
}
|
|
83
|
+
/** Topological order; throws on a cycle or an unknown dependency. */
|
|
84
|
+
function topoOrder(nodes) {
|
|
85
|
+
const ids = new Set(nodes.map((n) => n.node_id));
|
|
86
|
+
for (const n of nodes)
|
|
87
|
+
for (const d of n.depends_on)
|
|
88
|
+
if (!ids.has(d))
|
|
89
|
+
throw new Error(`${n.node_id} depends on unknown ${d}`);
|
|
90
|
+
const order = [];
|
|
91
|
+
const state = new Map();
|
|
92
|
+
const byId = new Map(nodes.map((n) => [n.node_id, n]));
|
|
93
|
+
const visit = (id) => {
|
|
94
|
+
if (state.get(id) === 2)
|
|
95
|
+
return;
|
|
96
|
+
if (state.get(id) === 1)
|
|
97
|
+
throw new Error(`cycle at ${id}`);
|
|
98
|
+
state.set(id, 1);
|
|
99
|
+
for (const d of byId.get(id).depends_on)
|
|
100
|
+
visit(d);
|
|
101
|
+
state.set(id, 2);
|
|
102
|
+
order.push(id);
|
|
103
|
+
};
|
|
104
|
+
for (const n of nodes)
|
|
105
|
+
visit(n.node_id);
|
|
106
|
+
return order;
|
|
107
|
+
}
|