@openshain/core 0.4.0 → 0.5.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.
Files changed (48) hide show
  1. package/dist/config/schema.d.ts +8 -0
  2. package/dist/config/schema.js +29 -1
  3. package/dist/errors.d.ts +6 -1
  4. package/dist/errors.js +9 -0
  5. package/dist/index.d.ts +10 -3
  6. package/dist/index.js +9 -2
  7. package/dist/knowledge/build.d.ts +102 -0
  8. package/dist/knowledge/build.js +279 -0
  9. package/dist/knowledge/check.d.ts +30 -0
  10. package/dist/knowledge/check.js +262 -0
  11. package/dist/knowledge/schema.d.ts +93 -0
  12. package/dist/knowledge/schema.js +76 -0
  13. package/dist/knowledge/search.d.ts +26 -0
  14. package/dist/knowledge/search.js +54 -0
  15. package/dist/knowledge/store.d.ts +10 -0
  16. package/dist/knowledge/store.js +69 -0
  17. package/dist/runtime.d.ts +3 -1
  18. package/dist/runtime.js +5 -7
  19. package/dist/schemas.d.ts +1 -1
  20. package/dist/schemas.js +3 -0
  21. package/dist/time.d.ts +14 -0
  22. package/dist/time.js +50 -0
  23. package/dist/tool/files.d.ts +21 -0
  24. package/dist/tool/files.js +69 -0
  25. package/dist/tool/paths.d.ts +1 -1
  26. package/dist/tool/paths.js +9 -1
  27. package/dist/tool/types.d.ts +13 -5
  28. package/dist/work/events.d.ts +23 -3
  29. package/dist/work/events.js +30 -3
  30. package/dist/work/projection.d.ts +9 -0
  31. package/dist/work/projection.js +54 -2
  32. package/package.json +1 -1
  33. package/src/config/schema.ts +42 -3
  34. package/src/errors.ts +12 -0
  35. package/src/index.ts +63 -2
  36. package/src/knowledge/build.ts +349 -0
  37. package/src/knowledge/check.ts +314 -0
  38. package/src/knowledge/schema.ts +105 -0
  39. package/src/knowledge/search.ts +74 -0
  40. package/src/knowledge/store.ts +82 -0
  41. package/src/runtime.ts +8 -8
  42. package/src/schemas.ts +14 -1
  43. package/src/time.ts +54 -0
  44. package/src/tool/files.ts +79 -0
  45. package/src/tool/paths.ts +9 -1
  46. package/src/tool/types.ts +14 -2
  47. package/src/work/events.ts +39 -4
  48. package/src/work/projection.ts +57 -2
package/src/index.ts CHANGED
@@ -31,7 +31,13 @@ export {
31
31
  } from "./config/load.ts";
32
32
  export type { Config, ModelConfig, ToolProviderRef } from "./config/schema.ts";
33
33
  export { LANGUAGES, type Language } from "./config/schema.ts";
34
- export { ERROR_CODES, type ErrorCode, isOpenshainError, OpenshainError } from "./errors.ts";
34
+ export {
35
+ ERROR_CODES,
36
+ type ErrorCode,
37
+ isOpenshainError,
38
+ isTooLarge,
39
+ OpenshainError,
40
+ } from "./errors.ts";
35
41
  export {
36
42
  type EventId,
37
43
  newEventId,
@@ -40,6 +46,47 @@ export {
40
46
  parseWorkId,
41
47
  type WorkId,
42
48
  } from "./ids.ts";
49
+ export {
50
+ buildIndex,
51
+ hashKnowledgeInput,
52
+ INDEX_FORMAT_VERSION,
53
+ type IndexState,
54
+ type IndexUnit,
55
+ type KnowledgeIndex,
56
+ type Manifest,
57
+ readIndex,
58
+ serializeIndex,
59
+ writeIndex,
60
+ } from "./knowledge/build.ts";
61
+ export {
62
+ type Checked,
63
+ checkKnowledge,
64
+ hasKnowledge,
65
+ KNOWLEDGE_DIR_NAME,
66
+ } from "./knowledge/check.ts";
67
+ export {
68
+ type LoadedRule as LoadedKnowledgeRule,
69
+ type Rule as KnowledgeRule,
70
+ RuleSchema as KnowledgeRuleSchema,
71
+ RulesFileSchema,
72
+ type Scope as KnowledgeScope,
73
+ ScopeSchema as KnowledgeScopeSchema,
74
+ type Source as KnowledgeSource,
75
+ type SourceFrontMatter,
76
+ SourceFrontMatterSchema,
77
+ } from "./knowledge/schema.ts";
78
+ export {
79
+ type Hit,
80
+ inEffect,
81
+ MIN_QUERY_LENGTH,
82
+ type SearchOptions,
83
+ search,
84
+ } from "./knowledge/search.ts";
85
+ export {
86
+ knowledgePath,
87
+ readKnowledgeFile,
88
+ writeKnowledgeFile,
89
+ } from "./knowledge/store.ts";
43
90
  export type {
44
91
  ModelDescription,
45
92
  ModelMessage,
@@ -62,7 +109,15 @@ export {
62
109
  type ToolSummary,
63
110
  } from "./runtime.ts";
64
111
  export { jsonSchemas, type SchemaName } from "./schemas.ts";
112
+ export { businessDate, companyTime, hostTimezone, isTimezone } from "./time.ts";
65
113
  export { ASK_USER, RUNTIME_PROVIDER_ID } from "./tool/ask-user.ts";
114
+ export {
115
+ MAX_READ_BYTES,
116
+ MAX_WRITE_BYTES,
117
+ readWorkspaceText,
118
+ readWorkspaceTextIfAny,
119
+ writeWorkspaceText,
120
+ } from "./tool/files.ts";
66
121
  export { loadToolModule } from "./tool/load-module.ts";
67
122
  export { RESERVED_PATHS, resolveWorkspacePath } from "./tool/paths.ts";
68
123
  export {
@@ -74,6 +129,7 @@ export {
74
129
  export {
75
130
  ASK_USER_TOOL_NAME,
76
131
  type JsonSchema,
132
+ type Observation,
77
133
  RESERVED_TOOL_NAMES,
78
134
  TOOL_NAME_PATTERN,
79
135
  type ToolCall,
@@ -122,7 +178,12 @@ export {
122
178
  workHistory,
123
179
  } from "./work/history.ts";
124
180
  export { acquireLock, LOCK_FILE_NAME, type Lock } from "./work/lock.ts";
125
- export { buildProjection, type Projection, type ProjectionInput } from "./work/projection.ts";
181
+ export {
182
+ buildProjection,
183
+ type Projection,
184
+ type ProjectionInput,
185
+ RECENT_MESSAGES,
186
+ } from "./work/projection.ts";
126
187
  export {
127
188
  type CreateWorkInput,
128
189
  type ListResult,
@@ -0,0 +1,349 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { readWorkspaceTextIfAny } from "../tool/files.ts";
5
+ import type { Checked } from "./check.ts";
6
+ import { KNOWLEDGE_DIR_NAME } from "./check.ts";
7
+ import type { LoadedRule, Scope, Source } from "./schema.ts";
8
+ import { readKnowledgeFile, writeKnowledgeFile } from "./store.ts";
9
+
10
+ /**
11
+ * Turning what a person wrote into the index the runtime serves. The index is a build artifact:
12
+ * the same input always makes the same bytes, and the manifest says which input it came from and
13
+ * what the index itself hashes to, so a rewritten index is not served.
14
+ */
15
+
16
+ /** Raised when the index is read by a runtime that indexes differently than the one that wrote it. */
17
+ export const INDEX_FORMAT_VERSION = 1;
18
+
19
+ const BUILD_DIR = "build";
20
+ const INDEX_FILE = "index.json";
21
+ const MANIFEST_FILE = "manifest.json";
22
+
23
+ /** One thing the search can return: a rule, or one section of a source. */
24
+ export interface IndexUnit {
25
+ /** `rule:<id>` or `source:<id>#<heading>`; unique in the index. */
26
+ key: string;
27
+ kind: "rule" | "source";
28
+ /** The id a person wrote, which citations name. */
29
+ ref: string;
30
+ heading: string;
31
+ /** What the unit says: the statement of a rule, or the text of a section. */
32
+ text: string;
33
+ scope: Scope | null;
34
+ /** The professions a rule is for, or null for every profession. A source is for all of them. */
35
+ professions: string[] | null;
36
+ expertise: string;
37
+ from: string;
38
+ to: string | null;
39
+ /** For a rule, the source it cites. */
40
+ source?: { id: string; section?: string };
41
+ /** For a source, where it came from. */
42
+ provenance?: { publisher: string; title: string; version?: string; retrieved_at: string };
43
+ }
44
+
45
+ export interface KnowledgeIndex {
46
+ format: number;
47
+ units: IndexUnit[];
48
+ /**
49
+ * The groups of characters to the units that contain them. Two-character groups answer a
50
+ * question too short to have three.
51
+ */
52
+ postings: { pairs: Record<string, number[]>; triples: Record<string, number[]> };
53
+ /** How many distinct three-character grams each unit has, for the length correction. */
54
+ sizes: number[];
55
+ }
56
+
57
+ export interface Manifest {
58
+ format: number;
59
+ input_sha256: string;
60
+ index_sha256: string;
61
+ units: number;
62
+ rules: number;
63
+ sources: number;
64
+ built_at: string;
65
+ }
66
+
67
+ /** Text as the index compares it: full width and half width alike, one case, no spaces. */
68
+ export function normalize(text: string): string {
69
+ return text.normalize("NFKC").toLowerCase().replace(/\s+/gu, "");
70
+ }
71
+
72
+ /** The distinct groups of `n` characters in the text. */
73
+ export function grams(text: string, n: number): Set<string> {
74
+ const chars = [...normalize(text)];
75
+ const out = new Set<string>();
76
+ for (let i = 0; i + n <= chars.length; i++) out.add(chars.slice(i, i + n).join(""));
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * The index of a checked set. A rule that another rule replaces is closed the day before the
82
+ * newer one starts, so the two are never in effect together.
83
+ */
84
+ export function buildIndex(checked: Checked): KnowledgeIndex {
85
+ const closed = closeSuperseded(checked.rules);
86
+ const units: IndexUnit[] = [
87
+ ...closed.map(ruleUnit),
88
+ ...checked.sources.flatMap((source) => sectionUnits(source)),
89
+ ].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
90
+
91
+ const postings = { pairs: {}, triples: {} } satisfies KnowledgeIndex["postings"];
92
+ const sizes: number[] = [];
93
+ for (const [at, unit] of units.entries()) {
94
+ // What a unit is matched on: its own words, and its heading when that is not the words
95
+ // themselves. The id is left out; it would only make a unit look longer than it reads.
96
+ // Aliases are already part of a rule's text, and nothing else bridges words that share no
97
+ // characters with it.
98
+ const matter = unit.heading === unit.text ? unit.text : `${unit.text} ${unit.heading}`;
99
+ add(postings.pairs, grams(matter, 2), at);
100
+ add(postings.triples, grams(matter, 3), at);
101
+ sizes.push(grams(matter, 3).size);
102
+ }
103
+ return {
104
+ format: INDEX_FORMAT_VERSION,
105
+ units,
106
+ postings: { pairs: settled(postings.pairs), triples: settled(postings.triples) },
107
+ sizes,
108
+ };
109
+ }
110
+
111
+ /** Notes that the unit at `at` holds each of these groups. */
112
+ function add(postings: Record<string, number[]>, of: Set<string>, at: number): void {
113
+ for (const gram of of) {
114
+ const units = postings[gram];
115
+ if (units) units.push(at);
116
+ else postings[gram] = [at];
117
+ }
118
+ }
119
+
120
+ /** The same postings in a fixed order, so that the same input writes the same bytes. */
121
+ function settled(postings: Record<string, number[]>): Record<string, number[]> {
122
+ return Object.fromEntries(
123
+ Object.entries(postings)
124
+ .sort(([a], [b]) => (a < b ? -1 : 1))
125
+ .map(([gram, units]) => [gram, [...units].sort((x, y) => x - y)]),
126
+ );
127
+ }
128
+
129
+ /** A rule replaced by another ends the day before that one begins. */
130
+ function closeSuperseded(rules: LoadedRule[]): LoadedRule[] {
131
+ const replacedBy = new Map<string, LoadedRule>();
132
+ for (const rule of rules) if (rule.supersedes) replacedBy.set(rule.supersedes, rule);
133
+ return rules.map((rule) => {
134
+ const next = replacedBy.get(rule.id);
135
+ if (!next) return rule;
136
+ const end = dayBefore(next.effective_from);
137
+ return {
138
+ ...rule,
139
+ effective_to: rule.effective_to === null ? end : minDay(rule.effective_to, end),
140
+ };
141
+ });
142
+ }
143
+
144
+ function dayBefore(day: string): string {
145
+ const at = new Date(`${day}T00:00:00Z`);
146
+ at.setUTCDate(at.getUTCDate() - 1);
147
+ return at.toISOString().slice(0, 10);
148
+ }
149
+
150
+ const minDay = (a: string, b: string) => (a < b ? a : b);
151
+
152
+ function ruleUnit(rule: LoadedRule): IndexUnit {
153
+ return {
154
+ key: `rule:${rule.id}`,
155
+ kind: "rule",
156
+ ref: rule.id,
157
+ heading: rule.statement,
158
+ text: [rule.statement, ...(rule.aliases ?? [])].join(" "),
159
+ scope: rule.scope ?? null,
160
+ professions: rule.applies_to?.profession ?? null,
161
+ expertise: rule.expertise,
162
+ from: rule.effective_from,
163
+ to: rule.effective_to,
164
+ source: {
165
+ id: rule.source.id,
166
+ ...(rule.source.section !== undefined && { section: rule.source.section }),
167
+ },
168
+ };
169
+ }
170
+
171
+ /** A source becomes one unit per heading; a source with no heading becomes one unit. */
172
+ function sectionUnits(source: Source): IndexUnit[] {
173
+ const provenance = {
174
+ publisher: source.publisher,
175
+ title: source.title,
176
+ ...(source.version !== undefined && { version: source.version }),
177
+ retrieved_at: source.retrieved_at,
178
+ };
179
+ const common = {
180
+ kind: "source" as const,
181
+ ref: source.id,
182
+ scope: source.scope ?? null,
183
+ professions: null,
184
+ expertise: source.expertise,
185
+ from: source.effective_from,
186
+ to: source.effective_to,
187
+ provenance,
188
+ };
189
+ const sections = split(source.body);
190
+ if (sections.length === 0) {
191
+ return [{ ...common, key: `source:${source.id}#`, heading: source.title, text: source.body }];
192
+ }
193
+ // Two sections of one document may carry the same heading. A key names one unit, so the
194
+ // second one of a name says which it is.
195
+ const seen = new Map<string, number>();
196
+ return sections.map((section) => {
197
+ const nth = (seen.get(section.heading) ?? 0) + 1;
198
+ seen.set(section.heading, nth);
199
+ return {
200
+ ...common,
201
+ key: `source:${source.id}#${section.heading}${nth === 1 ? "" : ` (${nth})`}`,
202
+ heading: section.heading,
203
+ text: section.text,
204
+ };
205
+ });
206
+ }
207
+
208
+ /** The body cut at its markdown headings. Text before the first heading joins the first section. */
209
+ function split(body: string): { heading: string; text: string }[] {
210
+ const lines = body.split("\n");
211
+ const sections: { heading: string; text: string[] }[] = [];
212
+ for (const line of lines) {
213
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
214
+ if (heading) sections.push({ heading: heading[2] as string, text: [] });
215
+ else sections.at(-1)?.text.push(line);
216
+ }
217
+ return sections.map((section) => ({
218
+ heading: section.heading,
219
+ text: section.text.join("\n").trim(),
220
+ }));
221
+ }
222
+
223
+ /** The same bytes for the same index: keys in a fixed order, two spaces, a newline at the end. */
224
+ export function serializeIndex(index: KnowledgeIndex): string {
225
+ const units = index.units.map((unit) => ordered(unit as unknown as Record<string, unknown>));
226
+ const body = {
227
+ format: index.format,
228
+ postings: index.postings,
229
+ sizes: index.sizes,
230
+ units,
231
+ };
232
+ return `${JSON.stringify(body, null, 2)}\n`;
233
+ }
234
+
235
+ /** An object with its keys in code point order, all the way down. */
236
+ function ordered(value: Record<string, unknown>): Record<string, unknown> {
237
+ const out: Record<string, unknown> = {};
238
+ for (const key of Object.keys(value).sort()) {
239
+ const inner = value[key];
240
+ out[key] =
241
+ inner && typeof inner === "object" && !Array.isArray(inner)
242
+ ? ordered(inner as Record<string, unknown>)
243
+ : inner;
244
+ }
245
+ return out;
246
+ }
247
+
248
+ const sha256 = (text: string) => createHash("sha256").update(text).digest("hex");
249
+
250
+ /**
251
+ * The hash of what a person wrote, read from the files themselves rather than from what was
252
+ * parsed out of them. The runtime recomputes this before it trusts an index.
253
+ */
254
+ export async function hashKnowledgeInput(workspaceRoot: string): Promise<string> {
255
+ const dir = join(workspaceRoot, KNOWLEDGE_DIR_NAME);
256
+ const parts: string[] = [];
257
+ for (const [sub, extension] of [
258
+ ["rules", ".yaml"],
259
+ ["sources", ".md"],
260
+ ] as const) {
261
+ let names: string[];
262
+ try {
263
+ names = (await readdir(join(dir, sub))).filter((n) => n.endsWith(extension)).sort();
264
+ } catch {
265
+ continue;
266
+ }
267
+ for (const name of names) {
268
+ // The same guarded read the checks use, so hashing and checking see one set of files: a
269
+ // file too large to read, or a link that leads out, is refused here as it is there.
270
+ const text = await readWorkspaceTextIfAny(dir, join(sub, name));
271
+ parts.push(`${sub}/${name}\n${text === undefined ? "unreadable" : sha256(text)}`);
272
+ }
273
+ }
274
+ return sha256(parts.join("\n"));
275
+ }
276
+
277
+ /**
278
+ * Writes the index and then the manifest, each through a temporary file. The manifest lands last
279
+ * and is the mark that the index beside it is whole: a reader that finds a manifest finds an
280
+ * index that was fully written.
281
+ */
282
+ export async function writeIndex(
283
+ workspaceRoot: string,
284
+ index: KnowledgeIndex,
285
+ input: { hash: string; rules: number; sources: number },
286
+ now: Date = new Date(),
287
+ ): Promise<Manifest> {
288
+ const serialized = serializeIndex(index);
289
+ const manifest: Manifest = {
290
+ format: INDEX_FORMAT_VERSION,
291
+ input_sha256: input.hash,
292
+ index_sha256: sha256(serialized),
293
+ units: index.units.length,
294
+ rules: input.rules,
295
+ sources: input.sources,
296
+ built_at: now.toISOString(),
297
+ };
298
+ await writeKnowledgeFile(workspaceRoot, [BUILD_DIR, INDEX_FILE], serialized);
299
+ await writeKnowledgeFile(
300
+ workspaceRoot,
301
+ [BUILD_DIR, MANIFEST_FILE],
302
+ `${JSON.stringify(manifest, null, 2)}\n`,
303
+ );
304
+ return manifest;
305
+ }
306
+
307
+ /** What the runtime reads before it serves knowledge, or a reason not to serve any. */
308
+ export type IndexState =
309
+ | { ok: true; index: KnowledgeIndex; manifest: Manifest }
310
+ | { ok: false; reason: string };
311
+
312
+ /**
313
+ * Reads the index only if it is the one the manifest describes and the manifest describes the
314
+ * files that are there now. An index rewritten on its own, a manifest rewritten on its own, and
315
+ * an index built by another version of the runtime all come back as a reason not to serve it.
316
+ */
317
+ export async function readIndex(workspaceRoot: string): Promise<IndexState> {
318
+ const stale = {
319
+ ok: false as const,
320
+ reason: "the index does not match the files it was built from; run `openshain knowledge build`",
321
+ };
322
+ let manifest: Manifest;
323
+ let serialized: string;
324
+ try {
325
+ // Read nothing before knowing its size: these two files are as writable as any other in the
326
+ // folder, and an index of a company's knowledge is far below this.
327
+ const manifestText = await readKnowledgeFile(workspaceRoot, [BUILD_DIR, MANIFEST_FILE]);
328
+ const indexText = await readKnowledgeFile(workspaceRoot, [BUILD_DIR, INDEX_FILE]);
329
+ if (manifestText === undefined || indexText === undefined) throw new Error("no index");
330
+ manifest = JSON.parse(manifestText) as Manifest;
331
+ serialized = indexText;
332
+ } catch {
333
+ return { ok: false, reason: "there is no index; run `openshain knowledge build`" };
334
+ }
335
+ if (manifest.format !== INDEX_FORMAT_VERSION) {
336
+ return {
337
+ ok: false,
338
+ reason:
339
+ "the index was built by another version of openshain; run `openshain knowledge build`",
340
+ };
341
+ }
342
+ if (sha256(serialized) !== manifest.index_sha256) return stale;
343
+ if ((await hashKnowledgeInput(workspaceRoot)) !== manifest.input_sha256) return stale;
344
+ try {
345
+ return { ok: true, index: JSON.parse(serialized) as KnowledgeIndex, manifest };
346
+ } catch {
347
+ return stale;
348
+ }
349
+ }