@kontourai/flow-agents 0.3.0 → 1.0.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/workflows/kit-gates-demo.yml +171 -0
- package/.github/workflows/release-please.yml +13 -1
- package/AGENTS.md +8 -1
- package/CHANGELOG.md +53 -0
- package/CONTEXT.md +1 -1
- package/README.md +13 -2
- package/build/src/cli/flow-kit.js +41 -2
- package/build/src/flow-kit/validate.js +98 -0
- package/build/src/tools/validate-source-tree.js +2 -1
- package/context/scripts/hooks/config-protection.js +217 -15
- package/docs/fixture-ownership.md +1 -0
- package/docs/index.md +9 -1
- package/docs/kit-authoring-guide.md +126 -0
- package/docs/knowledge-kit.md +69 -0
- package/docs/vision.md +22 -0
- package/evals/fixtures/kit-conformance-levels/k0-flows-only/flows/review.flow.json +26 -0
- package/evals/fixtures/kit-conformance-levels/k0-flows-only/kit.json +13 -0
- package/evals/fixtures/kit-conformance-levels/k1-agent-extension/docs/README.md +3 -0
- package/evals/fixtures/kit-conformance-levels/k1-agent-extension/flows/build.flow.json +26 -0
- package/evals/fixtures/kit-conformance-levels/k1-agent-extension/kit.json +20 -0
- package/evals/fixtures/kit-conformance-levels/k2-with-evals/docs/README.md +3 -0
- package/evals/fixtures/kit-conformance-levels/k2-with-evals/eval-suites/contract-suite/suite.test.js +1 -0
- package/evals/fixtures/kit-conformance-levels/k2-with-evals/flows/synthesize.flow.json +26 -0
- package/evals/fixtures/kit-conformance-levels/k2-with-evals/kit.json +27 -0
- package/evals/fixtures/kit-conformance-levels/third-party-extension/flows/review.flow.json +26 -0
- package/evals/fixtures/kit-conformance-levels/third-party-extension/kit.json +19 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_hook_category_behaviors.sh +51 -0
- package/evals/integration/test_kit_conformance_levels.sh +209 -0
- package/evals/run.sh +2 -0
- package/evals/static/test_universal_bundles.sh +10 -0
- package/kits/catalog.json +6 -0
- package/kits/knowledge/adapters/default-store/index.js +95 -14
- package/kits/knowledge/adapters/flow-runner/entity-extractor.js +194 -0
- package/kits/knowledge/adapters/flow-runner/index.js +639 -0
- package/kits/knowledge/adapters/obsidian-store/README.md +141 -0
- package/kits/knowledge/adapters/obsidian-store/demo.js +181 -0
- package/kits/knowledge/adapters/obsidian-store/index.js +868 -0
- package/kits/knowledge/adapters/shared/codec.js +325 -0
- package/kits/knowledge/adapters/similarity-vector/index.js +284 -0
- package/kits/knowledge/docs/README.md +193 -0
- package/kits/knowledge/docs/store-contract.md +196 -0
- package/kits/knowledge/evals/contract-suite/suite.test.js +10 -5
- package/kits/knowledge/evals/entities/demo-acme.js +125 -0
- package/kits/knowledge/evals/entities/suite.test.js +722 -0
- package/kits/knowledge/evals/retirement/suite.test.js +1173 -0
- package/kits/knowledge/evals/similarity-vector/suite.test.js +685 -0
- package/kits/knowledge/evals/synthesis/suite.test.js +10 -3
- package/kits/knowledge/flows/retire.flow.json +77 -0
- package/kits/knowledge/kit.json +31 -1
- package/kits/release-evidence/fixtures/claims/README.md +14 -0
- package/kits/release-evidence/fixtures/claims/fail-rejected-release.trust.json +22 -0
- package/kits/release-evidence/fixtures/claims/pass-trusted-release.trust.json +22 -0
- package/kits/release-evidence/flows/release-evidence.flow.json +38 -0
- package/kits/release-evidence/kit.json +13 -0
- package/package.json +1 -1
- package/packaging/conformance/fixtures/config-protection--allow-no-verify-in-string.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-git-no-verify.json +23 -0
- package/scripts/hooks/config-protection.js +217 -15
- package/src/cli/flow-kit.ts +40 -2
- package/src/flow-kit/validate.ts +127 -0
- package/src/tools/validate-source-tree.ts +2 -1
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* - compile(rawIds[], options) — compile flow: select → compile → link with provenance
|
|
14
14
|
* - synthesize(conceptId | topicSelector, options) — synthesize flow:
|
|
15
15
|
* detect-cluster → propose → evidence-gate → apply-or-reject
|
|
16
|
+
* - retire(recordId, options) — retire flow: identify → propose → evidence-gate → apply-or-reject
|
|
16
17
|
* - defaultSimilarityDetector — pluggable similarity interface default (R3)
|
|
17
18
|
*
|
|
18
19
|
* Telemetry:
|
|
@@ -34,6 +35,12 @@
|
|
|
34
35
|
import * as path from "node:path";
|
|
35
36
|
import { fileURLToPath } from "node:url";
|
|
36
37
|
import { KnowledgeTelemetry } from "./telemetry.js";
|
|
38
|
+
import {
|
|
39
|
+
defaultEntityExtractor,
|
|
40
|
+
normalizeName,
|
|
41
|
+
isExactMatch,
|
|
42
|
+
isPossibleDuplicate,
|
|
43
|
+
} from "./entity-extractor.js";
|
|
37
44
|
|
|
38
45
|
// ---------------------------------------------------------------------------
|
|
39
46
|
// Error helpers
|
|
@@ -112,6 +119,9 @@ export async function defaultSimilarityDetector(concept, candidates, store) {
|
|
|
112
119
|
const similar = [];
|
|
113
120
|
|
|
114
121
|
for (const candidate of candidates) {
|
|
122
|
+
// Exclude retired records from the working set (Addendum B — R3)
|
|
123
|
+
if ((candidate.status || "active") === "retired") continue;
|
|
124
|
+
|
|
115
125
|
// Check 1: category overlap (prefix match in either direction)
|
|
116
126
|
const catMatch =
|
|
117
127
|
candidate.category === concept.category ||
|
|
@@ -1107,12 +1117,597 @@ export class KnowledgeFlowRunner {
|
|
|
1107
1117
|
};
|
|
1108
1118
|
}
|
|
1109
1119
|
|
|
1120
|
+
// -------------------------------------------------------------------------
|
|
1121
|
+
// knowledge.retire flow (Addendum B — S7)
|
|
1122
|
+
// Steps: identify → propose-retirement → evidence-gate → apply-or-reject → done
|
|
1123
|
+
// Gate: evidence-gate — proposal carries rationale/ref; no direct mutation (AC1).
|
|
1124
|
+
// apply-gate — apply or reject via store retire op.
|
|
1125
|
+
// rejection leaves record status byte-identical (AC2).
|
|
1126
|
+
//
|
|
1127
|
+
// Machinery reuse: retire shares the same propose→evidence-gate→apply-or-reject
|
|
1128
|
+
// pattern as synthesize/consolidate. The store's retire op enforces the transition
|
|
1129
|
+
// table; rejection leaves the record unchanged.
|
|
1130
|
+
// -------------------------------------------------------------------------
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Execute the retire flow: identify the target record, create a retirement
|
|
1134
|
+
* proposal (never a direct mutation), gate the evidence, then apply or reject.
|
|
1135
|
+
*
|
|
1136
|
+
* On apply:
|
|
1137
|
+
* The store retire op updates the record status to targetStatus and appends
|
|
1138
|
+
* a mutation log entry with the full evidence. The record is excluded from
|
|
1139
|
+
* default working-set queries (listByType, listByCategory, similarity
|
|
1140
|
+
* detection) unless includeRetired is true.
|
|
1141
|
+
*
|
|
1142
|
+
* On reject:
|
|
1143
|
+
* The record status is byte-identical to its pre-proposal state.
|
|
1144
|
+
*
|
|
1145
|
+
* @param {string} recordId
|
|
1146
|
+
* ID of the record to retire.
|
|
1147
|
+
* @param {object} [options]
|
|
1148
|
+
* - targetStatus: "implemented"|"retired" — target status (required)
|
|
1149
|
+
* - rationale: string — why retiring (required)
|
|
1150
|
+
* - implementedByRef: string — ref when targetStatus="implemented" (required)
|
|
1151
|
+
* - supersededByRef: string — optional ref to superseding artifact
|
|
1152
|
+
* - decision: "apply"|"reject" — gate decision (default "apply")
|
|
1153
|
+
* - rejectReason: string — reason for rejection (required when decision="reject")
|
|
1154
|
+
* - agent: string — override agent name
|
|
1155
|
+
* - session_id: string — session id
|
|
1156
|
+
* - note: string — provenance note
|
|
1157
|
+
* @returns {Promise<{
|
|
1158
|
+
* recordId: string,
|
|
1159
|
+
* targetStatus: string,
|
|
1160
|
+
* decision: "apply"|"reject",
|
|
1161
|
+
* previousStatus: string,
|
|
1162
|
+
* proposerId: string,
|
|
1163
|
+
* telemetryEvents: object[]
|
|
1164
|
+
* }>}
|
|
1165
|
+
*/
|
|
1166
|
+
async retire(recordId, options = {}) {
|
|
1167
|
+
const events = [];
|
|
1168
|
+
const agent = options.agent || this._agent;
|
|
1169
|
+
|
|
1170
|
+
// ── Step: identify ─────────────────────────────────────────────────────
|
|
1171
|
+
if (!recordId || typeof recordId !== "string") {
|
|
1172
|
+
throw missingEvidenceError("retire: recordId must be a non-empty string");
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const targetStatus = options.targetStatus;
|
|
1176
|
+
if (targetStatus !== "implemented" && targetStatus !== "retired") {
|
|
1177
|
+
throw missingEvidenceError(
|
|
1178
|
+
'retire: options.targetStatus must be "implemented" or "retired"'
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
if (!options.rationale || !options.rationale.trim()) {
|
|
1183
|
+
throw missingEvidenceError("retire: options.rationale is required");
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
if (targetStatus === "implemented" && (!options.implementedByRef || !options.implementedByRef.trim())) {
|
|
1187
|
+
throw missingEvidenceError(
|
|
1188
|
+
'retire: options.implementedByRef is required when targetStatus is "implemented"'
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const record = await this._store.get(recordId);
|
|
1193
|
+
if (!record) {
|
|
1194
|
+
throw missingEvidenceError(`retire: record not found: ${recordId}`);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const previousStatus = record.status || "active";
|
|
1198
|
+
|
|
1199
|
+
// Validate transition early (surface errors at identify-gate, not at apply-gate)
|
|
1200
|
+
const VALID_TRANSITIONS = {
|
|
1201
|
+
active: new Set(["implemented", "retired"]),
|
|
1202
|
+
implemented: new Set(["retired"]),
|
|
1203
|
+
retired: new Set(),
|
|
1204
|
+
};
|
|
1205
|
+
const allowed = VALID_TRANSITIONS[previousStatus] || new Set();
|
|
1206
|
+
if (!allowed.has(targetStatus)) {
|
|
1207
|
+
throw missingEvidenceError(
|
|
1208
|
+
`retire: invalid transition from "${previousStatus}" to "${targetStatus}"`
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// Emit identify gate entry
|
|
1213
|
+
const identifyGateIn = this._telemetry.emitGate("knowledge.retire", "identify-gate", {
|
|
1214
|
+
flow: "knowledge.retire",
|
|
1215
|
+
gate: "identify-gate",
|
|
1216
|
+
record_id: recordId,
|
|
1217
|
+
record_type: record.type,
|
|
1218
|
+
current_status: previousStatus,
|
|
1219
|
+
target_status: targetStatus,
|
|
1220
|
+
});
|
|
1221
|
+
events.push(identifyGateIn);
|
|
1222
|
+
|
|
1223
|
+
const identifyGateOut = this._telemetry.emitGateResult("knowledge.retire", "identify-gate", {
|
|
1224
|
+
record_id: recordId,
|
|
1225
|
+
record_type: record.type,
|
|
1226
|
+
current_status: previousStatus,
|
|
1227
|
+
target_status: targetStatus,
|
|
1228
|
+
transition_valid: true,
|
|
1229
|
+
});
|
|
1230
|
+
events.push(identifyGateOut);
|
|
1231
|
+
|
|
1232
|
+
// ── Step: propose-retirement ───────────────────────────────────────────
|
|
1233
|
+
// We reuse the store's propose op against the record itself.
|
|
1234
|
+
// The record acts as the "concept" target; a transient proposer raw record
|
|
1235
|
+
// carries the retirement proposal and proposes link.
|
|
1236
|
+
|
|
1237
|
+
const proposeGateIn = this._telemetry.emitGate(
|
|
1238
|
+
"knowledge.retire",
|
|
1239
|
+
"propose-retirement-gate",
|
|
1240
|
+
{
|
|
1241
|
+
flow: "knowledge.retire",
|
|
1242
|
+
gate: "propose-retirement-gate",
|
|
1243
|
+
record_id: recordId,
|
|
1244
|
+
target_status: targetStatus,
|
|
1245
|
+
rationale: options.rationale,
|
|
1246
|
+
}
|
|
1247
|
+
);
|
|
1248
|
+
events.push(proposeGateIn);
|
|
1249
|
+
|
|
1250
|
+
// Create a transient proposer record to hold the retirement proposal
|
|
1251
|
+
const proposerBody =
|
|
1252
|
+
`Retirement proposal for record ${recordId}.
|
|
1253
|
+
` +
|
|
1254
|
+
`Target status: ${targetStatus}
|
|
1255
|
+
` +
|
|
1256
|
+
`Rationale: ${options.rationale}
|
|
1257
|
+
` +
|
|
1258
|
+
(options.implementedByRef ? `Implemented-by: ${options.implementedByRef}
|
|
1259
|
+
` : "") +
|
|
1260
|
+
(options.supersededByRef ? `Superseded-by: ${options.supersededByRef}
|
|
1261
|
+
` : "");
|
|
1262
|
+
|
|
1263
|
+
const proposerId = await this._store.create({
|
|
1264
|
+
type: "raw",
|
|
1265
|
+
title: `Retirement proposal: ${record.title}`,
|
|
1266
|
+
body: proposerBody,
|
|
1267
|
+
category: record.category,
|
|
1268
|
+
provenance: {
|
|
1269
|
+
agent,
|
|
1270
|
+
note: `Retirement proposal for ${recordId}`,
|
|
1271
|
+
...(options.session_id ? { session_id: options.session_id } : {}),
|
|
1272
|
+
},
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
// Attach the proposal via the store's propose op (not direct mutation — AC1)
|
|
1276
|
+
await this._store.propose(recordId, proposerId, {
|
|
1277
|
+
agent,
|
|
1278
|
+
proposal: options.rationale,
|
|
1279
|
+
...(options.note ? { note: options.note } : {}),
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
const proposeGateOut = this._telemetry.emitGateResult(
|
|
1283
|
+
"knowledge.retire",
|
|
1284
|
+
"propose-retirement-gate",
|
|
1285
|
+
{
|
|
1286
|
+
record_id: recordId,
|
|
1287
|
+
proposer_id: proposerId,
|
|
1288
|
+
target_status: targetStatus,
|
|
1289
|
+
proposal_recorded: true,
|
|
1290
|
+
}
|
|
1291
|
+
);
|
|
1292
|
+
events.push(proposeGateOut);
|
|
1293
|
+
|
|
1294
|
+
// ── Step: evidence-gate ────────────────────────────────────────────────
|
|
1295
|
+
// Verify the proposal carries required evidence and the transition is valid.
|
|
1296
|
+
|
|
1297
|
+
const evidenceGateIn = this._telemetry.emitGate("knowledge.retire", "evidence-gate", {
|
|
1298
|
+
flow: "knowledge.retire",
|
|
1299
|
+
gate: "evidence-gate",
|
|
1300
|
+
record_id: recordId,
|
|
1301
|
+
proposer_id: proposerId,
|
|
1302
|
+
target_status: targetStatus,
|
|
1303
|
+
});
|
|
1304
|
+
events.push(evidenceGateIn);
|
|
1305
|
+
|
|
1306
|
+
// Enforce: proposer must have a "proposes" link to the record
|
|
1307
|
+
const { forward } = await this._store.getLinks(proposerId);
|
|
1308
|
+
const hasProposesLink = forward.some(
|
|
1309
|
+
(l) => l.target_id === recordId && l.kind === "proposes"
|
|
1310
|
+
);
|
|
1311
|
+
if (!hasProposesLink) {
|
|
1312
|
+
throw missingEvidenceError(
|
|
1313
|
+
`evidence-gate: proposer ${proposerId} must have a "proposes" link to record ${recordId}`
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Enforce: target record still exists
|
|
1318
|
+
const targetRecord = await this._store.get(recordId);
|
|
1319
|
+
if (!targetRecord) {
|
|
1320
|
+
throw missingEvidenceError(
|
|
1321
|
+
`evidence-gate: target record ${recordId} does not exist`
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const evidenceGateOut = this._telemetry.emitGateResult("knowledge.retire", "evidence-gate", {
|
|
1326
|
+
record_id: recordId,
|
|
1327
|
+
proposer_id: proposerId,
|
|
1328
|
+
target_status: targetStatus,
|
|
1329
|
+
proposes_link_verified: true,
|
|
1330
|
+
target_record_verified: true,
|
|
1331
|
+
});
|
|
1332
|
+
events.push(evidenceGateOut);
|
|
1333
|
+
|
|
1334
|
+
// ── Step: apply-or-reject ──────────────────────────────────────────────
|
|
1335
|
+
const decision = options.decision || "apply";
|
|
1336
|
+
|
|
1337
|
+
const applyGateIn = this._telemetry.emitGate("knowledge.retire", "apply-gate", {
|
|
1338
|
+
flow: "knowledge.retire",
|
|
1339
|
+
gate: "apply-gate",
|
|
1340
|
+
record_id: recordId,
|
|
1341
|
+
proposer_id: proposerId,
|
|
1342
|
+
target_status: targetStatus,
|
|
1343
|
+
decision,
|
|
1344
|
+
});
|
|
1345
|
+
events.push(applyGateIn);
|
|
1346
|
+
|
|
1347
|
+
if (decision === "apply") {
|
|
1348
|
+
// Apply via store retire op — transitions status, appends mutation log (AC1)
|
|
1349
|
+
await this._store.retire(recordId, targetStatus, {
|
|
1350
|
+
agent,
|
|
1351
|
+
rationale: options.rationale,
|
|
1352
|
+
...(options.implementedByRef ? { implementedByRef: options.implementedByRef } : {}),
|
|
1353
|
+
...(options.supersededByRef ? { supersededByRef: options.supersededByRef } : {}),
|
|
1354
|
+
...(options.note ? { note: options.note } : {}),
|
|
1355
|
+
});
|
|
1356
|
+
} else if (decision === "reject") {
|
|
1357
|
+
if (!options.rejectReason || !options.rejectReason.trim()) {
|
|
1358
|
+
throw missingEvidenceError(
|
|
1359
|
+
"apply-gate: options.rejectReason is required when decision=reject"
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
// Reject via store reject op — record status remains untouched (AC2)
|
|
1363
|
+
await this._store.reject(recordId, proposerId, {
|
|
1364
|
+
agent,
|
|
1365
|
+
reason: options.rejectReason,
|
|
1366
|
+
});
|
|
1367
|
+
} else {
|
|
1368
|
+
throw missingEvidenceError(
|
|
1369
|
+
`apply-gate: decision must be "apply" or "reject"; got: ${decision}`
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const applyGateOut = this._telemetry.emitGateResult("knowledge.retire", "apply-gate", {
|
|
1374
|
+
record_id: recordId,
|
|
1375
|
+
proposer_id: proposerId,
|
|
1376
|
+
target_status: targetStatus,
|
|
1377
|
+
decision,
|
|
1378
|
+
previous_status: previousStatus,
|
|
1379
|
+
});
|
|
1380
|
+
events.push(applyGateOut);
|
|
1381
|
+
|
|
1382
|
+
return {
|
|
1383
|
+
recordId,
|
|
1384
|
+
targetStatus,
|
|
1385
|
+
decision,
|
|
1386
|
+
previousStatus,
|
|
1387
|
+
proposerId,
|
|
1388
|
+
telemetryEvents: events,
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
// -------------------------------------------------------------------------
|
|
1394
|
+
// knowledge.compile — entity extraction step (R2)
|
|
1395
|
+
//
|
|
1396
|
+
// Called after compile() to extract person mentions from the compiled record,
|
|
1397
|
+
// resolve/create person cards, and write bidirectional links:
|
|
1398
|
+
// - Person card → raw + compiled (kind: appears-in)
|
|
1399
|
+
// - Compiled record → person cards (kind: person)
|
|
1400
|
+
//
|
|
1401
|
+
// EntityExtractor interface (same pattern as SimilarityDetector — R3):
|
|
1402
|
+
// async (record: Record) => PersonMention[]
|
|
1403
|
+
// PersonMention: { name: string, role?: string, org?: string }
|
|
1404
|
+
// -------------------------------------------------------------------------
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* Extract person entities from a compiled record and its source raws, then
|
|
1408
|
+
* create or update person cards with bidirectional links.
|
|
1409
|
+
*
|
|
1410
|
+
* @param {string} compiledId - ID of the compiled record to process
|
|
1411
|
+
* @param {object} [options]
|
|
1412
|
+
* - entityExtractor: fn — pluggable extractor (default: defaultEntityExtractor)
|
|
1413
|
+
* - agent: string — override agent name
|
|
1414
|
+
* @returns {Promise<{
|
|
1415
|
+
* compiledId: string,
|
|
1416
|
+
* personCards: Array<{ cardId, name, created, duplicate }>,
|
|
1417
|
+
* linkCount: number
|
|
1418
|
+
* }>}
|
|
1419
|
+
*/
|
|
1420
|
+
async extractEntities(compiledId, options = {}) {
|
|
1421
|
+
const agent = options.agent || this._agent;
|
|
1422
|
+
const extractor = options.entityExtractor || defaultEntityExtractor;
|
|
1423
|
+
|
|
1424
|
+
const compiled = await this._store.get(compiledId);
|
|
1425
|
+
if (!compiled) throw new Error(`extractEntities: compiled record not found: ${compiledId}`);
|
|
1426
|
+
|
|
1427
|
+
// Gather mentions from the compiled record
|
|
1428
|
+
const mentions = await extractor(compiled);
|
|
1429
|
+
|
|
1430
|
+
// Also gather mentions from all source raw records
|
|
1431
|
+
const sourceLinks = (compiled.links || []).filter((l) => l.kind === "source");
|
|
1432
|
+
const seenNames = new Set(mentions.map((m) => normalizeName(m.name)));
|
|
1433
|
+
for (const link of sourceLinks) {
|
|
1434
|
+
const raw = await this._store.get(link.target_id);
|
|
1435
|
+
if (!raw) continue;
|
|
1436
|
+
const rawMentions = await extractor(raw);
|
|
1437
|
+
for (const m of rawMentions) {
|
|
1438
|
+
const norm = normalizeName(m.name);
|
|
1439
|
+
if (!seenNames.has(norm)) {
|
|
1440
|
+
seenNames.add(norm);
|
|
1441
|
+
mentions.push(m);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
const personCardResults = [];
|
|
1447
|
+
const category = compiled.category || "people";
|
|
1448
|
+
|
|
1449
|
+
for (const mention of mentions) {
|
|
1450
|
+
// Resolve or create the person card
|
|
1451
|
+
const result = await resolvePersonCard(this._store, mention, category, agent);
|
|
1452
|
+
const { cardId, created, duplicate } = result;
|
|
1453
|
+
|
|
1454
|
+
// Build link sets for both sides
|
|
1455
|
+
const cardRecord = await this._store.get(cardId);
|
|
1456
|
+
const compiledRecord = await this._store.get(compiledId);
|
|
1457
|
+
|
|
1458
|
+
// Person card → compiled (appears-in) — skip if already linked
|
|
1459
|
+
const cardLinks = cardRecord.links || [];
|
|
1460
|
+
const hasCompiledLink = cardLinks.some(
|
|
1461
|
+
(l) => l.target_id === compiledId && l.kind === "appears-in"
|
|
1462
|
+
);
|
|
1463
|
+
if (!hasCompiledLink) {
|
|
1464
|
+
await this._store.link(
|
|
1465
|
+
cardId,
|
|
1466
|
+
[{ target_id: compiledId, kind: "appears-in" }],
|
|
1467
|
+
{ agent, note: `Person appears in compiled record` }
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Person card → each source raw (appears-in) — skip if already linked
|
|
1472
|
+
for (const link of sourceLinks) {
|
|
1473
|
+
const updatedCard = await this._store.get(cardId);
|
|
1474
|
+
const hasRawLink = (updatedCard.links || []).some(
|
|
1475
|
+
(l) => l.target_id === link.target_id && l.kind === "appears-in"
|
|
1476
|
+
);
|
|
1477
|
+
if (!hasRawLink) {
|
|
1478
|
+
await this._store.link(
|
|
1479
|
+
cardId,
|
|
1480
|
+
[{ target_id: link.target_id, kind: "appears-in" }],
|
|
1481
|
+
{ agent, note: `Person appears in raw source` }
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// Compiled record → person card (person link) — skip if already linked
|
|
1487
|
+
const compLinks = compiledRecord.links || [];
|
|
1488
|
+
const hasPersonLink = compLinks.some(
|
|
1489
|
+
(l) => l.target_id === cardId && l.kind === "person"
|
|
1490
|
+
);
|
|
1491
|
+
if (!hasPersonLink) {
|
|
1492
|
+
await this._store.link(
|
|
1493
|
+
compiledId,
|
|
1494
|
+
[{ target_id: cardId, kind: "person" }],
|
|
1495
|
+
{ agent, note: `Compiled record references person card` }
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
personCardResults.push({ cardId, name: mention.name, created, duplicate });
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
return {
|
|
1503
|
+
compiledId,
|
|
1504
|
+
personCards: personCardResults,
|
|
1505
|
+
linkCount: personCardResults.length,
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// -------------------------------------------------------------------------
|
|
1510
|
+
// knowledge.merge-person flow (R3 / AC3)
|
|
1511
|
+
// Merge two person cards via the existing propose→apply/reject gate.
|
|
1512
|
+
// On apply: union aliases + links → supersede the duplicate (archive).
|
|
1513
|
+
// On reject: both cards remain byte-identical.
|
|
1514
|
+
// -------------------------------------------------------------------------
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* Merge a duplicate person card into a primary card via gated propose/apply.
|
|
1518
|
+
*
|
|
1519
|
+
* On apply:
|
|
1520
|
+
* 1. Primary card body updated with unioned role text.
|
|
1521
|
+
* 2. Aliases from the duplicate appended to primary's tags as "alias:Name".
|
|
1522
|
+
* 3. All appears-in links from the duplicate are added to the primary.
|
|
1523
|
+
* 4. The duplicate is superseded (archived) via store.supersede().
|
|
1524
|
+
*
|
|
1525
|
+
* On reject:
|
|
1526
|
+
* Both cards remain byte-identical (AC3).
|
|
1527
|
+
*
|
|
1528
|
+
* @param {string} primaryId - ID of the primary person card to keep
|
|
1529
|
+
* @param {string} duplicateId - ID of the card being merged in
|
|
1530
|
+
* @param {object} [options]
|
|
1531
|
+
* - decision: "apply"|"reject" (default "apply")
|
|
1532
|
+
* - rationale: string (required for apply)
|
|
1533
|
+
* - rejectReason: string (required for reject)
|
|
1534
|
+
* - agent: string
|
|
1535
|
+
* @returns {Promise<{ primaryId, duplicateId, decision }>}
|
|
1536
|
+
*/
|
|
1537
|
+
async mergePerson(primaryId, duplicateId, options = {}) {
|
|
1538
|
+
const agent = options.agent || this._agent;
|
|
1539
|
+
const decision = options.decision || "apply";
|
|
1540
|
+
|
|
1541
|
+
const primary = await this._store.get(primaryId);
|
|
1542
|
+
if (!primary) throw new Error(`mergePerson: primary card not found: ${primaryId}`);
|
|
1543
|
+
if (primary.type !== "person") throw new Error(`mergePerson: primaryId must be a person record`);
|
|
1544
|
+
|
|
1545
|
+
const duplicate = await this._store.get(duplicateId);
|
|
1546
|
+
if (!duplicate) throw new Error(`mergePerson: duplicate card not found: ${duplicateId}`);
|
|
1547
|
+
if (duplicate.type !== "person") throw new Error(`mergePerson: duplicateId must be a person record`);
|
|
1548
|
+
|
|
1549
|
+
// propose: duplicate proposes a change to primary
|
|
1550
|
+
await this._store.propose(primaryId, duplicateId, {
|
|
1551
|
+
agent,
|
|
1552
|
+
proposal: `Merge duplicate person card "${duplicate.title}" into "${primary.title}"`,
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
if (decision === "apply") {
|
|
1556
|
+
if (!options.rationale || !options.rationale.trim()) {
|
|
1557
|
+
throw new Error("mergePerson: options.rationale is required when decision=apply");
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// Compute merged body: union role text
|
|
1561
|
+
const mergedBodyLines = [];
|
|
1562
|
+
if (primary.body && primary.body.trim()) mergedBodyLines.push(primary.body.trim());
|
|
1563
|
+
if (duplicate.body && duplicate.body.trim() && duplicate.body.trim() !== primary.body.trim()) {
|
|
1564
|
+
mergedBodyLines.push(duplicate.body.trim());
|
|
1565
|
+
}
|
|
1566
|
+
const mergedBody = mergedBodyLines.join("\n") || primary.title;
|
|
1567
|
+
|
|
1568
|
+
// Apply: update primary body
|
|
1569
|
+
await this._store.apply(primaryId, duplicateId, {
|
|
1570
|
+
agent,
|
|
1571
|
+
new_body: mergedBody,
|
|
1572
|
+
rationale: options.rationale,
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1575
|
+
// Add duplicate title as alias on primary
|
|
1576
|
+
const primaryAfterApply = await this._store.get(primaryId);
|
|
1577
|
+
const existingTags = primaryAfterApply.tags || [];
|
|
1578
|
+
const aliasTag = `alias:${duplicate.title}`;
|
|
1579
|
+
if (!existingTags.includes(aliasTag)) {
|
|
1580
|
+
await this._store.update(primaryId, { tags: [...existingTags, aliasTag] }, {
|
|
1581
|
+
agent,
|
|
1582
|
+
note: `Added alias from merged duplicate: ${duplicate.title}`,
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// Union appears-in links from duplicate to primary
|
|
1587
|
+
const dupLinks = (duplicate.links || []).filter((l) => l.kind === "appears-in");
|
|
1588
|
+
const primaryLinks = await this._store.getLinks(primaryId);
|
|
1589
|
+
for (const link of dupLinks) {
|
|
1590
|
+
const hasLink = primaryLinks.forward.some(
|
|
1591
|
+
(l) => l.target_id === link.target_id && l.kind === "appears-in"
|
|
1592
|
+
);
|
|
1593
|
+
if (!hasLink) {
|
|
1594
|
+
await this._store.link(primaryId, [{ target_id: link.target_id, kind: "appears-in" }], {
|
|
1595
|
+
agent,
|
|
1596
|
+
note: `Unioned from merged duplicate ${duplicateId}`,
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// Supersede the duplicate (archives it — supersede-not-delete invariant)
|
|
1602
|
+
await this._store.supersede(primaryId, [duplicateId], {
|
|
1603
|
+
agent,
|
|
1604
|
+
rationale: options.rationale,
|
|
1605
|
+
note: `Merged duplicate person card into ${primaryId}`,
|
|
1606
|
+
});
|
|
1607
|
+
} else if (decision === "reject") {
|
|
1608
|
+
if (!options.rejectReason || !options.rejectReason.trim()) {
|
|
1609
|
+
throw new Error("mergePerson: options.rejectReason is required when decision=reject");
|
|
1610
|
+
}
|
|
1611
|
+
// Reject: both cards remain byte-identical
|
|
1612
|
+
await this._store.reject(primaryId, duplicateId, {
|
|
1613
|
+
agent,
|
|
1614
|
+
reason: options.rejectReason,
|
|
1615
|
+
});
|
|
1616
|
+
} else {
|
|
1617
|
+
throw new Error(`mergePerson: decision must be "apply" or "reject"; got: ${decision}`);
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
return { primaryId, duplicateId, decision };
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1110
1623
|
}
|
|
1111
1624
|
|
|
1112
1625
|
// ---------------------------------------------------------------------------
|
|
1113
1626
|
// Helpers
|
|
1114
1627
|
// ---------------------------------------------------------------------------
|
|
1115
1628
|
|
|
1629
|
+
/**
|
|
1630
|
+
* Resolve or create a person card for a given mention.
|
|
1631
|
+
*
|
|
1632
|
+
* Resolution rules (R3):
|
|
1633
|
+
* 1. Exact normalised-name match (or alias match) → update existing card.
|
|
1634
|
+
* 2. Possible duplicate (same surname + initial) → create new card + related
|
|
1635
|
+
* link of kind "related" with a possible-duplicate tag.
|
|
1636
|
+
* 3. No match → create new card.
|
|
1637
|
+
*
|
|
1638
|
+
* @param {object} store - KnowledgeStoreAdapter
|
|
1639
|
+
* @param {object} mention - { name, role? }
|
|
1640
|
+
* @param {string} category - category for new card
|
|
1641
|
+
* @param {string} agent - agent name
|
|
1642
|
+
* @returns {Promise<{ cardId: string, created: boolean, duplicate: boolean }>}
|
|
1643
|
+
*/
|
|
1644
|
+
async function resolvePersonCard(store, mention, category, agent) {
|
|
1645
|
+
const existing = await store.listByType("person");
|
|
1646
|
+
|
|
1647
|
+
// 1. Exact match (name or alias)
|
|
1648
|
+
for (const card of existing) {
|
|
1649
|
+
if (isExactMatch(card.title, mention.name)) {
|
|
1650
|
+
return { cardId: card.id, created: false, duplicate: false };
|
|
1651
|
+
}
|
|
1652
|
+
// Check aliases tag: "alias:Some Name"
|
|
1653
|
+
const aliases = (card.tags || [])
|
|
1654
|
+
.filter((t) => t.startsWith("alias:"))
|
|
1655
|
+
.map((t) => t.slice("alias:".length));
|
|
1656
|
+
for (const alias of aliases) {
|
|
1657
|
+
if (isExactMatch(alias, mention.name)) {
|
|
1658
|
+
return { cardId: card.id, created: false, duplicate: false };
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// 2. Possible duplicate check
|
|
1664
|
+
let possibleDupId = null;
|
|
1665
|
+
for (const card of existing) {
|
|
1666
|
+
if (isPossibleDuplicate(mention.name, card.title)) {
|
|
1667
|
+
possibleDupId = card.id;
|
|
1668
|
+
break;
|
|
1669
|
+
}
|
|
1670
|
+
const aliases = (card.tags || [])
|
|
1671
|
+
.filter((t) => t.startsWith("alias:"))
|
|
1672
|
+
.map((t) => t.slice("alias:".length));
|
|
1673
|
+
for (const alias of aliases) {
|
|
1674
|
+
if (isPossibleDuplicate(mention.name, alias)) {
|
|
1675
|
+
possibleDupId = card.id;
|
|
1676
|
+
break;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
if (possibleDupId) break;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// Build body: role/org as structured prose
|
|
1683
|
+
const bodyLines = [];
|
|
1684
|
+
if (mention.role) {
|
|
1685
|
+
bodyLines.push(`**Role/Org:** ${mention.role}`);
|
|
1686
|
+
}
|
|
1687
|
+
const body = bodyLines.length > 0 ? bodyLines.join("\n") : mention.name;
|
|
1688
|
+
|
|
1689
|
+
// Create new person card
|
|
1690
|
+
const cardId = await store.create({
|
|
1691
|
+
type: "person",
|
|
1692
|
+
title: mention.name,
|
|
1693
|
+
body,
|
|
1694
|
+
category,
|
|
1695
|
+
tags: [],
|
|
1696
|
+
provenance: { agent, note: `Auto-created from entity extraction` },
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
// If possible duplicate: add related link from new card to existing card
|
|
1700
|
+
if (possibleDupId) {
|
|
1701
|
+
await store.link(
|
|
1702
|
+
cardId,
|
|
1703
|
+
[{ target_id: possibleDupId, kind: "related", label: "possible-duplicate" }],
|
|
1704
|
+
{ agent, note: "Possible duplicate — same surname+initial; verify manually" }
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
return { cardId, created: true, duplicate: possibleDupId !== null };
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1116
1711
|
function mostCommonCategory(records) {
|
|
1117
1712
|
const counts = {};
|
|
1118
1713
|
for (const r of records) {
|
|
@@ -1176,4 +1771,48 @@ export async function consolidate(
|
|
|
1176
1771
|
return runner.consolidate(snapshotIdOrTopic, consolidateOpts);
|
|
1177
1772
|
}
|
|
1178
1773
|
|
|
1774
|
+
/**
|
|
1775
|
+
* Module-level retire: creates an ephemeral runner using the provided store.
|
|
1776
|
+
*
|
|
1777
|
+
* @param {string} recordId
|
|
1778
|
+
* @param {object} options (merged into retire options + runner options)
|
|
1779
|
+
*/
|
|
1780
|
+
export async function retire(
|
|
1781
|
+
recordId,
|
|
1782
|
+
{ store, workspace, agent, sessionId, ...retireOpts } = {}
|
|
1783
|
+
) {
|
|
1784
|
+
const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
|
|
1785
|
+
return runner.retire(recordId, retireOpts);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1179
1788
|
export default KnowledgeFlowRunner;
|
|
1789
|
+
|
|
1790
|
+
/**
|
|
1791
|
+
* Module-level extractEntities: creates an ephemeral runner using the provided store.
|
|
1792
|
+
*
|
|
1793
|
+
* @param {string} compiledId
|
|
1794
|
+
* @param {object} options (merged into extractEntities options + runner options)
|
|
1795
|
+
*/
|
|
1796
|
+
export async function extractEntities(
|
|
1797
|
+
compiledId,
|
|
1798
|
+
{ store, workspace, agent, sessionId, ...extractOpts } = {}
|
|
1799
|
+
) {
|
|
1800
|
+
const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
|
|
1801
|
+
return runner.extractEntities(compiledId, extractOpts);
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/**
|
|
1805
|
+
* Module-level mergePerson: creates an ephemeral runner using the provided store.
|
|
1806
|
+
*
|
|
1807
|
+
* @param {string} primaryId
|
|
1808
|
+
* @param {string} duplicateId
|
|
1809
|
+
* @param {object} options
|
|
1810
|
+
*/
|
|
1811
|
+
export async function mergePerson(
|
|
1812
|
+
primaryId,
|
|
1813
|
+
duplicateId,
|
|
1814
|
+
{ store, workspace, agent, sessionId, ...mergeOpts } = {}
|
|
1815
|
+
) {
|
|
1816
|
+
const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
|
|
1817
|
+
return runner.mergePerson(primaryId, duplicateId, mergeOpts);
|
|
1818
|
+
}
|