@kontourai/flow-agents 0.4.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/CHANGELOG.md +35 -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/kits/catalog.json +6 -0
- package/kits/knowledge/adapters/default-store/index.js +2 -2
- package/kits/knowledge/adapters/flow-runner/entity-extractor.js +194 -0
- package/kits/knowledge/adapters/flow-runner/index.js +349 -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/docs/store-contract.md +72 -0
- package/kits/knowledge/evals/entities/demo-acme.js +125 -0
- package/kits/knowledge/evals/entities/suite.test.js +722 -0
- package/kits/knowledge/kit.json +10 -0
- 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
|
@@ -35,6 +35,12 @@
|
|
|
35
35
|
import * as path from "node:path";
|
|
36
36
|
import { fileURLToPath } from "node:url";
|
|
37
37
|
import { KnowledgeTelemetry } from "./telemetry.js";
|
|
38
|
+
import {
|
|
39
|
+
defaultEntityExtractor,
|
|
40
|
+
normalizeName,
|
|
41
|
+
isExactMatch,
|
|
42
|
+
isPossibleDuplicate,
|
|
43
|
+
} from "./entity-extractor.js";
|
|
38
44
|
|
|
39
45
|
// ---------------------------------------------------------------------------
|
|
40
46
|
// Error helpers
|
|
@@ -1383,12 +1389,325 @@ export class KnowledgeFlowRunner {
|
|
|
1383
1389
|
};
|
|
1384
1390
|
}
|
|
1385
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
|
+
|
|
1386
1623
|
}
|
|
1387
1624
|
|
|
1388
1625
|
// ---------------------------------------------------------------------------
|
|
1389
1626
|
// Helpers
|
|
1390
1627
|
// ---------------------------------------------------------------------------
|
|
1391
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
|
+
|
|
1392
1711
|
function mostCommonCategory(records) {
|
|
1393
1712
|
const counts = {};
|
|
1394
1713
|
for (const r of records) {
|
|
@@ -1467,3 +1786,33 @@ export async function retire(
|
|
|
1467
1786
|
}
|
|
1468
1787
|
|
|
1469
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
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Knowledge Kit — Obsidian Store Adapter
|
|
2
|
+
|
|
3
|
+
Spike verdict: **RATIFY**
|
|
4
|
+
|
|
5
|
+
The "file is the record" thesis holds. Each Knowledge Kit record maps to exactly
|
|
6
|
+
one Obsidian-native markdown note. Frontmatter carries the full contract payload;
|
|
7
|
+
the markdown body is human-readable Obsidian rendering. The adapter passes the
|
|
8
|
+
full 48-test contract suite without modifications.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## The File-Is-the-Record Thesis
|
|
13
|
+
|
|
14
|
+
A Knowledge Kit record has a canonical identity (`id`), a structured payload
|
|
15
|
+
(type, category, provenance, links, mutation_log), and a human-facing body.
|
|
16
|
+
The Obsidian store places ALL of this in a single `.md` file:
|
|
17
|
+
|
|
18
|
+
- YAML frontmatter holds every contract field including `body`.
|
|
19
|
+
- The markdown section below `---` is rendered for Obsidian readability only —
|
|
20
|
+
it is decorative and not read back on load.
|
|
21
|
+
- The file IS the record. No separate database. No shadow index for content.
|
|
22
|
+
|
|
23
|
+
This is the core claim of the spike: **a single Obsidian note can faithfully
|
|
24
|
+
represent a Knowledge Kit record** with zero fidelity loss.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## File Shape
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
---
|
|
32
|
+
id: <uuid>
|
|
33
|
+
type: raw | compiled | concept | snapshot
|
|
34
|
+
title: <string>
|
|
35
|
+
category: eng.decisions
|
|
36
|
+
tags: [tag-a, tag-b]
|
|
37
|
+
status: active | implemented | retired
|
|
38
|
+
created_at: <ISO8601>
|
|
39
|
+
updated_at: <ISO8601>
|
|
40
|
+
provenance:
|
|
41
|
+
agent: <string>
|
|
42
|
+
session_id: <optional>
|
|
43
|
+
source_ids: [<optional uuid list>]
|
|
44
|
+
links:
|
|
45
|
+
- target_id: <uuid>
|
|
46
|
+
kind: related | source | proposes | supersedes | refines
|
|
47
|
+
label: <optional>
|
|
48
|
+
mutation_log:
|
|
49
|
+
- op: update
|
|
50
|
+
at: <ISO8601>
|
|
51
|
+
agent: <string>
|
|
52
|
+
evidence:
|
|
53
|
+
fields: [title, body]
|
|
54
|
+
body: "The full contract body text stored here for round-trip fidelity."
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
<!-- Obsidian-readable section below — decorative, not parsed on load -->
|
|
58
|
+
|
|
59
|
+
> [!note]- Raw Notes ← raw type: collapsed callout
|
|
60
|
+
> Original capture text here.
|
|
61
|
+
|
|
62
|
+
## Sources ← compiled/concept/snapshot: wikilinks to sources
|
|
63
|
+
|
|
64
|
+
[[source-slug|Source Note]]
|
|
65
|
+
|
|
66
|
+
## Related
|
|
67
|
+
|
|
68
|
+
[[related-slug]]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Storage Layout
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
<storeRoot>/
|
|
77
|
+
<category/as/path>/<title-slug>.md active records
|
|
78
|
+
archive/<category/as/path>/<slug>.md superseded records (moved, not deleted)
|
|
79
|
+
graph-index.json link graph (suite §13 requirement)
|
|
80
|
+
.graph-index.json path index (id → {path, archived})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- Category dots map to directory segments: `eng.decisions` → `eng/decisions/`.
|
|
84
|
+
- Title is slugified for the filename: `My Decision` → `my-decision.md`.
|
|
85
|
+
- Filename collisions (same slug, different id) get a suffix: `my-decision-2.md`.
|
|
86
|
+
- When a record is superseded, its file MOVES to `archive/` (supersede-not-delete
|
|
87
|
+
invariant). The record remains fully queryable via `get(id)`.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Spike Gaps Found
|
|
92
|
+
|
|
93
|
+
1. **Multi-file wikilink resolution**: Obsidian resolves `[[slug]]` links by
|
|
94
|
+
filename across the entire vault, regardless of folder. The adapter renders
|
|
95
|
+
wikilinks using the filename slug for human readability, but the canonical
|
|
96
|
+
link data (stored as UUIDs in frontmatter) is what the contract uses. If a
|
|
97
|
+
user clicks a wikilink in Obsidian, it navigates by slug — not by UUID —
|
|
98
|
+
which may not match if slugs collide across categories.
|
|
99
|
+
|
|
100
|
+
2. **Vault-level wikilink display**: Obsidian flattens `[[slug]]` resolution.
|
|
101
|
+
Two records in different categories that happen to share a slug
|
|
102
|
+
(`eng/api/deploy.md` and `ops/api/deploy.md`) would cause Obsidian link
|
|
103
|
+
ambiguity. The contract itself is unaffected (UUIDs in frontmatter win),
|
|
104
|
+
but the human-readable `## Related` section would be ambiguous in Obsidian.
|
|
105
|
+
Mitigation: use category-prefixed slugs in the Obsidian body rendering.
|
|
106
|
+
|
|
107
|
+
3. **Frontmatter `body` YAML quoting for complex content**: Bodies containing
|
|
108
|
+
Obsidian callout syntax (`> [!note]`) or multi-paragraph content serialize
|
|
109
|
+
correctly via the shared codec's `yamlScalar` quoting, but the result can be
|
|
110
|
+
a long single-line quoted string in frontmatter. Obsidian displays this fine,
|
|
111
|
+
but it is less human-editable than a literal block scalar. A future YAML block
|
|
112
|
+
scalar (`|`) serializer would improve ergonomics.
|
|
113
|
+
|
|
114
|
+
4. **Obsidian sync conflicts**: If two agents write to the same record
|
|
115
|
+
concurrently, Obsidian Sync may create conflict copies. The adapter uses no
|
|
116
|
+
file locking. This is acceptable for a spike but would need attention in
|
|
117
|
+
production use with live sync.
|
|
118
|
+
|
|
119
|
+
5. **Archive folder visibility**: The `archive/` subdirectory will appear in the
|
|
120
|
+
Obsidian vault file explorer. This is intentional (superseded records remain
|
|
121
|
+
inspectable) but users may want to exclude it via `.obsidianignore` or a
|
|
122
|
+
dedicated vault folder setting.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Spike Verdict: RATIFY
|
|
127
|
+
|
|
128
|
+
The adapter proves:
|
|
129
|
+
|
|
130
|
+
- A single Obsidian note is a sufficient and non-lossy representation of a
|
|
131
|
+
Knowledge Kit record, including all Addendum A (snapshot/supersede) and
|
|
132
|
+
Addendum B (status/retire) contract extensions.
|
|
133
|
+
- The category hierarchy maps naturally to Obsidian folder organization.
|
|
134
|
+
- Supersede-not-delete is expressible as an archive move within the vault.
|
|
135
|
+
- Human readability (callouts, Sources/Related wikilink sections) coexists with
|
|
136
|
+
machine-readable frontmatter without duplication errors.
|
|
137
|
+
- The contract suite (48/48) passes without modification.
|
|
138
|
+
|
|
139
|
+
**Recommendation**: Proceed with the Obsidian store adapter as a supported adapter
|
|
140
|
+
alongside the default store. Address gap #2 (slug ambiguity) before marking the
|
|
141
|
+
adapter production-ready for multi-category vaults.
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Knowledge Kit — Obsidian Store Demo
|
|
4
|
+
*
|
|
5
|
+
* Generates a small set of realistic sample notes in a temporary vault,
|
|
6
|
+
* then prints the snapshot note verbatim to demonstrate the file-is-the-record
|
|
7
|
+
* thesis.
|
|
8
|
+
*
|
|
9
|
+
* Run:
|
|
10
|
+
* node kits/knowledge/adapters/obsidian-store/demo.js
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const adapterPath = path.join(__dirname, "index.js");
|
|
20
|
+
const { ObsidianKnowledgeStore } = await import(adapterPath);
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Setup: ephemeral vault
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
const vaultRoot = fs.mkdtempSync(path.join(os.tmpdir(), "obsidian-demo-"));
|
|
27
|
+
const store = new ObsidianKnowledgeStore({ storeRoot: vaultRoot });
|
|
28
|
+
|
|
29
|
+
console.log(`\nDemo vault: ${vaultRoot}\n`);
|
|
30
|
+
console.log("─".repeat(70));
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// 1. Raw capture: meeting transcript
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const rawId = await store.create({
|
|
37
|
+
type: "raw",
|
|
38
|
+
title: "Q2 Planning Meeting — Transcript",
|
|
39
|
+
body: `Discussed moving deploy pipeline to GitHub Actions.
|
|
40
|
+
Ben: we should retire the Jenkins instance, it's costing us $300/mo.
|
|
41
|
+
Alice: agreed, but we need the secrets migration plan first.
|
|
42
|
+
Action items: Alice to draft secrets migration doc by Friday.`,
|
|
43
|
+
category: "eng.decisions",
|
|
44
|
+
tags: ["meeting", "infra", "q2"],
|
|
45
|
+
provenance: {
|
|
46
|
+
agent: "demo-agent",
|
|
47
|
+
session_id: "demo-sess-001",
|
|
48
|
+
note: "Captured from Zoom transcript",
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
console.log(`\n[1/4] Created raw capture: ${rawId}`);
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// 2. Compiled: distilled from the raw
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
const compiledId = await store.create({
|
|
59
|
+
type: "compiled",
|
|
60
|
+
title: "Jenkins Retirement Decision",
|
|
61
|
+
body: `Team agreed to retire the Jenkins CI instance in Q2.
|
|
62
|
+
Prerequisite: secrets migration plan must be completed first (owner: Alice).
|
|
63
|
+
Cost savings: ~$300/month after retirement.`,
|
|
64
|
+
category: "eng.decisions",
|
|
65
|
+
tags: ["infra", "ci", "cost"],
|
|
66
|
+
links: [{ target_id: rawId, kind: "source" }],
|
|
67
|
+
provenance: {
|
|
68
|
+
agent: "demo-agent",
|
|
69
|
+
source_ids: [rawId],
|
|
70
|
+
note: "Compiled from Q2 planning meeting transcript",
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
console.log(`[2/4] Created compiled note: ${compiledId}`);
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// 3. Placeholder snapshot (to be superseded)
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
const placeholderSnapshotId = await store.create({
|
|
81
|
+
type: "snapshot",
|
|
82
|
+
title: "CI Strategy Snapshot — Draft",
|
|
83
|
+
body: "Placeholder: CI strategy under discussion. No final decision yet.",
|
|
84
|
+
category: "eng.decisions",
|
|
85
|
+
tags: ["ci", "strategy", "draft"],
|
|
86
|
+
provenance: {
|
|
87
|
+
agent: "demo-agent",
|
|
88
|
+
note: "Placeholder snapshot pending planning meeting outcome",
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
console.log(`[3/4] Created placeholder snapshot: ${placeholderSnapshotId}`);
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// 4. Final snapshot that supersedes the placeholder
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
const finalSnapshotId = await store.create({
|
|
99
|
+
type: "snapshot",
|
|
100
|
+
title: "CI Strategy Snapshot — Q2 2026",
|
|
101
|
+
body: `Decision: Migrate from Jenkins to GitHub Actions in Q2 2026.
|
|
102
|
+
|
|
103
|
+
Key constraints:
|
|
104
|
+
- Secrets migration must complete before Jenkins decommission.
|
|
105
|
+
- Target: Jenkins instance retired by end of June 2026.
|
|
106
|
+
- Owner: Platform team (Alice lead).
|
|
107
|
+
|
|
108
|
+
Cost impact: -$300/month after retirement.`,
|
|
109
|
+
category: "eng.decisions",
|
|
110
|
+
tags: ["ci", "strategy", "q2-2026"],
|
|
111
|
+
links: [
|
|
112
|
+
{ target_id: compiledId, kind: "source" },
|
|
113
|
+
],
|
|
114
|
+
provenance: {
|
|
115
|
+
agent: "demo-agent",
|
|
116
|
+
source_ids: [compiledId],
|
|
117
|
+
note: "Final Q2 snapshot after planning meeting",
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Supersede the placeholder
|
|
122
|
+
await store.supersede(finalSnapshotId, [placeholderSnapshotId], {
|
|
123
|
+
agent: "demo-agent",
|
|
124
|
+
rationale: "Q2 planning meeting resolved the CI strategy; placeholder superseded by final snapshot.",
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
console.log(`[4/4] Created final snapshot ${finalSnapshotId} (supersedes ${placeholderSnapshotId})\n`);
|
|
128
|
+
console.log("─".repeat(70));
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Print the final snapshot note verbatim
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
// Find the file via the path index
|
|
135
|
+
const pathIndexRaw = fs.readFileSync(path.join(vaultRoot, ".graph-index.json"), "utf8");
|
|
136
|
+
const pathIndex = JSON.parse(pathIndexRaw);
|
|
137
|
+
const snapshotEntry = pathIndex.by_id[finalSnapshotId];
|
|
138
|
+
const snapshotPath = path.join(vaultRoot, snapshotEntry.path);
|
|
139
|
+
|
|
140
|
+
console.log(`\nFinal snapshot note on disk: ${snapshotPath}\n`);
|
|
141
|
+
console.log("═".repeat(70));
|
|
142
|
+
console.log(fs.readFileSync(snapshotPath, "utf8"));
|
|
143
|
+
console.log("═".repeat(70));
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Show the archived placeholder
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
const archivedEntry = pathIndex.by_id[placeholderSnapshotId];
|
|
150
|
+
console.log(`\nSuperseded placeholder archived at: ${archivedEntry.path}`);
|
|
151
|
+
console.log(`(archived: ${archivedEntry.archived})`);
|
|
152
|
+
|
|
153
|
+
// Confirm it's still queryable
|
|
154
|
+
const archived = await store.get(placeholderSnapshotId);
|
|
155
|
+
console.log(`\nArchived record still queryable via get(): title = "${archived.title}"`);
|
|
156
|
+
console.log(`Mutation log has ${archived.mutation_log.length} entr${archived.mutation_log.length === 1 ? "y" : "ies"}.`);
|
|
157
|
+
const sbEntry = archived.mutation_log.find((e) => e.op === "superseded-by");
|
|
158
|
+
if (sbEntry) {
|
|
159
|
+
console.log(` - superseded-by: new_id = ${sbEntry.new_id}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Vault structure
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
console.log("\nVault layout:");
|
|
167
|
+
function printTree(dir, prefix = "") {
|
|
168
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
169
|
+
for (const entry of entries) {
|
|
170
|
+
const isLast = entry === entries[entries.length - 1];
|
|
171
|
+
console.log(`${prefix}${isLast ? "└── " : "├── "}${entry.name}`);
|
|
172
|
+
if (entry.isDirectory()) {
|
|
173
|
+
printTree(path.join(dir, entry.name), prefix + (isLast ? " " : "│ "));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
printTree(vaultRoot);
|
|
178
|
+
|
|
179
|
+
// Cleanup
|
|
180
|
+
fs.rmSync(vaultRoot, { recursive: true, force: true });
|
|
181
|
+
console.log(`\nDemo vault cleaned up.\n`);
|