@indigoai-us/hq-cli 5.221.0 → 5.222.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/CHANGELOG.md +12 -0
- package/dist/command-catalog.generated.d.ts +18 -0
- package/dist/command-catalog.generated.js +23 -0
- package/dist/commands/core-ontology.d.ts +6 -0
- package/dist/commands/core-ontology.js +61 -5
- package/dist/lib/checkpoint-ontology/aliases.d.ts +17 -0
- package/dist/lib/checkpoint-ontology/aliases.js +44 -0
- package/dist/lib/checkpoint-ontology/apply.d.ts +10 -0
- package/dist/lib/checkpoint-ontology/apply.js +61 -44
- package/dist/lib/checkpoint-ontology/entity-file.d.ts +2 -0
- package/dist/lib/checkpoint-ontology/entity-file.js +2 -1
- package/dist/lib/checkpoint-ontology/multi-writer.d.ts +106 -0
- package/dist/lib/checkpoint-ontology/multi-writer.js +391 -0
- package/dist/lib/checkpoint-ontology/plan.d.ts +10 -0
- package/dist/lib/checkpoint-ontology/plan.js +23 -5
- package/dist/lib/checkpoint-ontology/tree.d.ts +2 -0
- package/dist/lib/checkpoint-ontology/tree.js +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.222.0] — 2026-09-26
|
|
6
|
+
|
|
7
|
+
- Checkpoint ontology, behind `checkpoint.ontology.multi-writer` (off by
|
|
8
|
+
default): entity versions that sync set aside after losing a push race are
|
|
9
|
+
merged back at the start of each plan (aliases, back-references, the higher
|
|
10
|
+
`signal_count`, the later `last_updated`). Any other difference keeps both
|
|
11
|
+
and leaves the set-aside copy, and an entity deleted since is not
|
|
12
|
+
re-created. Every entity file the step writes is recorded in
|
|
13
|
+
`.hq/ontology-outbox/<company>.json`. The apply report counts duplicate
|
|
14
|
+
entity groups, and `hq core ontology duplicates --company <slug> [--json]`
|
|
15
|
+
lists them.
|
|
16
|
+
|
|
5
17
|
## [5.221.0] — 2026-09-26
|
|
6
18
|
|
|
7
19
|
### Added
|
|
@@ -5124,6 +5124,24 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
5124
5124
|
readonly description: "HQ installation to operate on";
|
|
5125
5125
|
}];
|
|
5126
5126
|
readonly subcommands: readonly [];
|
|
5127
|
+
}, {
|
|
5128
|
+
readonly name: "duplicates";
|
|
5129
|
+
readonly description: "List same-type entities whose names collide, with a proposed survivor (read-only)";
|
|
5130
|
+
readonly aliases: readonly [];
|
|
5131
|
+
readonly hidden: false;
|
|
5132
|
+
readonly usage: "[options]";
|
|
5133
|
+
readonly arguments: readonly [];
|
|
5134
|
+
readonly options: readonly [{
|
|
5135
|
+
readonly flags: "--company <slug>";
|
|
5136
|
+
readonly description: "company to scan";
|
|
5137
|
+
}, {
|
|
5138
|
+
readonly flags: "--json";
|
|
5139
|
+
readonly description: "print JSON";
|
|
5140
|
+
}, {
|
|
5141
|
+
readonly flags: "--hq-root <path>";
|
|
5142
|
+
readonly description: "HQ installation to operate on";
|
|
5143
|
+
}];
|
|
5144
|
+
readonly subcommands: readonly [];
|
|
5127
5145
|
}, {
|
|
5128
5146
|
readonly name: "apply";
|
|
5129
5147
|
readonly description: "Validate the run's entity candidates and write them to the payload company";
|
|
@@ -6626,6 +6626,29 @@ export const COMMAND_CATALOG = [
|
|
|
6626
6626
|
],
|
|
6627
6627
|
"subcommands": []
|
|
6628
6628
|
},
|
|
6629
|
+
{
|
|
6630
|
+
"name": "duplicates",
|
|
6631
|
+
"description": "List same-type entities whose names collide, with a proposed survivor (read-only)",
|
|
6632
|
+
"aliases": [],
|
|
6633
|
+
"hidden": false,
|
|
6634
|
+
"usage": "[options]",
|
|
6635
|
+
"arguments": [],
|
|
6636
|
+
"options": [
|
|
6637
|
+
{
|
|
6638
|
+
"flags": "--company <slug>",
|
|
6639
|
+
"description": "company to scan"
|
|
6640
|
+
},
|
|
6641
|
+
{
|
|
6642
|
+
"flags": "--json",
|
|
6643
|
+
"description": "print JSON"
|
|
6644
|
+
},
|
|
6645
|
+
{
|
|
6646
|
+
"flags": "--hq-root <path>",
|
|
6647
|
+
"description": "HQ installation to operate on"
|
|
6648
|
+
}
|
|
6649
|
+
],
|
|
6650
|
+
"subcommands": []
|
|
6651
|
+
},
|
|
6629
6652
|
{
|
|
6630
6653
|
"name": "apply",
|
|
6631
6654
|
"description": "Validate the run's entity candidates and write them to the payload company",
|
|
@@ -18,5 +18,11 @@ export interface OntologyCommandDeps {
|
|
|
18
18
|
}
|
|
19
19
|
export declare function runOntologyPlan(hqRoot: string, runDirOption: string | undefined, deps?: OntologyCommandDeps): Promise<string>;
|
|
20
20
|
export declare function runOntologyApply(hqRoot: string, runDirOption: string | undefined, deps?: OntologyCommandDeps): string;
|
|
21
|
+
/**
|
|
22
|
+
* `hq core ontology duplicates`: every group of same-type entities whose
|
|
23
|
+
* names collide, with a proposed survivor. Read-only; gated by
|
|
24
|
+
* `checkpoint.ontology.multi-writer`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function runOntologyDuplicates(hqRoot: string, slug: string | undefined, json: boolean, deps?: OntologyCommandDeps): Promise<string>;
|
|
21
27
|
export declare function registerCoreOntologyCommand(core: Command): void;
|
|
22
28
|
//# sourceMappingURL=core-ontology.d.ts.map
|
|
@@ -11,9 +11,11 @@ import * as fs from "node:fs";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { resolveLiveRoot } from "../utils/hq-roots.js";
|
|
13
13
|
import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
|
|
14
|
-
import { planOntologyRun } from "../lib/checkpoint-ontology/plan.js";
|
|
14
|
+
import { MULTI_WRITER_FLAG, planOntologyRun, } from "../lib/checkpoint-ontology/plan.js";
|
|
15
15
|
import { applyOntologyRun, ApplyError } from "../lib/checkpoint-ontology/apply.js";
|
|
16
|
-
import { WriteBoundsError } from "../lib/checkpoint-ontology/company.js";
|
|
16
|
+
import { resolveOntologyCompany, WriteBoundsError } from "../lib/checkpoint-ontology/company.js";
|
|
17
|
+
import { findDuplicateGroups, summarizeMergeBack, } from "../lib/checkpoint-ontology/multi-writer.js";
|
|
18
|
+
import { buildEntityIndex } from "../lib/checkpoint-ontology/tree.js";
|
|
17
19
|
export const ONTOLOGY_PAYLOAD_FILE = "payload.json";
|
|
18
20
|
class OntologyUsageError extends Error {
|
|
19
21
|
}
|
|
@@ -49,6 +51,16 @@ function companyBriefSuffix(brief) {
|
|
|
49
51
|
return "";
|
|
50
52
|
return `; company brief ${brief.status}${brief.reason ? ` (${brief.reason})` : ""}`;
|
|
51
53
|
}
|
|
54
|
+
function multiWriterSuffix(start) {
|
|
55
|
+
if (!start)
|
|
56
|
+
return "";
|
|
57
|
+
const parts = [];
|
|
58
|
+
if (start.merge_back.length > 0)
|
|
59
|
+
parts.push(`set-aside versions: ${summarizeMergeBack(start.merge_back)}`);
|
|
60
|
+
if (start.error)
|
|
61
|
+
parts.push(`multi-writer error ${start.error}`);
|
|
62
|
+
return parts.length > 0 ? `; ${parts.join("; ")}` : "";
|
|
63
|
+
}
|
|
52
64
|
export async function runOntologyPlan(hqRoot, runDirOption, deps = {}) {
|
|
53
65
|
const runDir = requireRunDir(runDirOption);
|
|
54
66
|
const outcome = await planOntologyRun({
|
|
@@ -59,12 +71,13 @@ export async function runOntologyPlan(hqRoot, runDirOption, deps = {}) {
|
|
|
59
71
|
now: deps.now?.(),
|
|
60
72
|
});
|
|
61
73
|
if (outcome.status === "skipped") {
|
|
62
|
-
return `ontology: skipped (${outcome.reason})${companyBriefSuffix(outcome.companyBrief)}`;
|
|
74
|
+
return `ontology: skipped (${outcome.reason})${companyBriefSuffix(outcome.companyBrief)}${multiWriterSuffix(outcome.multiWriter)}`;
|
|
63
75
|
}
|
|
64
76
|
const { plan } = outcome;
|
|
65
77
|
return (`ontology: planned ${plan.inputs.length} input(s), ~${plan.budget.planned_tokens} tokens, ` +
|
|
66
78
|
`${plan.pending_after_run} left for later runs; plan at ${outcome.planPath}` +
|
|
67
|
-
companyBriefSuffix(outcome.companyBrief)
|
|
79
|
+
companyBriefSuffix(outcome.companyBrief) +
|
|
80
|
+
multiWriterSuffix(plan.multi_writer));
|
|
68
81
|
}
|
|
69
82
|
export function runOntologyApply(hqRoot, runDirOption, deps = {}) {
|
|
70
83
|
const runDir = requireRunDir(runDirOption);
|
|
@@ -78,7 +91,36 @@ export function runOntologyApply(hqRoot, runDirOption, deps = {}) {
|
|
|
78
91
|
return (`ontology: ${report.created.length} created, ${report.updated.length} updated, ` +
|
|
79
92
|
`${report.matched} matched, ${report.rejected.length} rejected, ${report.deferred.length} deferred, ` +
|
|
80
93
|
`${report.suspect_duplicates.length} suspect duplicate(s), ${report.write_errors.length} write error(s)` +
|
|
81
|
-
companyBriefSuffix(report.company_brief)
|
|
94
|
+
companyBriefSuffix(report.company_brief) +
|
|
95
|
+
(report.multi_writer?.duplicates ? `; ${report.multi_writer.duplicates.groups} duplicate group(s) in the company` : "") +
|
|
96
|
+
(report.multi_writer?.error ? `; multi-writer error ${report.multi_writer.error}` : ""));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* `hq core ontology duplicates`: every group of same-type entities whose
|
|
100
|
+
* names collide, with a proposed survivor. Read-only; gated by
|
|
101
|
+
* `checkpoint.ontology.multi-writer`.
|
|
102
|
+
*/
|
|
103
|
+
export async function runOntologyDuplicates(hqRoot, slug, json, deps = {}) {
|
|
104
|
+
const resolution = resolveOntologyCompany(hqRoot, slug);
|
|
105
|
+
if (!resolution.ok)
|
|
106
|
+
throw new OntologyUsageError(`ontology: company rejected: ${resolution.reason}`);
|
|
107
|
+
const company = resolution.company;
|
|
108
|
+
if (!(await (deps.readFlag ?? registryFlagReader)(MULTI_WRITER_FLAG, company))) {
|
|
109
|
+
return json ? JSON.stringify({ company: company.slug, skipped: "flag off" }) : `ontology duplicates: skipped (${MULTI_WRITER_FLAG} is off)`;
|
|
110
|
+
}
|
|
111
|
+
const groups = findDuplicateGroups(buildEntityIndex(company.dir));
|
|
112
|
+
if (json)
|
|
113
|
+
return JSON.stringify({ company: company.slug, groups }, null, 2);
|
|
114
|
+
if (groups.length === 0)
|
|
115
|
+
return `ontology duplicates: none in ${company.slug}`;
|
|
116
|
+
const lines = [`ontology duplicates: ${groups.length} group(s) in ${company.slug}`];
|
|
117
|
+
for (const group of groups) {
|
|
118
|
+
lines.push(`${group.type} (${group.reason.replaceAll("_", " ")}), keep ${group.survivor}`);
|
|
119
|
+
for (const rel of group.paths)
|
|
120
|
+
if (rel !== group.survivor)
|
|
121
|
+
lines.push(` ${rel}`);
|
|
122
|
+
}
|
|
123
|
+
return lines.join("\n");
|
|
82
124
|
}
|
|
83
125
|
function fail(error) {
|
|
84
126
|
if (error instanceof OntologyUsageError || error instanceof ApplyError || error instanceof WriteBoundsError) {
|
|
@@ -105,6 +147,20 @@ export function registerCoreOntologyCommand(core) {
|
|
|
105
147
|
fail(error);
|
|
106
148
|
}
|
|
107
149
|
});
|
|
150
|
+
ontology
|
|
151
|
+
.command("duplicates")
|
|
152
|
+
.description("List same-type entities whose names collide, with a proposed survivor (read-only)")
|
|
153
|
+
.requiredOption("--company <slug>", "company to scan")
|
|
154
|
+
.option("--json", "print JSON")
|
|
155
|
+
.option("--hq-root <path>", "HQ installation to operate on")
|
|
156
|
+
.action(async (options) => {
|
|
157
|
+
try {
|
|
158
|
+
process.stdout.write(`${await runOntologyDuplicates(rootFor(options), options.company, options.json === true)}\n`);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
fail(error);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
108
164
|
ontology
|
|
109
165
|
.command("apply")
|
|
110
166
|
.description("Validate the run's entity candidates and write them to the payload company")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alias rules shared by `apply` and the multi-writer merge-back.
|
|
3
|
+
*/
|
|
4
|
+
import type { EntityIndex } from "./tree.js";
|
|
5
|
+
/** Longest canonical name or alias the writer accepts. */
|
|
6
|
+
export declare const MAX_NAME_LENGTH = 200;
|
|
7
|
+
/** The index key for a normalised name of one type. */
|
|
8
|
+
export declare function entityKey(type: string, normalized: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Aliases worth adding to the entity at `self`: new to it, and held by no
|
|
11
|
+
* other entity of any type, so an alias can never make a later match
|
|
12
|
+
* ambiguous. `own` holds the normalised names the entity already answers to.
|
|
13
|
+
*/
|
|
14
|
+
export declare function freshAliases(index: EntityIndex, type: string, self: string, own: Set<string>, names: string[]): string[];
|
|
15
|
+
/** The normalised names an indexed entity answers to, plus aliases queued this run. */
|
|
16
|
+
export declare function ownNames(index: EntityIndex, rel: string, queued: string[]): Set<string>;
|
|
17
|
+
//# sourceMappingURL=aliases.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alias rules shared by `apply` and the multi-writer merge-back.
|
|
3
|
+
*/
|
|
4
|
+
import { canonicalizeName, isRejectedName, normalizeEntityName } from "./identity.js";
|
|
5
|
+
/** Longest canonical name or alias the writer accepts. */
|
|
6
|
+
export const MAX_NAME_LENGTH = 200;
|
|
7
|
+
/** The index key for a normalised name of one type. */
|
|
8
|
+
export function entityKey(type, normalized) {
|
|
9
|
+
return `${type}\0${normalized}`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Aliases worth adding to the entity at `self`: new to it, and held by no
|
|
13
|
+
* other entity of any type, so an alias can never make a later match
|
|
14
|
+
* ambiguous. `own` holds the normalised names the entity already answers to.
|
|
15
|
+
*/
|
|
16
|
+
export function freshAliases(index, type, self, own, names) {
|
|
17
|
+
const seen = new Set(own);
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const raw of names) {
|
|
20
|
+
const alias = canonicalizeName(raw);
|
|
21
|
+
if (!alias || alias.length > MAX_NAME_LENGTH)
|
|
22
|
+
continue;
|
|
23
|
+
const normalized = normalizeEntityName(alias, type);
|
|
24
|
+
if (!normalized || seen.has(normalized) || isRejectedName(normalized))
|
|
25
|
+
continue;
|
|
26
|
+
const holders = [
|
|
27
|
+
...(index.canonical.get(entityKey(type, normalized)) ?? []),
|
|
28
|
+
...(index.alias.get(entityKey(type, normalized)) ?? []),
|
|
29
|
+
];
|
|
30
|
+
if (holders.some((holder) => holder !== self))
|
|
31
|
+
continue;
|
|
32
|
+
if ([...(index.types.get(normalized) ?? [])].some((other) => other !== type))
|
|
33
|
+
continue;
|
|
34
|
+
seen.add(normalized);
|
|
35
|
+
out.push(alias);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/** The normalised names an indexed entity answers to, plus aliases queued this run. */
|
|
40
|
+
export function ownNames(index, rel, queued) {
|
|
41
|
+
const entity = index.byRel.get(rel);
|
|
42
|
+
return new Set([entity.canonical_name, ...entity.aliases, ...queued].map((name) => normalizeEntityName(name, entity.type)));
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=aliases.js.map
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* sections 4.3, 5 and 14). The model output is untrusted input: a malformed
|
|
6
6
|
* candidate is rejected and counted, never repaired.
|
|
7
7
|
*/
|
|
8
|
+
import { type DuplicateReport, type MergeBackOutcome } from "./multi-writer.js";
|
|
8
9
|
import { type CompanyBriefResult } from "./company-brief.js";
|
|
9
10
|
import { type WritableEntityType } from "./identity.js";
|
|
10
11
|
import { type EntityIndex } from "./tree.js";
|
|
@@ -76,6 +77,15 @@ export interface ApplyReport {
|
|
|
76
77
|
}>;
|
|
77
78
|
/** Present only when the plan ran the company brief and this apply created or updated entities. */
|
|
78
79
|
company_brief?: CompanyBriefResult;
|
|
80
|
+
/** Present only when `checkpoint.ontology.multi-writer` was on for the plan (design section 20). */
|
|
81
|
+
multi_writer?: {
|
|
82
|
+
conflicts: MergeBackOutcome[];
|
|
83
|
+
/** Entity files this apply wrote and recorded in the outbox. */
|
|
84
|
+
outbox_recorded: number;
|
|
85
|
+
outbox_reset?: true;
|
|
86
|
+
error?: string;
|
|
87
|
+
duplicates?: DuplicateReport;
|
|
88
|
+
};
|
|
79
89
|
duration_ms: number;
|
|
80
90
|
}
|
|
81
91
|
export interface ApplyOptions {
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as path from "node:path";
|
|
11
|
+
import { entityKey, freshAliases, MAX_NAME_LENGTH, ownNames } from "./aliases.js";
|
|
12
|
+
import { recordOutbox, scanDuplicates } from "./multi-writer.js";
|
|
11
13
|
import { writeCompanyBrief } from "./company-brief.js";
|
|
12
14
|
import { entitiesRoot, resolveOntologyCompany, safeWriteFile } from "./company.js";
|
|
13
15
|
import { appendBackRefs, parseEntityFile, patchEntityFile, previewSignalContent, renderNewEntity, validateEntityFrontmatter, } from "./entity-file.js";
|
|
@@ -20,7 +22,7 @@ export const CAPS = {
|
|
|
20
22
|
bytes: 200 * 1024,
|
|
21
23
|
newAliasesPerEntity: 3,
|
|
22
24
|
aliasesPerCandidate: 5,
|
|
23
|
-
nameLength:
|
|
25
|
+
nameLength: MAX_NAME_LENGTH,
|
|
24
26
|
};
|
|
25
27
|
export const MIN_CREATE_CONFIDENCE = 0.7;
|
|
26
28
|
export class ApplyError extends Error {
|
|
@@ -60,21 +62,18 @@ export function parseCandidate(raw) {
|
|
|
60
62
|
evidence: typeof c.evidence === "string" ? c.evidence : "",
|
|
61
63
|
};
|
|
62
64
|
}
|
|
63
|
-
function key(type, normalized) {
|
|
64
|
-
return `${type}\0${normalized}`;
|
|
65
|
-
}
|
|
66
65
|
/** Design section 5.3, tiers 1 to 4. */
|
|
67
66
|
export function resolveCandidate(index, type, canonical) {
|
|
68
67
|
const normalized = normalizeEntityName(canonical, type);
|
|
69
|
-
const byCanonical = [...(index.canonical.get(
|
|
68
|
+
const byCanonical = [...(index.canonical.get(entityKey(type, normalized)) ?? [])].sort();
|
|
70
69
|
if (byCanonical.length > 0)
|
|
71
70
|
return { tier: 1, target: byCanonical[0], duplicates: byCanonical.slice(1) };
|
|
72
|
-
const byAlias = [...(index.alias.get(
|
|
71
|
+
const byAlias = [...(index.alias.get(entityKey(type, normalized)) ?? [])].sort();
|
|
73
72
|
if (byAlias.length === 1)
|
|
74
73
|
return { tier: 2, target: byAlias[0] };
|
|
75
74
|
if (byAlias.length > 1)
|
|
76
75
|
return { tier: "ambiguous_alias", paths: byAlias };
|
|
77
|
-
const squashed = [...(index.squashed.get(
|
|
76
|
+
const squashed = [...(index.squashed.get(entityKey(type, squash(normalized))) ?? [])].sort();
|
|
78
77
|
if (squashed.length > 0)
|
|
79
78
|
return { tier: "suspect_duplicate", paths: squashed };
|
|
80
79
|
const otherTypes = [...(index.types.get(normalized) ?? [])].filter((other) => other !== type).sort();
|
|
@@ -95,14 +94,14 @@ export function overlappingEntities(index, type, canonical, aliases) {
|
|
|
95
94
|
// A generic alias still counts when an existing entity already holds it.
|
|
96
95
|
if (!normalized)
|
|
97
96
|
continue;
|
|
98
|
-
for (const rel of index.canonical.get(
|
|
97
|
+
for (const rel of index.canonical.get(entityKey(type, normalized)) ?? [])
|
|
99
98
|
paths.add(rel);
|
|
100
|
-
for (const rel of index.alias.get(
|
|
99
|
+
for (const rel of index.alias.get(entityKey(type, normalized)) ?? [])
|
|
101
100
|
paths.add(rel);
|
|
102
101
|
}
|
|
103
102
|
if (type === "person") {
|
|
104
103
|
const words = new Set(normalizeEntityName(canonical, type).split(" "));
|
|
105
|
-
const prefix =
|
|
104
|
+
const prefix = entityKey(type, "");
|
|
106
105
|
for (const [name, rels] of index.canonical) {
|
|
107
106
|
if (!name.startsWith(prefix))
|
|
108
107
|
continue;
|
|
@@ -114,39 +113,6 @@ export function overlappingEntities(index, type, canonical, aliases) {
|
|
|
114
113
|
}
|
|
115
114
|
return [...paths].sort();
|
|
116
115
|
}
|
|
117
|
-
/**
|
|
118
|
-
* Aliases worth adding to the entity at `self`: new to it, and held by no
|
|
119
|
-
* other entity of any type, so an alias can never make a later match
|
|
120
|
-
* ambiguous. `own` holds the normalised names the entity already answers to.
|
|
121
|
-
*/
|
|
122
|
-
function freshAliases(index, type, self, own, names) {
|
|
123
|
-
const seen = new Set(own);
|
|
124
|
-
const out = [];
|
|
125
|
-
for (const raw of names) {
|
|
126
|
-
const alias = canonicalizeName(raw);
|
|
127
|
-
if (!alias || alias.length > CAPS.nameLength)
|
|
128
|
-
continue;
|
|
129
|
-
const normalized = normalizeEntityName(alias, type);
|
|
130
|
-
if (!normalized || seen.has(normalized) || isRejectedName(normalized))
|
|
131
|
-
continue;
|
|
132
|
-
const holders = [
|
|
133
|
-
...(index.canonical.get(key(type, normalized)) ?? []),
|
|
134
|
-
...(index.alias.get(key(type, normalized)) ?? []),
|
|
135
|
-
];
|
|
136
|
-
if (holders.some((holder) => holder !== self))
|
|
137
|
-
continue;
|
|
138
|
-
if ([...(index.types.get(normalized) ?? [])].some((other) => other !== type))
|
|
139
|
-
continue;
|
|
140
|
-
seen.add(normalized);
|
|
141
|
-
out.push(alias);
|
|
142
|
-
}
|
|
143
|
-
return out;
|
|
144
|
-
}
|
|
145
|
-
/** The normalised names an indexed entity answers to, plus aliases queued this run. */
|
|
146
|
-
function ownNames(index, rel, queued) {
|
|
147
|
-
const entity = index.byRel.get(rel);
|
|
148
|
-
return new Set([entity.canonical_name, ...entity.aliases, ...queued].map((name) => normalizeEntityName(name, entity.type)));
|
|
149
|
-
}
|
|
150
116
|
function readJson(file) {
|
|
151
117
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
152
118
|
}
|
|
@@ -211,7 +177,33 @@ export function applyOntologyRun(options) {
|
|
|
211
177
|
write_errors: [],
|
|
212
178
|
duration_ms: 0,
|
|
213
179
|
};
|
|
180
|
+
const outboxRels = new Set();
|
|
181
|
+
if (plan.multi_writer) {
|
|
182
|
+
report.multi_writer = {
|
|
183
|
+
conflicts: plan.multi_writer.merge_back ?? [],
|
|
184
|
+
outbox_recorded: 0,
|
|
185
|
+
...(plan.multi_writer.error ? { error: plan.multi_writer.error } : {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// Design 20.4: the outbox entry is written before the entity file, so sync
|
|
189
|
+
// never sees a checkpoint write without one. A failed outbox write stops
|
|
190
|
+
// the entity write and leaves its inputs pending for the next run.
|
|
191
|
+
const recordBeforeWrite = (rel, content) => {
|
|
192
|
+
if (!report.multi_writer)
|
|
193
|
+
return null;
|
|
194
|
+
try {
|
|
195
|
+
if (recordOutbox(options.hqRoot, company, [{ rel, content }], options.now ?? new Date()).reset) {
|
|
196
|
+
report.multi_writer.outbox_reset = true;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
return `outbox:${error.name}`;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
214
204
|
const finish = () => {
|
|
205
|
+
if (report.multi_writer)
|
|
206
|
+
report.multi_writer.outbox_recorded = outboxRels.size;
|
|
215
207
|
report.duration_ms = Date.now() - started;
|
|
216
208
|
fs.writeFileSync(path.join(options.runDir, REPORT_FILE), `${JSON.stringify(report, null, 2)}\n`);
|
|
217
209
|
return report;
|
|
@@ -377,6 +369,12 @@ export function applyOntologyRun(options) {
|
|
|
377
369
|
blocked.add(candidate.input);
|
|
378
370
|
continue;
|
|
379
371
|
}
|
|
372
|
+
const createOutboxError = recordBeforeWrite(rel, content);
|
|
373
|
+
if (createOutboxError) {
|
|
374
|
+
report.write_errors.push({ path: rel, reason: createOutboxError });
|
|
375
|
+
blocked.add(candidate.input);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
380
378
|
try {
|
|
381
379
|
safeWriteFile(options.hqRoot, [entityRoot], target, content);
|
|
382
380
|
}
|
|
@@ -386,9 +384,11 @@ export function applyOntologyRun(options) {
|
|
|
386
384
|
continue;
|
|
387
385
|
}
|
|
388
386
|
report.bytes_written += bytes;
|
|
387
|
+
if (report.multi_writer)
|
|
388
|
+
outboxRels.add(rel);
|
|
389
389
|
report.created.push(rel);
|
|
390
390
|
report.links.push({ input: candidate.input, action: "create", type: candidate.type, canonical_name: canonical, path: rel });
|
|
391
|
-
indexEntity(index, { ...fields, rel, slug, last_updated: now });
|
|
391
|
+
indexEntity(index, { ...fields, rel, slug, last_updated: now, first_seen: now });
|
|
392
392
|
}
|
|
393
393
|
applySignalRefs(company, plan, inputs, index, update);
|
|
394
394
|
for (const rel of [...updates.keys()].sort()) {
|
|
@@ -423,6 +423,13 @@ export function applyOntologyRun(options) {
|
|
|
423
423
|
report.deferred.push({ input: [...pending.inputs][0] ?? "", type: entity.type, name: entity.canonical_name, reason });
|
|
424
424
|
continue;
|
|
425
425
|
}
|
|
426
|
+
const patchOutboxError = recordBeforeWrite(rel, patched.content);
|
|
427
|
+
if (patchOutboxError) {
|
|
428
|
+
report.write_errors.push({ path: rel, reason: patchOutboxError });
|
|
429
|
+
for (const id of pending.inputs)
|
|
430
|
+
blocked.add(id);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
426
433
|
try {
|
|
427
434
|
safeWriteFile(options.hqRoot, [entityRoot], target, patched.content);
|
|
428
435
|
}
|
|
@@ -434,6 +441,8 @@ export function applyOntologyRun(options) {
|
|
|
434
441
|
}
|
|
435
442
|
report.bytes_written += bytes;
|
|
436
443
|
report.backrefs_added += withRefs.added;
|
|
444
|
+
if (report.multi_writer)
|
|
445
|
+
outboxRels.add(rel);
|
|
437
446
|
// An entity created earlier in this run is reported once, as created.
|
|
438
447
|
if (!report.created.includes(rel))
|
|
439
448
|
report.updated.push(rel);
|
|
@@ -443,6 +452,14 @@ export function applyOntologyRun(options) {
|
|
|
443
452
|
if (plan.company_brief && report.created.length + report.updated.length > 0) {
|
|
444
453
|
report.company_brief = writeCompanyBrief(options.hqRoot, company, options.now ?? new Date());
|
|
445
454
|
}
|
|
455
|
+
if (report.multi_writer) {
|
|
456
|
+
try {
|
|
457
|
+
report.multi_writer.duplicates = scanDuplicates(company);
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
report.multi_writer.error = `duplicates:${error.name}`;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
446
463
|
return finish();
|
|
447
464
|
}
|
|
448
465
|
let signalRefIndex = null;
|
|
@@ -43,6 +43,8 @@ export interface NewEntity {
|
|
|
43
43
|
}
|
|
44
44
|
/** A new entity file in the cloud schema, with a `# Name` heading. */
|
|
45
45
|
export declare function renderNewEntity(entity: NewEntity): string;
|
|
46
|
+
/** The frontmatter keys a checkpoint may change in an existing file. */
|
|
47
|
+
export declare const OWNED_KEYS: ReadonlySet<string>;
|
|
46
48
|
export interface EntityPatch {
|
|
47
49
|
addAliases?: string[];
|
|
48
50
|
signalCount?: number;
|
|
@@ -111,7 +111,8 @@ export function renderNewEntity(entity) {
|
|
|
111
111
|
"",
|
|
112
112
|
].join("\n");
|
|
113
113
|
}
|
|
114
|
-
|
|
114
|
+
/** The frontmatter keys a checkpoint may change in an existing file. */
|
|
115
|
+
export const OWNED_KEYS = new Set(["aliases", "signal_count", "last_updated"]);
|
|
115
116
|
/**
|
|
116
117
|
* Replace one top-level key in a raw frontmatter block. The key's value may
|
|
117
118
|
* span several lines (a block sequence); continuation lines are the ones that
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Several checkpoint writers in one company vault (design section 20).
|
|
3
|
+
*
|
|
4
|
+
* Gated by `checkpoint.ontology.multi-writer`. Three parts:
|
|
5
|
+
*
|
|
6
|
+
* - The outbox records the exact bytes this machine's checkpoint wrote to
|
|
7
|
+
* each entity file. Sync pushes an entity file only when it still matches
|
|
8
|
+
* its outbox entry, so an old pulled copy is never uploaded (C1, C2).
|
|
9
|
+
* - Merge-back re-applies the monotone part of an entity version that sync
|
|
10
|
+
* set aside after losing a push race (C3, C4). A version whose entity was
|
|
11
|
+
* deleted stays aside (C6); one that differs in keys the checkpoint does
|
|
12
|
+
* not own is merged for the owned keys and also stays aside (C8).
|
|
13
|
+
* - The duplicate scan reports entities of one type whose names collide, with
|
|
14
|
+
* a proposed survivor. Nothing is deleted (C5, C7).
|
|
15
|
+
*/
|
|
16
|
+
import { type OntologyCompany } from "./company.js";
|
|
17
|
+
import { type SignalBackRef } from "./entity-file.js";
|
|
18
|
+
import { type EntityIndex } from "./tree.js";
|
|
19
|
+
export declare const OUTBOX_SCHEMA = "hq-ontology-outbox.v1";
|
|
20
|
+
export declare const OUTBOX_DIR = ".hq/ontology-outbox";
|
|
21
|
+
export declare const OUTBOX_RETENTION_MS: number;
|
|
22
|
+
/** Where sync parks a losing local body (hq-cloud `CONFLICT_BACKUPS_DIR`). */
|
|
23
|
+
export declare const CONFLICT_BACKUPS_DIR = ".hq/conflict-backups";
|
|
24
|
+
export declare const MERGED_BACKUPS_DIR = ".hq/conflict-backups/ontology-merged";
|
|
25
|
+
export declare const STALE_BACKUP_MS: number;
|
|
26
|
+
export declare const DUPLICATE_REPORT_LIMIT = 20;
|
|
27
|
+
export interface OutboxEntry {
|
|
28
|
+
sha256: string;
|
|
29
|
+
written_at: string;
|
|
30
|
+
}
|
|
31
|
+
export interface Outbox {
|
|
32
|
+
schema: typeof OUTBOX_SCHEMA;
|
|
33
|
+
company: string;
|
|
34
|
+
entries: Record<string, OutboxEntry>;
|
|
35
|
+
}
|
|
36
|
+
export interface OutboxWrite {
|
|
37
|
+
/** Company-relative vault key, `ontology/entities/<type>/<file>.md`. */
|
|
38
|
+
rel: string;
|
|
39
|
+
content: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function outboxPath(hqRoot: string, company: OntologyCompany): string;
|
|
42
|
+
/** The company's outbox. A missing file is an empty outbox; an unreadable one throws. */
|
|
43
|
+
export declare function readOutbox(hqRoot: string, company: OntologyCompany): Outbox;
|
|
44
|
+
/**
|
|
45
|
+
* Record entity bytes this checkpoint wrote, and drop entries older than the
|
|
46
|
+
* retention window. An unreadable outbox is replaced: a lost entry only makes
|
|
47
|
+
* sync hold that file back, which is the safe direction.
|
|
48
|
+
*/
|
|
49
|
+
export declare function recordOutbox(hqRoot: string, company: OntologyCompany, writes: OutboxWrite[], now: Date): {
|
|
50
|
+
reset: boolean;
|
|
51
|
+
};
|
|
52
|
+
export type MergeBackResult = "merged" | "kept_both" | "stale" | "deleted" | "identity_conflict" | "invalid" | "write_error";
|
|
53
|
+
export interface MergeBackOutcome {
|
|
54
|
+
/** HQ-relative path of the set-aside version. */
|
|
55
|
+
backup: string;
|
|
56
|
+
/** Company-relative entity file it belongs to. */
|
|
57
|
+
target: string;
|
|
58
|
+
result: MergeBackResult;
|
|
59
|
+
/** For `kept_both`, the keys that differ (`body` for prose); for `invalid` and `write_error`, the reason. */
|
|
60
|
+
detail?: string;
|
|
61
|
+
}
|
|
62
|
+
/** Back-reference lines in a body, as refs `appendBackRefs` can re-add. */
|
|
63
|
+
export declare function readBackRefs(body: string): SignalBackRef[];
|
|
64
|
+
export interface MergeBackRun {
|
|
65
|
+
outcomes: MergeBackOutcome[];
|
|
66
|
+
/** Entity files rewritten; each was recorded in the outbox before it was written. */
|
|
67
|
+
writes: OutboxWrite[];
|
|
68
|
+
outbox_reset?: true;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Re-apply set-aside entity versions of this company to the current files
|
|
72
|
+
* (design 20.4). Never deletes an entity file and never re-creates one.
|
|
73
|
+
*/
|
|
74
|
+
export declare function mergeBackConflicts(hqRoot: string, company: OntologyCompany, now: Date): MergeBackRun;
|
|
75
|
+
export interface DuplicateGroup {
|
|
76
|
+
type: string;
|
|
77
|
+
/** `same_name`: equal canonical names, or one's canonical name is the other's unshared alias. `similar_name`: canonical names match once spaces are removed. */
|
|
78
|
+
reason: "same_name" | "similar_name";
|
|
79
|
+
paths: string[];
|
|
80
|
+
/** Highest `signal_count`, then earliest `first_seen`, then path. */
|
|
81
|
+
survivor: string;
|
|
82
|
+
}
|
|
83
|
+
export interface DuplicateReport {
|
|
84
|
+
groups: number;
|
|
85
|
+
shown: DuplicateGroup[];
|
|
86
|
+
}
|
|
87
|
+
/** Groups of writable entities of one type whose names collide. Read-only. */
|
|
88
|
+
export declare function findDuplicateGroups(index: EntityIndex): DuplicateGroup[];
|
|
89
|
+
/** The duplicate scan for a company, capped for the apply report. */
|
|
90
|
+
export declare function scanDuplicates(company: OntologyCompany, limit?: number): DuplicateReport;
|
|
91
|
+
export interface MultiWriterStart {
|
|
92
|
+
merge_back: MergeBackOutcome[];
|
|
93
|
+
/** Why merge-back or the outbox write stopped, when it did. */
|
|
94
|
+
error?: string;
|
|
95
|
+
outbox_reset?: true;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The start of a checkpoint run: merge back set-aside versions (each merged
|
|
99
|
+
* file is recorded in the outbox before it is written). Runs in `plan`, so a
|
|
100
|
+
* run with no changed inputs still merges. Errors are returned, not thrown,
|
|
101
|
+
* so the rest of the checkpoint goes on.
|
|
102
|
+
*/
|
|
103
|
+
export declare function startMultiWriterRun(hqRoot: string, company: OntologyCompany, now: Date): MultiWriterStart;
|
|
104
|
+
/** Count merge-back outcomes by result, for one-line summaries. */
|
|
105
|
+
export declare function summarizeMergeBack(outcomes: MergeBackOutcome[]): string;
|
|
106
|
+
//# sourceMappingURL=multi-writer.d.ts.map
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Several checkpoint writers in one company vault (design section 20).
|
|
3
|
+
*
|
|
4
|
+
* Gated by `checkpoint.ontology.multi-writer`. Three parts:
|
|
5
|
+
*
|
|
6
|
+
* - The outbox records the exact bytes this machine's checkpoint wrote to
|
|
7
|
+
* each entity file. Sync pushes an entity file only when it still matches
|
|
8
|
+
* its outbox entry, so an old pulled copy is never uploaded (C1, C2).
|
|
9
|
+
* - Merge-back re-applies the monotone part of an entity version that sync
|
|
10
|
+
* set aside after losing a push race (C3, C4). A version whose entity was
|
|
11
|
+
* deleted stays aside (C6); one that differs in keys the checkpoint does
|
|
12
|
+
* not own is merged for the owned keys and also stays aside (C8).
|
|
13
|
+
* - The duplicate scan reports entities of one type whose names collide, with
|
|
14
|
+
* a proposed survivor. Nothing is deleted (C5, C7).
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { isDeepStrictEqual } from "node:util";
|
|
20
|
+
import { entityKey, freshAliases, ownNames } from "./aliases.js";
|
|
21
|
+
import { entitiesRoot, safeWriteFile } from "./company.js";
|
|
22
|
+
import { appendBackRefs, OWNED_KEYS, parseEntityFile, patchEntityFile, validateEntityFrontmatter, } from "./entity-file.js";
|
|
23
|
+
import { isWritableEntityType, squash } from "./identity.js";
|
|
24
|
+
import { buildEntityIndex, indexAlias } from "./tree.js";
|
|
25
|
+
export const OUTBOX_SCHEMA = "hq-ontology-outbox.v1";
|
|
26
|
+
export const OUTBOX_DIR = ".hq/ontology-outbox";
|
|
27
|
+
export const OUTBOX_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
28
|
+
/** Where sync parks a losing local body (hq-cloud `CONFLICT_BACKUPS_DIR`). */
|
|
29
|
+
export const CONFLICT_BACKUPS_DIR = ".hq/conflict-backups";
|
|
30
|
+
export const MERGED_BACKUPS_DIR = ".hq/conflict-backups/ontology-merged";
|
|
31
|
+
export const STALE_BACKUP_MS = 7 * 24 * 60 * 60 * 1000;
|
|
32
|
+
export const DUPLICATE_REPORT_LIMIT = 20;
|
|
33
|
+
/**
|
|
34
|
+
* Keys that differ between two versions of a new entity for reasons that say
|
|
35
|
+
* nothing about its content. The vault's value stands and the difference is
|
|
36
|
+
* not reported.
|
|
37
|
+
*/
|
|
38
|
+
const IGNORED_ON_MERGE = new Set(["first_seen"]);
|
|
39
|
+
/** hq-cloud `buildConflictBackupPath`: `<file>.md.<YYYY-MM-DDTHH-MM-SSZ>-<machine>.md`. */
|
|
40
|
+
const BACKUP_NAME_RE = /^(.+\.md)\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)-([a-f0-9]+|unknown)\.md$/;
|
|
41
|
+
const TYPE_DIR_RE = /^[a-z][a-z0-9-]*$/;
|
|
42
|
+
/** One back-reference line, as `appendBackRefs` and the gardener write it. */
|
|
43
|
+
const BACKREF_LINE_RE = /^- Referenced in \[([A-Za-z_]+): (.*)\]\(([^)\s]+)\)\s*$/;
|
|
44
|
+
function sha256(content) {
|
|
45
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
46
|
+
}
|
|
47
|
+
export function outboxPath(hqRoot, company) {
|
|
48
|
+
return path.join(hqRoot, ...OUTBOX_DIR.split("/"), `${company.slug}.json`);
|
|
49
|
+
}
|
|
50
|
+
/** The company's outbox. A missing file is an empty outbox; an unreadable one throws. */
|
|
51
|
+
export function readOutbox(hqRoot, company) {
|
|
52
|
+
const empty = { schema: OUTBOX_SCHEMA, company: company.slug, entries: {} };
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = fs.readFileSync(outboxPath(hqRoot, company), "utf8");
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error.code === "ENOENT")
|
|
59
|
+
return empty;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
const doc = JSON.parse(raw);
|
|
63
|
+
if (doc.schema !== OUTBOX_SCHEMA || doc.company !== company.slug || !doc.entries || typeof doc.entries !== "object") {
|
|
64
|
+
throw new Error("outbox has an unexpected shape");
|
|
65
|
+
}
|
|
66
|
+
return { schema: OUTBOX_SCHEMA, company: company.slug, entries: doc.entries };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Record entity bytes this checkpoint wrote, and drop entries older than the
|
|
70
|
+
* retention window. An unreadable outbox is replaced: a lost entry only makes
|
|
71
|
+
* sync hold that file back, which is the safe direction.
|
|
72
|
+
*/
|
|
73
|
+
export function recordOutbox(hqRoot, company, writes, now) {
|
|
74
|
+
let outbox;
|
|
75
|
+
let reset = false;
|
|
76
|
+
try {
|
|
77
|
+
outbox = readOutbox(hqRoot, company);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
outbox = { schema: OUTBOX_SCHEMA, company: company.slug, entries: {} };
|
|
81
|
+
reset = true;
|
|
82
|
+
}
|
|
83
|
+
const cutoff = now.getTime() - OUTBOX_RETENTION_MS;
|
|
84
|
+
const entries = {};
|
|
85
|
+
for (const [rel, entry] of Object.entries(outbox.entries)) {
|
|
86
|
+
const at = Date.parse(entry?.written_at ?? "");
|
|
87
|
+
if (typeof entry?.sha256 === "string" && Number.isFinite(at) && at >= cutoff)
|
|
88
|
+
entries[rel] = entry;
|
|
89
|
+
}
|
|
90
|
+
for (const write of writes)
|
|
91
|
+
entries[write.rel] = { sha256: sha256(write.content), written_at: now.toISOString() };
|
|
92
|
+
const sorted = Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)));
|
|
93
|
+
const doc = { schema: OUTBOX_SCHEMA, company: company.slug, entries: sorted };
|
|
94
|
+
safeWriteFile(hqRoot, [path.join(hqRoot, ...OUTBOX_DIR.split("/"))], outboxPath(hqRoot, company), `${JSON.stringify(doc, null, 2)}\n`);
|
|
95
|
+
return { reset };
|
|
96
|
+
}
|
|
97
|
+
/** A directory that exists and is not a symlink. */
|
|
98
|
+
function isRealDir(dir) {
|
|
99
|
+
try {
|
|
100
|
+
return fs.lstatSync(dir).isDirectory();
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error.code === "ENOENT")
|
|
104
|
+
return false;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Set-aside entity versions of this company, oldest first per entity. */
|
|
109
|
+
function listEntityBackups(hqRoot, company) {
|
|
110
|
+
const segments = [...CONFLICT_BACKUPS_DIR.split("/"), "companies", company.slug, "ontology", "entities"];
|
|
111
|
+
for (let i = 1; i <= segments.length; i += 1) {
|
|
112
|
+
if (!isRealDir(path.join(hqRoot, ...segments.slice(0, i))))
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
const root = path.join(hqRoot, ...segments);
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const typeDir of fs.readdirSync(root, { withFileTypes: true })) {
|
|
118
|
+
// Other entity types (contacts, deals) belong to other writers.
|
|
119
|
+
if (!typeDir.isDirectory() || !TYPE_DIR_RE.test(typeDir.name) || !isWritableEntityType(typeDir.name))
|
|
120
|
+
continue;
|
|
121
|
+
for (const file of fs.readdirSync(path.join(root, typeDir.name), { withFileTypes: true })) {
|
|
122
|
+
const match = file.isFile() ? BACKUP_NAME_RE.exec(file.name) : null;
|
|
123
|
+
if (!match)
|
|
124
|
+
continue;
|
|
125
|
+
out.push({
|
|
126
|
+
full: path.join(root, typeDir.name, file.name),
|
|
127
|
+
hqRel: [...segments, typeDir.name, file.name].join("/"),
|
|
128
|
+
target: `ontology/entities/${typeDir.name}/${match[1]}`,
|
|
129
|
+
stampedAt: match[2],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return out.sort((a, b) => a.target.localeCompare(b.target) || a.stampedAt.localeCompare(b.stampedAt) || a.hqRel.localeCompare(b.hqRel));
|
|
134
|
+
}
|
|
135
|
+
/** The later of two ISO timestamps; an unparseable one loses. */
|
|
136
|
+
function later(a, b) {
|
|
137
|
+
const ta = Date.parse(a);
|
|
138
|
+
const tb = Date.parse(b);
|
|
139
|
+
if (!Number.isFinite(ta))
|
|
140
|
+
return b;
|
|
141
|
+
if (!Number.isFinite(tb))
|
|
142
|
+
return a;
|
|
143
|
+
return tb > ta ? b : a;
|
|
144
|
+
}
|
|
145
|
+
/** Back-reference lines in a body, as refs `appendBackRefs` can re-add. */
|
|
146
|
+
export function readBackRefs(body) {
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const line of body.split(/\r?\n/)) {
|
|
149
|
+
const match = BACKREF_LINE_RE.exec(line);
|
|
150
|
+
if (match)
|
|
151
|
+
out.push({ signalType: match[1], preview: match[2], href: match[3] });
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/** The body with back-reference lines and blank lines removed, for comparing prose. */
|
|
156
|
+
function proseOf(body) {
|
|
157
|
+
return body
|
|
158
|
+
.split(/\r?\n/)
|
|
159
|
+
.filter((line) => !BACKREF_LINE_RE.test(line) && !/^##\s+Relationships\s*$/.test(line))
|
|
160
|
+
.map((line) => line.trimEnd())
|
|
161
|
+
.filter((line) => line !== "")
|
|
162
|
+
.join("\n");
|
|
163
|
+
}
|
|
164
|
+
/** Keys the checkpoint does not own whose values differ, plus `body` for prose. */
|
|
165
|
+
function unownedDifferences(backup, current) {
|
|
166
|
+
const keys = [...new Set([...Object.keys(backup.frontmatter), ...Object.keys(current.frontmatter)])].sort();
|
|
167
|
+
const out = keys.filter((key) => !OWNED_KEYS.has(key) && !IGNORED_ON_MERGE.has(key) && !isDeepStrictEqual(backup.frontmatter[key], current.frontmatter[key]));
|
|
168
|
+
if (proseOf(backup.body) !== proseOf(current.body))
|
|
169
|
+
out.push("body");
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Re-apply set-aside entity versions of this company to the current files
|
|
174
|
+
* (design 20.4). Never deletes an entity file and never re-creates one.
|
|
175
|
+
*/
|
|
176
|
+
export function mergeBackConflicts(hqRoot, company, now) {
|
|
177
|
+
const run = { outcomes: [], writes: [] };
|
|
178
|
+
const backups = listEntityBackups(hqRoot, company);
|
|
179
|
+
if (backups.length === 0)
|
|
180
|
+
return run;
|
|
181
|
+
const index = buildEntityIndex(company.dir);
|
|
182
|
+
const entityRoot = entitiesRoot(company);
|
|
183
|
+
for (const backup of backups) {
|
|
184
|
+
const outcome = (result, detail) => run.outcomes.push({ backup: backup.hqRel, target: backup.target, result, ...(detail ? { detail } : {}) });
|
|
185
|
+
const saved = parseEntityFile(fs.readFileSync(backup.full, "utf8"));
|
|
186
|
+
const savedFields = saved ? validateEntityFrontmatter(saved.frontmatter) : "unparseable";
|
|
187
|
+
if (!saved || typeof savedFields === "string") {
|
|
188
|
+
outcome("invalid", `backup:${savedFields}`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const savedUpdated = String(saved.frontmatter.last_updated);
|
|
192
|
+
const savedAt = Date.parse(savedUpdated);
|
|
193
|
+
if (!Number.isFinite(savedAt)) {
|
|
194
|
+
outcome("invalid", "backup:last_updated");
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (now.getTime() - savedAt > STALE_BACKUP_MS) {
|
|
198
|
+
outcome("stale");
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const targetFull = path.join(company.dir, backup.target);
|
|
202
|
+
let targetStat;
|
|
203
|
+
try {
|
|
204
|
+
targetStat = fs.lstatSync(targetFull);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (error.code !== "ENOENT")
|
|
208
|
+
throw error;
|
|
209
|
+
outcome("deleted");
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (!targetStat.isFile()) {
|
|
213
|
+
outcome("invalid", "current:not_a_file");
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const currentContent = fs.readFileSync(targetFull, "utf8");
|
|
217
|
+
const current = parseEntityFile(currentContent);
|
|
218
|
+
const currentFields = current ? validateEntityFrontmatter(current.frontmatter) : "unparseable";
|
|
219
|
+
if (!current || typeof currentFields === "string") {
|
|
220
|
+
outcome("invalid", `current:${currentFields}`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (savedFields.id !== currentFields.id || savedFields.type !== currentFields.type) {
|
|
224
|
+
outcome("identity_conflict");
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (!isWritableEntityType(currentFields.type)) {
|
|
228
|
+
outcome("invalid", "current:type_not_checkpoint_owned");
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const differences = unownedDifferences(saved, current);
|
|
232
|
+
const type = currentFields.type;
|
|
233
|
+
const aliases = index.byRel.has(backup.target)
|
|
234
|
+
? freshAliases(index, type, backup.target, ownNames(index, backup.target, []), savedFields.aliases)
|
|
235
|
+
: [];
|
|
236
|
+
const withRefs = appendBackRefs(currentContent, readBackRefs(saved.body));
|
|
237
|
+
const currentUpdated = String(current.frontmatter.last_updated);
|
|
238
|
+
const lastUpdated = later(currentUpdated, savedUpdated);
|
|
239
|
+
const patched = patchEntityFile(withRefs.content, {
|
|
240
|
+
addAliases: aliases,
|
|
241
|
+
signalCount: savedFields.signal_count,
|
|
242
|
+
lastUpdated,
|
|
243
|
+
// A later timestamp alone is a change too; otherwise the merge result
|
|
244
|
+
// would depend on which version this machine holds.
|
|
245
|
+
bodyChanged: withRefs.added > 0 || lastUpdated !== currentUpdated,
|
|
246
|
+
});
|
|
247
|
+
if (!patched.ok) {
|
|
248
|
+
outcome("write_error", patched.reason);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (patched.changed) {
|
|
252
|
+
// Outbox first: a file sync may push must never lack its entry, and a
|
|
253
|
+
// failed outbox write leaves the file and the backup for the next run.
|
|
254
|
+
try {
|
|
255
|
+
if (recordOutbox(hqRoot, company, [{ rel: backup.target, content: patched.content }], now).reset)
|
|
256
|
+
run.outbox_reset = true;
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
outcome("write_error", `outbox:${error.name}`);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
safeWriteFile(hqRoot, [entityRoot], targetFull, patched.content);
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
outcome("write_error", error.name);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
run.writes = run.writes.filter((write) => write.rel !== backup.target);
|
|
270
|
+
run.writes.push({ rel: backup.target, content: patched.content });
|
|
271
|
+
for (const alias of aliases)
|
|
272
|
+
indexAlias(index, type, backup.target, alias);
|
|
273
|
+
const entity = index.byRel.get(backup.target);
|
|
274
|
+
if (entity)
|
|
275
|
+
entity.aliases = [...entity.aliases, ...aliases];
|
|
276
|
+
}
|
|
277
|
+
if (differences.length > 0) {
|
|
278
|
+
outcome("kept_both", differences.join(","));
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const mergedFull = path.join(hqRoot, ...MERGED_BACKUPS_DIR.split("/"), ...backup.hqRel.split("/").slice(CONFLICT_BACKUPS_DIR.split("/").length));
|
|
282
|
+
fs.mkdirSync(path.dirname(mergedFull), { recursive: true });
|
|
283
|
+
fs.renameSync(backup.full, mergedFull);
|
|
284
|
+
outcome("merged");
|
|
285
|
+
}
|
|
286
|
+
return run;
|
|
287
|
+
}
|
|
288
|
+
/** Groups of writable entities of one type whose names collide. Read-only. */
|
|
289
|
+
export function findDuplicateGroups(index) {
|
|
290
|
+
const parent = new Map();
|
|
291
|
+
const find = (rel) => {
|
|
292
|
+
let root = rel;
|
|
293
|
+
while (parent.get(root) !== root)
|
|
294
|
+
root = parent.get(root);
|
|
295
|
+
parent.set(rel, root);
|
|
296
|
+
return root;
|
|
297
|
+
};
|
|
298
|
+
const union = (rels) => {
|
|
299
|
+
const list = [...rels].filter((rel) => isWritableEntityType(index.byRel.get(rel)?.type));
|
|
300
|
+
for (const rel of list)
|
|
301
|
+
if (!parent.has(rel))
|
|
302
|
+
parent.set(rel, rel);
|
|
303
|
+
for (const rel of list.slice(1)) {
|
|
304
|
+
const a = find(list[0]);
|
|
305
|
+
const b = find(rel);
|
|
306
|
+
if (a !== b)
|
|
307
|
+
parent.set(b, a);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
// Only the matches `resolveCandidate` treats as one entity link files: equal
|
|
311
|
+
// canonical names, or a canonical name that is exactly one other file's
|
|
312
|
+
// alias. An alias several files share (a Slack id, "hq") is ambiguous, not
|
|
313
|
+
// a duplicate, and following it chains unrelated entities together.
|
|
314
|
+
const exactPairs = new Set();
|
|
315
|
+
const sameName = (rels) => {
|
|
316
|
+
if (rels.length < 2)
|
|
317
|
+
return;
|
|
318
|
+
union(rels);
|
|
319
|
+
for (const rel of rels)
|
|
320
|
+
exactPairs.add(rel);
|
|
321
|
+
};
|
|
322
|
+
const squashed = new Map();
|
|
323
|
+
for (const [name, rels] of index.canonical) {
|
|
324
|
+
const holders = index.alias.get(name);
|
|
325
|
+
const aliasHolder = holders && holders.size === 1 ? [...holders] : [];
|
|
326
|
+
sameName([...new Set([...rels, ...aliasHolder])]);
|
|
327
|
+
const [type, normalized] = name.split("\0");
|
|
328
|
+
const squashKey = entityKey(type, squash(normalized));
|
|
329
|
+
squashed.set(squashKey, [...(squashed.get(squashKey) ?? []), ...rels]);
|
|
330
|
+
}
|
|
331
|
+
// Canonical names that match once spaces and punctuation are removed.
|
|
332
|
+
for (const rels of squashed.values())
|
|
333
|
+
if (rels.length > 1)
|
|
334
|
+
union(rels);
|
|
335
|
+
const groups = new Map();
|
|
336
|
+
for (const rel of parent.keys()) {
|
|
337
|
+
const root = find(rel);
|
|
338
|
+
groups.set(root, [...(groups.get(root) ?? []), rel]);
|
|
339
|
+
}
|
|
340
|
+
const out = [];
|
|
341
|
+
for (const members of groups.values()) {
|
|
342
|
+
if (members.length < 2)
|
|
343
|
+
continue;
|
|
344
|
+
const paths = members.sort();
|
|
345
|
+
const ranked = [...paths].sort((a, b) => {
|
|
346
|
+
const ea = index.byRel.get(a);
|
|
347
|
+
const eb = index.byRel.get(b);
|
|
348
|
+
const fa = Date.parse(ea.first_seen ?? "");
|
|
349
|
+
const fb = Date.parse(eb.first_seen ?? "");
|
|
350
|
+
return (eb.signal_count - ea.signal_count ||
|
|
351
|
+
(Number.isFinite(fa) ? fa : Infinity) - (Number.isFinite(fb) ? fb : Infinity) ||
|
|
352
|
+
a.localeCompare(b));
|
|
353
|
+
});
|
|
354
|
+
out.push({
|
|
355
|
+
type: index.byRel.get(paths[0]).type,
|
|
356
|
+
reason: paths.filter((rel) => exactPairs.has(rel)).length >= 2 ? "same_name" : "similar_name",
|
|
357
|
+
paths,
|
|
358
|
+
survivor: ranked[0],
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
return out.sort((a, b) => b.paths.length - a.paths.length || a.paths[0].localeCompare(b.paths[0]));
|
|
362
|
+
}
|
|
363
|
+
/** The duplicate scan for a company, capped for the apply report. */
|
|
364
|
+
export function scanDuplicates(company, limit = DUPLICATE_REPORT_LIMIT) {
|
|
365
|
+
const groups = findDuplicateGroups(buildEntityIndex(company.dir));
|
|
366
|
+
return { groups: groups.length, shown: groups.slice(0, limit) };
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* The start of a checkpoint run: merge back set-aside versions (each merged
|
|
370
|
+
* file is recorded in the outbox before it is written). Runs in `plan`, so a
|
|
371
|
+
* run with no changed inputs still merges. Errors are returned, not thrown,
|
|
372
|
+
* so the rest of the checkpoint goes on.
|
|
373
|
+
*/
|
|
374
|
+
export function startMultiWriterRun(hqRoot, company, now) {
|
|
375
|
+
let merged;
|
|
376
|
+
try {
|
|
377
|
+
merged = mergeBackConflicts(hqRoot, company, now);
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
return { merge_back: [], error: `merge_back:${error.name}` };
|
|
381
|
+
}
|
|
382
|
+
return { merge_back: merged.outcomes, ...(merged.outbox_reset ? { outbox_reset: true } : {}) };
|
|
383
|
+
}
|
|
384
|
+
/** Count merge-back outcomes by result, for one-line summaries. */
|
|
385
|
+
export function summarizeMergeBack(outcomes) {
|
|
386
|
+
const counts = new Map();
|
|
387
|
+
for (const outcome of outcomes)
|
|
388
|
+
counts.set(outcome.result, (counts.get(outcome.result) ?? 0) + 1);
|
|
389
|
+
return [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([result, n]) => `${n} ${result.replaceAll("_", " ")}`).join(", ");
|
|
390
|
+
}
|
|
391
|
+
//# sourceMappingURL=multi-writer.js.map
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { type CompanyBriefResult } from "./company-brief.js";
|
|
11
11
|
import { type OntologyCompany } from "./company.js";
|
|
12
|
+
import { type MultiWriterStart } from "./multi-writer.js";
|
|
12
13
|
export declare const ONTOLOGY_FLAG = "checkpoint.ontology";
|
|
13
14
|
export declare const SHARED_INPUTS_FLAG = "checkpoint.ontology.shared-inputs";
|
|
14
15
|
export declare const COMPANY_BRIEF_FLAG = "checkpoint.ontology.company-brief";
|
|
@@ -25,6 +26,12 @@ export declare const IGNORE_ANNOTATIONS_FLAG = "checkpoint.ontology.ignore-annot
|
|
|
25
26
|
* state directory, so the foreground checkpoint makes no network call.
|
|
26
27
|
*/
|
|
27
28
|
export declare const GROK_BACKEND_FLAG = "checkpoint.ontology.grok-backend";
|
|
29
|
+
/**
|
|
30
|
+
* Several writers in one company vault (design section 20): record written
|
|
31
|
+
* entity bytes in the outbox, merge back set-aside versions, and report
|
|
32
|
+
* duplicate entities.
|
|
33
|
+
*/
|
|
34
|
+
export declare const MULTI_WRITER_FLAG = "checkpoint.ontology.multi-writer";
|
|
28
35
|
export declare const PLAN_FILE = "ontology-plan.json";
|
|
29
36
|
export declare const CANDIDATES_FILE = "ontology-candidates.json";
|
|
30
37
|
export declare const REPORT_FILE = "ontology-report.json";
|
|
@@ -88,6 +95,8 @@ export interface OntologyPlan {
|
|
|
88
95
|
status: CompanyBriefResult["status"];
|
|
89
96
|
reason?: string;
|
|
90
97
|
};
|
|
98
|
+
/** Present only when `checkpoint.ontology.multi-writer` was on for this plan. */
|
|
99
|
+
multi_writer?: MultiWriterStart;
|
|
91
100
|
inputs: PlanInput[];
|
|
92
101
|
/** The session's summary, decisions and learnings, for the `session` input. */
|
|
93
102
|
session_text: string | null;
|
|
@@ -123,6 +132,7 @@ export type PlanOutcome = {
|
|
|
123
132
|
status: "skipped";
|
|
124
133
|
reason: string;
|
|
125
134
|
companyBrief?: CompanyBriefResult;
|
|
135
|
+
multiWriter?: MultiWriterStart;
|
|
126
136
|
} | {
|
|
127
137
|
status: "planned";
|
|
128
138
|
plan: OntologyPlan;
|
|
@@ -12,6 +12,7 @@ import * as fs from "node:fs";
|
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { writeCompanyBrief } from "./company-brief.js";
|
|
14
14
|
import { resolveOntologyCompany, safeWriteFile, stateRoot } from "./company.js";
|
|
15
|
+
import { startMultiWriterRun } from "./multi-writer.js";
|
|
15
16
|
import { buildEntityIndex, listKnowledgeFiles, listSignalFiles, readSignal } from "./tree.js";
|
|
16
17
|
export const ONTOLOGY_FLAG = "checkpoint.ontology";
|
|
17
18
|
export const SHARED_INPUTS_FLAG = "checkpoint.ontology.shared-inputs";
|
|
@@ -29,6 +30,12 @@ export const IGNORE_ANNOTATIONS_FLAG = "checkpoint.ontology.ignore-annotations";
|
|
|
29
30
|
* state directory, so the foreground checkpoint makes no network call.
|
|
30
31
|
*/
|
|
31
32
|
export const GROK_BACKEND_FLAG = "checkpoint.ontology.grok-backend";
|
|
33
|
+
/**
|
|
34
|
+
* Several writers in one company vault (design section 20): record written
|
|
35
|
+
* entity bytes in the outbox, merge back set-aside versions, and report
|
|
36
|
+
* duplicate entities.
|
|
37
|
+
*/
|
|
38
|
+
export const MULTI_WRITER_FLAG = "checkpoint.ontology.multi-writer";
|
|
32
39
|
export const PLAN_FILE = "ontology-plan.json";
|
|
33
40
|
export const CANDIDATES_FILE = "ontology-candidates.json";
|
|
34
41
|
export const REPORT_FILE = "ontology-report.json";
|
|
@@ -212,14 +219,19 @@ export async function planOntologyRun(options) {
|
|
|
212
219
|
return { status: "skipped", reason: "flag off" };
|
|
213
220
|
}
|
|
214
221
|
const sharedInputs = await options.readFlag(SHARED_INPUTS_FLAG, company);
|
|
215
|
-
|
|
216
|
-
const companyBrief = (await options.readFlag(COMPANY_BRIEF_FLAG, company))
|
|
217
|
-
? writeCompanyBrief(hqRoot, company, options.now ?? new Date())
|
|
218
|
-
: undefined;
|
|
222
|
+
const briefOn = await options.readFlag(COMPANY_BRIEF_FLAG, company);
|
|
219
223
|
const ignoreAnnotations = (await options.readFlag(IGNORE_ANNOTATIONS_FLAG, company)) && payload.backend === "grok";
|
|
220
224
|
recordBackendPreference(hqRoot, company, await options.readFlag(GROK_BACKEND_FLAG, company));
|
|
225
|
+
const multiWriterOn = await options.readFlag(MULTI_WRITER_FLAG, company);
|
|
221
226
|
const touched = sessionTouched(hqRoot, company, payload.files);
|
|
227
|
+
// A first run takes its boundary from the entity tree, so the ledger is
|
|
228
|
+
// refreshed before merge-back moves any `last_updated` forward.
|
|
222
229
|
const { ledger, firstRun } = refreshLedger(company, readLedger(hqRoot, company), touched, sharedInputs);
|
|
230
|
+
// Merge-back and the brief come before the nothing-changed return, so such
|
|
231
|
+
// a plan still merges set-aside versions and records the brief; the brief
|
|
232
|
+
// follows merge-back so its counts include merged entities.
|
|
233
|
+
const multiWriter = multiWriterOn ? startMultiWriterRun(hqRoot, company, options.now ?? new Date()) : undefined;
|
|
234
|
+
const companyBrief = briefOn ? writeCompanyBrief(hqRoot, company, options.now ?? new Date()) : undefined;
|
|
223
235
|
const pending = Object.entries(ledger.entries)
|
|
224
236
|
.filter(([, entry]) => entry.state === "pending" && (sharedInputs || entry.reason === "session-touched"))
|
|
225
237
|
.sort(([aRel, a], [bRel, b]) => reasonRank(a.reason) - reasonRank(b.reason) || a.mtime_ms - b.mtime_ms || aRel.localeCompare(bRel));
|
|
@@ -268,7 +280,12 @@ export async function planOntologyRun(options) {
|
|
|
268
280
|
writeLedger(hqRoot, company, ledger);
|
|
269
281
|
if (inputs.length === 0) {
|
|
270
282
|
const reason = pending.length > 0 ? "over budget" : "nothing changed";
|
|
271
|
-
return
|
|
283
|
+
return {
|
|
284
|
+
status: "skipped",
|
|
285
|
+
reason,
|
|
286
|
+
...(companyBrief ? { companyBrief } : {}),
|
|
287
|
+
...(multiWriter ? { multiWriter } : {}),
|
|
288
|
+
};
|
|
272
289
|
}
|
|
273
290
|
// Session content is scoped to the session's user (design section 6, D2).
|
|
274
291
|
// v1 records the existing entities it names as links in the report and
|
|
@@ -304,6 +321,7 @@ export async function planOntologyRun(options) {
|
|
|
304
321
|
: { status: companyBrief.status },
|
|
305
322
|
}
|
|
306
323
|
: {}),
|
|
324
|
+
...(multiWriter ? { multi_writer: multiWriter } : {}),
|
|
307
325
|
inputs,
|
|
308
326
|
session_text: sessionText,
|
|
309
327
|
pending_after_run: remaining,
|
|
@@ -9,6 +9,8 @@ export interface IndexedEntity extends EntityFields {
|
|
|
9
9
|
rel: string;
|
|
10
10
|
slug: string;
|
|
11
11
|
last_updated: string | null;
|
|
12
|
+
/** Absent for an entity created in this run before it was re-read. */
|
|
13
|
+
first_seen?: string | null;
|
|
12
14
|
}
|
|
13
15
|
export interface EntityIndex {
|
|
14
16
|
byRel: Map<string, IndexedEntity>;
|
|
@@ -78,12 +78,13 @@ export function buildEntityIndex(companyDir) {
|
|
|
78
78
|
index.invalid.add(rel);
|
|
79
79
|
continue;
|
|
80
80
|
}
|
|
81
|
-
const lastUpdated = parsed.frontmatter
|
|
81
|
+
const { last_updated: lastUpdated, first_seen: firstSeen } = parsed.frontmatter;
|
|
82
82
|
indexEntity(index, {
|
|
83
83
|
...fields,
|
|
84
84
|
rel,
|
|
85
85
|
slug: file.name.slice(0, -3),
|
|
86
86
|
last_updated: typeof lastUpdated === "string" ? lastUpdated : null,
|
|
87
|
+
first_seen: typeof firstSeen === "string" ? firstSeen : null,
|
|
87
88
|
});
|
|
88
89
|
}
|
|
89
90
|
}
|