@indigoai-us/hq-cli 5.193.1 → 5.194.1
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 +29 -0
- package/dist/command-catalog.generated.d.ts +39 -0
- package/dist/command-catalog.generated.js +49 -0
- package/dist/commands/core-checkpoint.d.ts +13 -2
- package/dist/commands/core-checkpoint.js +25 -4
- package/dist/commands/core-ontology.d.ts +22 -0
- package/dist/commands/core-ontology.js +114 -0
- package/dist/commands/core.js +2 -0
- package/dist/lib/checkpoint-ontology/apply.d.ts +120 -0
- package/dist/lib/checkpoint-ontology/apply.js +531 -0
- package/dist/lib/checkpoint-ontology/company.d.ts +40 -0
- package/dist/lib/checkpoint-ontology/company.js +110 -0
- package/dist/lib/checkpoint-ontology/entity-file.d.ts +83 -0
- package/dist/lib/checkpoint-ontology/entity-file.js +264 -0
- package/dist/lib/checkpoint-ontology/identity.d.ts +37 -0
- package/dist/lib/checkpoint-ontology/identity.js +95 -0
- package/dist/lib/checkpoint-ontology/plan.d.ts +124 -0
- package/dist/lib/checkpoint-ontology/plan.js +284 -0
- package/dist/lib/checkpoint-ontology/tree.d.ts +51 -0
- package/dist/lib/checkpoint-ontology/tree.js +163 -0
- package/dist/lib/lanes/reconcile.js +2 -3
- package/package.json +1 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq core ontology plan`: pick this run's inputs.
|
|
3
|
+
*
|
|
4
|
+
* Runs in the detached checkpoint sibling. It resolves the flag, finds the
|
|
5
|
+
* company's changed knowledge and signal files with a content-hash ledger,
|
|
6
|
+
* applies the per-run budget, and writes `<runDir>/ontology-plan.json` for the
|
|
7
|
+
* backend model to read. The model only fills a candidates file; everything
|
|
8
|
+
* that affects identity, scope and cost stays in code.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { resolveOntologyCompany, safeWriteFile, stateRoot } from "./company.js";
|
|
14
|
+
import { buildEntityIndex, listKnowledgeFiles, listSignalFiles, readSignal } from "./tree.js";
|
|
15
|
+
export const ONTOLOGY_FLAG = "checkpoint.ontology";
|
|
16
|
+
export const SHARED_INPUTS_FLAG = "checkpoint.ontology.shared-inputs";
|
|
17
|
+
export const PLAN_FILE = "ontology-plan.json";
|
|
18
|
+
export const CANDIDATES_FILE = "ontology-candidates.json";
|
|
19
|
+
export const REPORT_FILE = "ontology-report.json";
|
|
20
|
+
export const PLAN_SCHEMA = "hq-checkpoint-ontology-plan.v1";
|
|
21
|
+
export const CANDIDATES_SCHEMA = "hq-checkpoint-ontology-candidates.v1";
|
|
22
|
+
export const LEDGER_SCHEMA = "hq-checkpoint-ontology-ledger.v1";
|
|
23
|
+
export const BUDGET = {
|
|
24
|
+
maxInputs: 20,
|
|
25
|
+
maxTokens: 60_000,
|
|
26
|
+
chunkTokens: 12_000,
|
|
27
|
+
};
|
|
28
|
+
const CANDIDATE_INSTRUCTIONS = [
|
|
29
|
+
`Write ${CANDIDATES_FILE} in the run directory as {"schema":"${CANDIDATES_SCHEMA}","candidates":[...]} and nothing else.`,
|
|
30
|
+
"Each candidate: {input, type, name, aliases, confidence, evidence}. input is one of this plan's input ids; type is person, project, company or concept; name is the entity's full proper name; aliases are other names the input uses for it (at most 5); confidence is 0 to 1; evidence is a short quote from the input.",
|
|
31
|
+
"Read only the files and line ranges this plan lists. For a signal, read its body and name the entities in missing_refs if the signal supports them.",
|
|
32
|
+
"Name the specific people, projects, products, companies and named concepts that the input says something about. That includes the company that owns this knowledge and its own products when the input describes them. Name a third-party tool, vendor or service only when a section of the input is about it; skip ones that appear in a list, a stack summary or a passing mention. Skip generic words, file names and headings.",
|
|
33
|
+
"For the session input (its text is session_text in this plan), name only entities that already exist in the company and that the session did work on. Session candidates are recorded as links, never created.",
|
|
34
|
+
"Do not create, edit, move or delete anything under ontology/, signals/ or sources/ yourself.",
|
|
35
|
+
];
|
|
36
|
+
function sha256File(file) {
|
|
37
|
+
return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
38
|
+
}
|
|
39
|
+
export function ledgerPath(hqRoot, company) {
|
|
40
|
+
return path.join(stateRoot(hqRoot, company), "ledger.json");
|
|
41
|
+
}
|
|
42
|
+
export function readLedger(hqRoot, company) {
|
|
43
|
+
let raw;
|
|
44
|
+
try {
|
|
45
|
+
raw = fs.readFileSync(ledgerPath(hqRoot, company), "utf8");
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error.code === "ENOENT")
|
|
49
|
+
return null;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
if (parsed.schema !== LEDGER_SCHEMA || parsed.company !== company.slug || typeof parsed.entries !== "object") {
|
|
54
|
+
throw new Error(`ontology ledger for ${company.slug} has an unexpected schema`);
|
|
55
|
+
}
|
|
56
|
+
return parsed;
|
|
57
|
+
}
|
|
58
|
+
export function writeLedger(hqRoot, company, ledger) {
|
|
59
|
+
const target = ledgerPath(hqRoot, company);
|
|
60
|
+
safeWriteFile(hqRoot, [stateRoot(hqRoot, company)], target, `${JSON.stringify(ledger, null, 2)}\n`);
|
|
61
|
+
}
|
|
62
|
+
/** Company-relative paths of the payload's touched files that are inside this company. */
|
|
63
|
+
export function sessionTouched(hqRoot, company, files) {
|
|
64
|
+
const out = new Set();
|
|
65
|
+
if (!Array.isArray(files))
|
|
66
|
+
return out;
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
if (typeof file !== "string" || !file.trim())
|
|
69
|
+
continue;
|
|
70
|
+
const absolute = path.resolve(hqRoot, file.trim());
|
|
71
|
+
const rel = path.relative(company.dir, absolute);
|
|
72
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
|
|
73
|
+
continue;
|
|
74
|
+
out.add(rel.split(path.sep).join("/"));
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** 1-based line ranges, each at most `chunkTokens` tokens (bytes / 4). */
|
|
79
|
+
export function chunkLines(content, chunkTokens) {
|
|
80
|
+
const lines = content.split("\n");
|
|
81
|
+
const maxBytes = chunkTokens * 4;
|
|
82
|
+
const chunks = [];
|
|
83
|
+
let start = 1;
|
|
84
|
+
let bytes = 0;
|
|
85
|
+
lines.forEach((line, i) => {
|
|
86
|
+
const size = Buffer.byteLength(line, "utf8") + 1;
|
|
87
|
+
if (bytes > 0 && bytes + size > maxBytes) {
|
|
88
|
+
chunks.push([start, i]);
|
|
89
|
+
start = i + 1;
|
|
90
|
+
bytes = 0;
|
|
91
|
+
}
|
|
92
|
+
bytes += size;
|
|
93
|
+
});
|
|
94
|
+
chunks.push([start, lines.length]);
|
|
95
|
+
return chunks;
|
|
96
|
+
}
|
|
97
|
+
function estTokens(bytes) {
|
|
98
|
+
return Math.ceil(bytes / 4);
|
|
99
|
+
}
|
|
100
|
+
/** Latest `last_updated` over the company's writable entity types (design 14.3). */
|
|
101
|
+
function entityBoundary(index) {
|
|
102
|
+
let best = null;
|
|
103
|
+
let bestMs = -Infinity;
|
|
104
|
+
for (const entity of index.byRel.values()) {
|
|
105
|
+
if (!["person", "project", "company", "concept"].includes(entity.type) || !entity.last_updated)
|
|
106
|
+
continue;
|
|
107
|
+
const ms = Date.parse(entity.last_updated);
|
|
108
|
+
if (Number.isFinite(ms) && ms > bestMs) {
|
|
109
|
+
bestMs = ms;
|
|
110
|
+
best = entity.last_updated;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return best;
|
|
114
|
+
}
|
|
115
|
+
function missingRefs(companyDir, rel) {
|
|
116
|
+
const signal = readSignal(companyDir, rel);
|
|
117
|
+
if (!signal)
|
|
118
|
+
return [];
|
|
119
|
+
return signal.refs.filter((ref) => !fs.existsSync(path.join(companyDir, "ontology", "entities", `${ref}.md`)));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Update the ledger with the current tree and return it. New or changed files
|
|
123
|
+
* become pending when this machine should process them (design 14.1) and
|
|
124
|
+
* seen otherwise. The first run seeds from the entity boundary (design 14.3).
|
|
125
|
+
*/
|
|
126
|
+
export function refreshLedger(company, existing, touched, sharedInputs) {
|
|
127
|
+
const firstRun = existing === null;
|
|
128
|
+
const index = firstRun ? buildEntityIndex(company.dir) : null;
|
|
129
|
+
const boundary = existing ? existing.boundary : entityBoundary(index);
|
|
130
|
+
const boundaryMs = boundary ? Date.parse(boundary) : Number.NaN;
|
|
131
|
+
const entries = {};
|
|
132
|
+
const files = [...listKnowledgeFiles(company.dir), ...listSignalFiles(company.dir)];
|
|
133
|
+
for (const rel of files) {
|
|
134
|
+
const absolute = path.join(company.dir, rel);
|
|
135
|
+
const stat = fs.statSync(absolute);
|
|
136
|
+
const previous = existing?.entries[rel];
|
|
137
|
+
const unchangedStat = previous && previous.size === stat.size && previous.mtime_ms === stat.mtimeMs;
|
|
138
|
+
const sha256 = unchangedStat ? previous.sha256 : sha256File(absolute);
|
|
139
|
+
const base = { sha256, size: stat.size, mtime_ms: stat.mtimeMs };
|
|
140
|
+
if (previous && previous.sha256 === sha256) {
|
|
141
|
+
// Same content. A pending entry stays pending until apply finishes it.
|
|
142
|
+
// A file another run recorded as seen is still this session's input.
|
|
143
|
+
entries[rel] = previous.state === "seen" && touched.has(rel)
|
|
144
|
+
? { ...base, state: "pending", reason: "session-touched" }
|
|
145
|
+
: { ...previous, ...base };
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
let reason = null;
|
|
149
|
+
if (touched.has(rel))
|
|
150
|
+
reason = "session-touched";
|
|
151
|
+
else if (sharedInputs && firstRun) {
|
|
152
|
+
if (Number.isFinite(boundaryMs) && stat.mtimeMs > boundaryMs)
|
|
153
|
+
reason = "first-run-gap";
|
|
154
|
+
else if (rel.startsWith("signals/") && missingRefs(company.dir, rel).length > 0)
|
|
155
|
+
reason = "missing-refs";
|
|
156
|
+
}
|
|
157
|
+
else if (sharedInputs)
|
|
158
|
+
reason = "shared";
|
|
159
|
+
entries[rel] = reason ? { ...base, state: "pending", reason } : { ...base, state: "seen" };
|
|
160
|
+
}
|
|
161
|
+
return { ledger: { schema: LEDGER_SCHEMA, company: company.slug, boundary, entries }, firstRun };
|
|
162
|
+
}
|
|
163
|
+
function reasonRank(reason) {
|
|
164
|
+
return reason === "session-touched" ? 0 : 1;
|
|
165
|
+
}
|
|
166
|
+
/** Plan this run. Never throws for a skip; returns the reason instead. */
|
|
167
|
+
export async function planOntologyRun(options) {
|
|
168
|
+
const { hqRoot, runDir, payload } = options;
|
|
169
|
+
const resolution = resolveOntologyCompany(hqRoot, typeof payload.company === "string" ? payload.company : null);
|
|
170
|
+
if (!resolution.ok)
|
|
171
|
+
return { status: "skipped", reason: resolution.reason };
|
|
172
|
+
const company = resolution.company;
|
|
173
|
+
if (!(await options.readFlag(ONTOLOGY_FLAG, company)))
|
|
174
|
+
return { status: "skipped", reason: "flag off" };
|
|
175
|
+
const sharedInputs = await options.readFlag(SHARED_INPUTS_FLAG, company);
|
|
176
|
+
const touched = sessionTouched(hqRoot, company, payload.files);
|
|
177
|
+
const { ledger, firstRun } = refreshLedger(company, readLedger(hqRoot, company), touched, sharedInputs);
|
|
178
|
+
const pending = Object.entries(ledger.entries)
|
|
179
|
+
.filter(([, entry]) => entry.state === "pending" && (sharedInputs || entry.reason === "session-touched"))
|
|
180
|
+
.sort(([aRel, a], [bRel, b]) => reasonRank(a.reason) - reasonRank(b.reason) || a.mtime_ms - b.mtime_ms || aRel.localeCompare(bRel));
|
|
181
|
+
const inputs = [];
|
|
182
|
+
let tokens = 0;
|
|
183
|
+
let remaining = 0;
|
|
184
|
+
for (const [rel, entry] of pending) {
|
|
185
|
+
if (inputs.length >= BUDGET.maxInputs || tokens >= BUDGET.maxTokens) {
|
|
186
|
+
remaining += 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const lines = fs.readFileSync(path.join(company.dir, rel), "utf8").split("\n");
|
|
190
|
+
const chunks = chunkLines(lines.join("\n"), BUDGET.chunkTokens);
|
|
191
|
+
const kind = rel.startsWith("signals/") ? "signal" : "knowledge";
|
|
192
|
+
let index = entry.next_chunk ?? 0;
|
|
193
|
+
for (; index < chunks.length; index += 1) {
|
|
194
|
+
const [start, end] = chunks[index];
|
|
195
|
+
const cost = estTokens(Buffer.byteLength(lines.slice(start - 1, end).join("\n"), "utf8"));
|
|
196
|
+
if (inputs.length >= BUDGET.maxInputs || tokens + cost > BUDGET.maxTokens)
|
|
197
|
+
break;
|
|
198
|
+
inputs.push({
|
|
199
|
+
id: chunks.length > 1 ? `${rel}#${index}` : rel,
|
|
200
|
+
kind,
|
|
201
|
+
path: rel,
|
|
202
|
+
audience: "company",
|
|
203
|
+
reason: entry.reason ?? "shared",
|
|
204
|
+
sha256: entry.sha256,
|
|
205
|
+
est_tokens: cost,
|
|
206
|
+
lines: chunks.length > 1 ? [start, end] : null,
|
|
207
|
+
chunk: chunks.length > 1 ? { index, count: chunks.length } : null,
|
|
208
|
+
...(kind === "signal" ? { missing_refs: missingRefs(company.dir, rel) } : {}),
|
|
209
|
+
});
|
|
210
|
+
tokens += cost;
|
|
211
|
+
}
|
|
212
|
+
if (index < chunks.length)
|
|
213
|
+
remaining += 1;
|
|
214
|
+
}
|
|
215
|
+
writeLedger(hqRoot, company, ledger);
|
|
216
|
+
if (inputs.length === 0) {
|
|
217
|
+
return { status: "skipped", reason: pending.length > 0 ? "over budget" : "nothing changed" };
|
|
218
|
+
}
|
|
219
|
+
// Session content is scoped to the session's user (design section 6, D2).
|
|
220
|
+
// v1 records the existing entities it names as links in the report and
|
|
221
|
+
// writes nothing from it, so it rides along only when files changed.
|
|
222
|
+
const sessionText = sessionContent(payload);
|
|
223
|
+
if (sessionText) {
|
|
224
|
+
const cost = estTokens(Buffer.byteLength(sessionText, "utf8"));
|
|
225
|
+
inputs.unshift({
|
|
226
|
+
id: "session",
|
|
227
|
+
kind: "session",
|
|
228
|
+
path: null,
|
|
229
|
+
audience: "session",
|
|
230
|
+
reason: "session-touched",
|
|
231
|
+
sha256: null,
|
|
232
|
+
est_tokens: cost,
|
|
233
|
+
lines: null,
|
|
234
|
+
chunk: null,
|
|
235
|
+
});
|
|
236
|
+
tokens += cost;
|
|
237
|
+
}
|
|
238
|
+
const plan = {
|
|
239
|
+
schema: PLAN_SCHEMA,
|
|
240
|
+
company: company.slug,
|
|
241
|
+
run_id: path.basename(runDir),
|
|
242
|
+
created_at: (options.now ?? new Date()).toISOString(),
|
|
243
|
+
first_run: firstRun,
|
|
244
|
+
boundary: ledger.boundary,
|
|
245
|
+
shared_inputs: sharedInputs,
|
|
246
|
+
inputs,
|
|
247
|
+
session_text: sessionText,
|
|
248
|
+
pending_after_run: remaining,
|
|
249
|
+
budget: {
|
|
250
|
+
max_inputs: BUDGET.maxInputs,
|
|
251
|
+
max_tokens: BUDGET.maxTokens,
|
|
252
|
+
chunk_tokens: BUDGET.chunkTokens,
|
|
253
|
+
planned_tokens: tokens,
|
|
254
|
+
},
|
|
255
|
+
candidates_file: CANDIDATES_FILE,
|
|
256
|
+
instructions: CANDIDATE_INSTRUCTIONS,
|
|
257
|
+
};
|
|
258
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
259
|
+
const planPath = path.join(runDir, PLAN_FILE);
|
|
260
|
+
fs.writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`);
|
|
261
|
+
return { status: "planned", plan, planPath };
|
|
262
|
+
}
|
|
263
|
+
const SESSION_TEXT_MAX_BYTES = 8_000;
|
|
264
|
+
/** The session summary, decisions and learnings from the payload, capped. */
|
|
265
|
+
function sessionContent(payload) {
|
|
266
|
+
const parts = [];
|
|
267
|
+
if (typeof payload.summary === "string" && payload.summary.trim())
|
|
268
|
+
parts.push(payload.summary.trim());
|
|
269
|
+
for (const key of ["decisions", "learnings", "insights"]) {
|
|
270
|
+
const items = payload[key];
|
|
271
|
+
if (!Array.isArray(items))
|
|
272
|
+
continue;
|
|
273
|
+
for (const item of items)
|
|
274
|
+
if (typeof item === "string" && item.trim())
|
|
275
|
+
parts.push(`- ${item.trim()}`);
|
|
276
|
+
}
|
|
277
|
+
if (parts.length === 0)
|
|
278
|
+
return null;
|
|
279
|
+
const text = parts.join("\n");
|
|
280
|
+
return Buffer.byteLength(text, "utf8") > SESSION_TEXT_MAX_BYTES
|
|
281
|
+
? Buffer.from(text, "utf8").subarray(0, SESSION_TEXT_MAX_BYTES).toString("utf8").replace(/\uFFFD$/, "")
|
|
282
|
+
: text;
|
|
283
|
+
}
|
|
284
|
+
//# sourceMappingURL=plan.js.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory indexes over a company's ontology and signals trees.
|
|
3
|
+
*/
|
|
4
|
+
import { type EntityFields } from "./entity-file.js";
|
|
5
|
+
/** The eight company-audience signal types (`signals/{type}/*.md`). */
|
|
6
|
+
export declare const SIGNAL_TYPES: readonly ["action_item", "commitment", "decision", "risk", "question", "key_point", "participant_contribution", "summary"];
|
|
7
|
+
export interface IndexedEntity extends EntityFields {
|
|
8
|
+
/** Company-relative path, e.g. `ontology/entities/person/jane-doe.md`. */
|
|
9
|
+
rel: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
last_updated: string | null;
|
|
12
|
+
}
|
|
13
|
+
export interface EntityIndex {
|
|
14
|
+
byRel: Map<string, IndexedEntity>;
|
|
15
|
+
/** `<type>\0<normalised canonical name>` → entity paths. */
|
|
16
|
+
canonical: Map<string, Set<string>>;
|
|
17
|
+
/** `<type>\0<normalised alias>` → entity paths. */
|
|
18
|
+
alias: Map<string, Set<string>>;
|
|
19
|
+
/** `<type>\0<squashed name or alias>` → entity paths. */
|
|
20
|
+
squashed: Map<string, Set<string>>;
|
|
21
|
+
/** Normalised name or alias → the types that hold it. */
|
|
22
|
+
types: Map<string, Set<string>>;
|
|
23
|
+
/** Files under `ontology/entities/` that fail validation. Never written. */
|
|
24
|
+
invalid: Set<string>;
|
|
25
|
+
}
|
|
26
|
+
export declare function emptyIndex(): EntityIndex;
|
|
27
|
+
/** Add one parsed entity to the index (also used for entities created mid-run). */
|
|
28
|
+
export declare function indexEntity(index: EntityIndex, entity: IndexedEntity): void;
|
|
29
|
+
/** Record that the entity at `rel` answers to `alias`. */
|
|
30
|
+
export declare function indexAlias(index: EntityIndex, type: string, rel: string, alias: string): void;
|
|
31
|
+
/**
|
|
32
|
+
* Index every entity file of every type under `ontology/entities/`. Other
|
|
33
|
+
* types (CRM, accounting, legacy `tool`) are indexed so a name they hold is a
|
|
34
|
+
* type conflict, but they are never written.
|
|
35
|
+
*/
|
|
36
|
+
export declare function buildEntityIndex(companyDir: string): EntityIndex;
|
|
37
|
+
export interface SignalRecord {
|
|
38
|
+
/** Company-relative path, `signals/<type>/<file>.md`. */
|
|
39
|
+
rel: string;
|
|
40
|
+
type: string;
|
|
41
|
+
canonicalContent: string;
|
|
42
|
+
/** `entity_refs` items such as `person/jane-doe`. */
|
|
43
|
+
refs: string[];
|
|
44
|
+
}
|
|
45
|
+
/** Parse the frontmatter of one company-audience signal file. */
|
|
46
|
+
export declare function readSignal(companyDir: string, rel: string): SignalRecord | null;
|
|
47
|
+
/** Company-relative paths of every root signal file, sorted. */
|
|
48
|
+
export declare function listSignalFiles(companyDir: string): string[];
|
|
49
|
+
/** Company-relative paths of every knowledge markdown file, sorted. */
|
|
50
|
+
export declare function listKnowledgeFiles(companyDir: string): string[];
|
|
51
|
+
//# sourceMappingURL=tree.d.ts.map
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory indexes over a company's ontology and signals trees.
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { normalizeEntityName, squash } from "./identity.js";
|
|
7
|
+
import { parseEntityFile, validateEntityFrontmatter } from "./entity-file.js";
|
|
8
|
+
/** The eight company-audience signal types (`signals/{type}/*.md`). */
|
|
9
|
+
export const SIGNAL_TYPES = [
|
|
10
|
+
"action_item", "commitment", "decision", "risk", "question", "key_point",
|
|
11
|
+
"participant_contribution", "summary",
|
|
12
|
+
];
|
|
13
|
+
function add(map, key, value) {
|
|
14
|
+
let set = map.get(key);
|
|
15
|
+
if (!set) {
|
|
16
|
+
set = new Set();
|
|
17
|
+
map.set(key, set);
|
|
18
|
+
}
|
|
19
|
+
set.add(value);
|
|
20
|
+
}
|
|
21
|
+
export function emptyIndex() {
|
|
22
|
+
return {
|
|
23
|
+
byRel: new Map(),
|
|
24
|
+
canonical: new Map(),
|
|
25
|
+
alias: new Map(),
|
|
26
|
+
squashed: new Map(),
|
|
27
|
+
types: new Map(),
|
|
28
|
+
invalid: new Set(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Add one parsed entity to the index (also used for entities created mid-run). */
|
|
32
|
+
export function indexEntity(index, entity) {
|
|
33
|
+
index.byRel.set(entity.rel, entity);
|
|
34
|
+
const canonical = normalizeEntityName(entity.canonical_name, entity.type);
|
|
35
|
+
if (canonical) {
|
|
36
|
+
add(index.canonical, `${entity.type}\0${canonical}`, entity.rel);
|
|
37
|
+
add(index.squashed, `${entity.type}\0${squash(canonical)}`, entity.rel);
|
|
38
|
+
add(index.types, canonical, entity.type);
|
|
39
|
+
}
|
|
40
|
+
for (const alias of entity.aliases)
|
|
41
|
+
indexAlias(index, entity.type, entity.rel, alias);
|
|
42
|
+
}
|
|
43
|
+
/** Record that the entity at `rel` answers to `alias`. */
|
|
44
|
+
export function indexAlias(index, type, rel, alias) {
|
|
45
|
+
const normalized = normalizeEntityName(alias, type);
|
|
46
|
+
if (!normalized)
|
|
47
|
+
return;
|
|
48
|
+
add(index.alias, `${type}\0${normalized}`, rel);
|
|
49
|
+
add(index.squashed, `${type}\0${squash(normalized)}`, rel);
|
|
50
|
+
add(index.types, normalized, type);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Index every entity file of every type under `ontology/entities/`. Other
|
|
54
|
+
* types (CRM, accounting, legacy `tool`) are indexed so a name they hold is a
|
|
55
|
+
* type conflict, but they are never written.
|
|
56
|
+
*/
|
|
57
|
+
export function buildEntityIndex(companyDir) {
|
|
58
|
+
const index = emptyIndex();
|
|
59
|
+
const root = path.join(companyDir, "ontology", "entities");
|
|
60
|
+
let types;
|
|
61
|
+
try {
|
|
62
|
+
types = fs.readdirSync(root, { withFileTypes: true });
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code === "ENOENT")
|
|
66
|
+
return index;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
for (const typeDir of types.filter((entry) => entry.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
70
|
+
const dir = path.join(root, typeDir.name);
|
|
71
|
+
for (const file of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
72
|
+
if (!file.isFile() || !file.name.endsWith(".md"))
|
|
73
|
+
continue;
|
|
74
|
+
const rel = `ontology/entities/${typeDir.name}/${file.name}`;
|
|
75
|
+
const parsed = parseEntityFile(fs.readFileSync(path.join(dir, file.name), "utf8"));
|
|
76
|
+
const fields = parsed ? validateEntityFrontmatter(parsed.frontmatter) : "unparseable";
|
|
77
|
+
if (typeof fields === "string" || !parsed) {
|
|
78
|
+
index.invalid.add(rel);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const lastUpdated = parsed.frontmatter.last_updated;
|
|
82
|
+
indexEntity(index, {
|
|
83
|
+
...fields,
|
|
84
|
+
rel,
|
|
85
|
+
slug: file.name.slice(0, -3),
|
|
86
|
+
last_updated: typeof lastUpdated === "string" ? lastUpdated : null,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return index;
|
|
91
|
+
}
|
|
92
|
+
/** Parse the frontmatter of one company-audience signal file. */
|
|
93
|
+
export function readSignal(companyDir, rel) {
|
|
94
|
+
let content;
|
|
95
|
+
try {
|
|
96
|
+
content = fs.readFileSync(path.join(companyDir, rel), "utf8");
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const parsed = parseEntityFile(content);
|
|
102
|
+
if (!parsed)
|
|
103
|
+
return null;
|
|
104
|
+
const fm = parsed.frontmatter;
|
|
105
|
+
const refs = Array.isArray(fm.entity_refs)
|
|
106
|
+
? fm.entity_refs.filter((ref) => typeof ref === "string" && /^[a-z_-]+\/[^/]+$/.test(ref))
|
|
107
|
+
: [];
|
|
108
|
+
return {
|
|
109
|
+
rel,
|
|
110
|
+
type: typeof fm.type === "string" ? fm.type : rel.split("/")[1],
|
|
111
|
+
canonicalContent: typeof fm.canonical_content === "string" ? fm.canonical_content : "",
|
|
112
|
+
refs,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Company-relative paths of every root signal file, sorted. */
|
|
116
|
+
export function listSignalFiles(companyDir) {
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const type of SIGNAL_TYPES) {
|
|
119
|
+
let files;
|
|
120
|
+
try {
|
|
121
|
+
files = fs.readdirSync(path.join(companyDir, "signals", type), { withFileTypes: true });
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (error.code === "ENOENT")
|
|
125
|
+
continue;
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
if (file.isFile() && file.name.endsWith(".md"))
|
|
130
|
+
out.push(`signals/${type}/${file.name}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return out.sort();
|
|
134
|
+
}
|
|
135
|
+
/** Company-relative paths of every knowledge markdown file, sorted. */
|
|
136
|
+
export function listKnowledgeFiles(companyDir) {
|
|
137
|
+
const out = [];
|
|
138
|
+
const walk = (rel) => {
|
|
139
|
+
let entries;
|
|
140
|
+
try {
|
|
141
|
+
entries = fs.readdirSync(path.join(companyDir, rel), { withFileTypes: true });
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (error.code === "ENOENT")
|
|
145
|
+
return;
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
// Dot files are local state; `@{key}` folders are scoped audiences.
|
|
150
|
+
if (entry.name.startsWith(".") || entry.name.startsWith("@"))
|
|
151
|
+
continue;
|
|
152
|
+
const child = `${rel}/${entry.name}`;
|
|
153
|
+
// Symlinks are not followed: a link could point into another company.
|
|
154
|
+
if (entry.isDirectory())
|
|
155
|
+
walk(child);
|
|
156
|
+
else if (entry.isFile() && entry.name.endsWith(".md"))
|
|
157
|
+
out.push(child);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
walk("knowledge");
|
|
161
|
+
return out.sort();
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=tree.js.map
|
|
@@ -569,7 +569,7 @@ async function withReconcileLock(hqRoot, fn) {
|
|
|
569
569
|
}
|
|
570
570
|
}
|
|
571
571
|
}
|
|
572
|
-
function classifyDeadLocalLane(hqRoot, laneId) {
|
|
572
|
+
function classifyDeadLocalLane(hqRoot, laneId, now) {
|
|
573
573
|
const runDir = laneRunDir(hqRoot, laneId);
|
|
574
574
|
const manifest = readSpawnManifest(runDir);
|
|
575
575
|
const minEnvelopeName = manifest.envelope_path ? path.basename(manifest.envelope_path) : undefined;
|
|
@@ -586,7 +586,6 @@ function classifyDeadLocalLane(hqRoot, laneId) {
|
|
|
586
586
|
// the quota signal, any other becomes provider_rejected, and only a stream
|
|
587
587
|
// with no failure object falls back to the quota reader.
|
|
588
588
|
const workerOutOffset = readSpawnManifest(runDir).worker_out_offset ?? 0;
|
|
589
|
-
const now = new Date();
|
|
590
589
|
const routed = envelope
|
|
591
590
|
? { quota: null, rejection: null }
|
|
592
591
|
: routeProviderFailure(readProviderRejection(runDir, lane.provider, workerOutOffset), () => readQuotaSignal(runDir, lane.model, now, workerOutOffset), lane.model, now);
|
|
@@ -894,7 +893,7 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
894
893
|
continue;
|
|
895
894
|
}
|
|
896
895
|
try {
|
|
897
|
-
const { classification, latest, quota, providerRejection, lane: classified } = classifyDeadLocalLane(hqRoot, lane.lane_id);
|
|
896
|
+
const { classification, latest, quota, providerRejection, lane: classified } = classifyDeadLocalLane(hqRoot, lane.lane_id, now);
|
|
898
897
|
// Match the watcher's one-repair decision before classifying an
|
|
899
898
|
// unparseable envelope after both processes have died. As in the
|
|
900
899
|
// watcher, a provider rejection or a quota signal skips the repair.
|