@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.
- package/dist/config/schema.d.ts +8 -0
- package/dist/config/schema.js +29 -1
- package/dist/errors.d.ts +6 -1
- package/dist/errors.js +9 -0
- package/dist/index.d.ts +10 -3
- package/dist/index.js +9 -2
- package/dist/knowledge/build.d.ts +102 -0
- package/dist/knowledge/build.js +279 -0
- package/dist/knowledge/check.d.ts +30 -0
- package/dist/knowledge/check.js +262 -0
- package/dist/knowledge/schema.d.ts +93 -0
- package/dist/knowledge/schema.js +76 -0
- package/dist/knowledge/search.d.ts +26 -0
- package/dist/knowledge/search.js +54 -0
- package/dist/knowledge/store.d.ts +10 -0
- package/dist/knowledge/store.js +69 -0
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.js +5 -7
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.js +3 -0
- package/dist/time.d.ts +14 -0
- package/dist/time.js +50 -0
- package/dist/tool/files.d.ts +21 -0
- package/dist/tool/files.js +69 -0
- package/dist/tool/paths.d.ts +1 -1
- package/dist/tool/paths.js +9 -1
- package/dist/tool/types.d.ts +13 -5
- package/dist/work/events.d.ts +23 -3
- package/dist/work/events.js +30 -3
- package/dist/work/projection.d.ts +9 -0
- package/dist/work/projection.js +54 -2
- package/package.json +1 -1
- package/src/config/schema.ts +42 -3
- package/src/errors.ts +12 -0
- package/src/index.ts +63 -2
- package/src/knowledge/build.ts +349 -0
- package/src/knowledge/check.ts +314 -0
- package/src/knowledge/schema.ts +105 -0
- package/src/knowledge/search.ts +74 -0
- package/src/knowledge/store.ts +82 -0
- package/src/runtime.ts +8 -8
- package/src/schemas.ts +14 -1
- package/src/time.ts +54 -0
- package/src/tool/files.ts +79 -0
- package/src/tool/paths.ts +9 -1
- package/src/tool/types.ts +14 -2
- package/src/work/events.ts +39 -4
- package/src/work/projection.ts +57 -2
package/dist/config/schema.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export declare const ConfigFileSchema: z.ZodObject<{
|
|
|
11
11
|
en: "en";
|
|
12
12
|
ja: "ja";
|
|
13
13
|
}>>;
|
|
14
|
+
timezone: z.ZodOptional<z.ZodString>;
|
|
14
15
|
}, z.core.$strict>;
|
|
15
16
|
principal: z.ZodObject<{
|
|
16
17
|
id: z.ZodString;
|
|
@@ -26,6 +27,7 @@ export declare const ConfigFileSchema: z.ZodObject<{
|
|
|
26
27
|
api_key_env: z.ZodString;
|
|
27
28
|
base_url: z.ZodOptional<z.ZodURL>;
|
|
28
29
|
options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
30
|
+
context_tokens: z.ZodOptional<z.ZodInt>;
|
|
29
31
|
}, z.core.$strict>>;
|
|
30
32
|
tools: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
31
33
|
provider: z.ZodOptional<z.ZodString>;
|
|
@@ -36,6 +38,7 @@ export declare const ConfigFileSchema: z.ZodObject<{
|
|
|
36
38
|
max_model_calls: z.ZodDefault<z.ZodInt>;
|
|
37
39
|
max_tool_calls: z.ZodDefault<z.ZodInt>;
|
|
38
40
|
max_output_tokens: z.ZodDefault<z.ZodInt>;
|
|
41
|
+
compact_at_input_tokens: z.ZodOptional<z.ZodInt>;
|
|
39
42
|
}, z.core.$strict>>;
|
|
40
43
|
debug: z.ZodPrefault<z.ZodObject<{
|
|
41
44
|
persist_raw: z.ZodDefault<z.ZodBoolean>;
|
|
@@ -57,12 +60,15 @@ export interface ModelConfig {
|
|
|
57
60
|
apiKeyEnv: string;
|
|
58
61
|
baseUrl: string | undefined;
|
|
59
62
|
options: Record<string, unknown> | undefined;
|
|
63
|
+
/** How much this model takes as input, when the person wrote it. */
|
|
64
|
+
contextTokens: number | undefined;
|
|
60
65
|
}
|
|
61
66
|
export interface Config {
|
|
62
67
|
version: 1;
|
|
63
68
|
company: {
|
|
64
69
|
name: string;
|
|
65
70
|
language: Language;
|
|
71
|
+
timezone: string;
|
|
66
72
|
};
|
|
67
73
|
principal: {
|
|
68
74
|
id: string;
|
|
@@ -79,6 +85,8 @@ export interface Config {
|
|
|
79
85
|
maxModelCalls: number;
|
|
80
86
|
maxToolCalls: number;
|
|
81
87
|
maxOutputTokens: number;
|
|
88
|
+
/** Where the conversation is summarized, when the person wrote it. 0 never summarizes. */
|
|
89
|
+
compactAtInputTokens: number | undefined;
|
|
82
90
|
};
|
|
83
91
|
debug: {
|
|
84
92
|
persistRaw: boolean;
|
package/dist/config/schema.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { hostTimezone, isTimezone } from "../time.js";
|
|
2
3
|
const identifier = z
|
|
3
4
|
.string()
|
|
4
5
|
.regex(/^[a-z][a-z0-9_-]*$/, "use lowercase letters, digits, _ or -, starting with a letter");
|
|
@@ -35,6 +36,16 @@ export const ConfigFileSchema = z.strictObject({
|
|
|
35
36
|
company: z.strictObject({
|
|
36
37
|
name: z.string().min(1).max(200),
|
|
37
38
|
language: z.enum(LANGUAGES).default("ja"),
|
|
39
|
+
// The company's own clock decides every business date, so a workspace answers the same
|
|
40
|
+
// whether it runs on a laptop in Tokyo or in a container set to UTC.
|
|
41
|
+
// No default in the schema: it would bake the machine that generated it into the published
|
|
42
|
+
// JSON Schema. The fallback is applied when the file is turned into a Config.
|
|
43
|
+
timezone: z
|
|
44
|
+
.string()
|
|
45
|
+
.min(1)
|
|
46
|
+
.max(100)
|
|
47
|
+
.refine(isTimezone, "not a timezone name, such as Asia/Tokyo")
|
|
48
|
+
.optional(),
|
|
38
49
|
}),
|
|
39
50
|
principal: z.strictObject({ id: identifier, name: z.string().min(1).max(200) }),
|
|
40
51
|
profession: z.strictObject({ id: identifier, instructions: z.string().min(1).max(100_000) }),
|
|
@@ -55,6 +66,9 @@ export const ConfigFileSchema = z.strictObject({
|
|
|
55
66
|
}, "base_url must use https unless it points at this machine (localhost, 127.0.0.0/8, ::1)")
|
|
56
67
|
.optional(),
|
|
57
68
|
options: z.record(z.string(), z.unknown()).optional(),
|
|
69
|
+
// How much this model takes as input. Written by the person: openshain does not guess a
|
|
70
|
+
// length from a model's name, and an OpenAI-compatible endpoint may serve anything.
|
|
71
|
+
context_tokens: z.int().positive().optional(),
|
|
58
72
|
})
|
|
59
73
|
.optional(),
|
|
60
74
|
tools: z.array(toolProviderRef).default([{ provider: "standard" }]),
|
|
@@ -63,6 +77,14 @@ export const ConfigFileSchema = z.strictObject({
|
|
|
63
77
|
max_model_calls: z.int().positive().default(30),
|
|
64
78
|
max_tool_calls: z.int().positive().default(100),
|
|
65
79
|
max_output_tokens: z.int().positive().default(16000),
|
|
80
|
+
// What the conversation may reach before it is summarized. Written when the default does
|
|
81
|
+
// not suit the model; 0 turns compaction off. Below 50000 a conversation is summarized so
|
|
82
|
+
// often that it loses more than it saves.
|
|
83
|
+
compact_at_input_tokens: z
|
|
84
|
+
.int()
|
|
85
|
+
.nonnegative()
|
|
86
|
+
.refine((value) => value === 0 || value >= 50_000, "write 0 to never compact, or at least 50000")
|
|
87
|
+
.optional(),
|
|
66
88
|
})
|
|
67
89
|
.prefault({}),
|
|
68
90
|
debug: z.strictObject({ persist_raw: z.boolean().default(false) }).prefault({}),
|
|
@@ -70,7 +92,11 @@ export const ConfigFileSchema = z.strictObject({
|
|
|
70
92
|
export function toConfig(file) {
|
|
71
93
|
return {
|
|
72
94
|
version: file.version,
|
|
73
|
-
company: {
|
|
95
|
+
company: {
|
|
96
|
+
name: file.company.name,
|
|
97
|
+
language: file.company.language,
|
|
98
|
+
timezone: file.company.timezone ?? hostTimezone(),
|
|
99
|
+
},
|
|
74
100
|
principal: { id: file.principal.id, name: file.principal.name },
|
|
75
101
|
profession: { id: file.profession.id, instructions: file.profession.instructions },
|
|
76
102
|
...(file.model && {
|
|
@@ -80,6 +106,7 @@ export function toConfig(file) {
|
|
|
80
106
|
apiKeyEnv: file.model.api_key_env,
|
|
81
107
|
baseUrl: file.model.base_url,
|
|
82
108
|
options: file.model.options,
|
|
109
|
+
contextTokens: file.model.context_tokens,
|
|
83
110
|
},
|
|
84
111
|
}),
|
|
85
112
|
tools: file.tools.map(toToolProviderRef),
|
|
@@ -87,6 +114,7 @@ export function toConfig(file) {
|
|
|
87
114
|
maxModelCalls: file.limits.max_model_calls,
|
|
88
115
|
maxToolCalls: file.limits.max_tool_calls,
|
|
89
116
|
maxOutputTokens: file.limits.max_output_tokens,
|
|
117
|
+
compactAtInputTokens: file.limits.compact_at_input_tokens,
|
|
90
118
|
},
|
|
91
119
|
debug: { persistRaw: file.debug.persist_raw },
|
|
92
120
|
};
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const ERROR_CODES: readonly ["auth", "network", "rate_limit", "invalid_response", "config", "corrupt_log", "invalid_transition", "duplicate_tool", "invalid_id", "invalid_tool", "invalid_path", "lock_held", "not_found", "reserved_path", "outside_workspace", "concurrent_write", "invalid_event"];
|
|
1
|
+
export declare const ERROR_CODES: readonly ["auth", "network", "rate_limit", "too_large", "invalid_response", "config", "corrupt_log", "invalid_transition", "duplicate_tool", "invalid_id", "invalid_tool", "invalid_path", "lock_held", "not_found", "reserved_path", "outside_workspace", "concurrent_write", "invalid_event"];
|
|
2
2
|
export type ErrorCode = (typeof ERROR_CODES)[number];
|
|
3
3
|
export declare class OpenshainError extends Error {
|
|
4
4
|
readonly name = "OpenshainError";
|
|
@@ -8,3 +8,8 @@ export declare class OpenshainError extends Error {
|
|
|
8
8
|
});
|
|
9
9
|
}
|
|
10
10
|
export declare function isOpenshainError(value: unknown): value is OpenshainError;
|
|
11
|
+
/**
|
|
12
|
+
* A request the model refused for its size. Both APIs answer 400 for it, and the only thing that
|
|
13
|
+
* separates it from a wrong setting is what the message says, so the words are matched loosely.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isTooLarge(message: string): boolean;
|
package/dist/errors.js
CHANGED
|
@@ -2,6 +2,8 @@ export const ERROR_CODES = [
|
|
|
2
2
|
"auth",
|
|
3
3
|
"network",
|
|
4
4
|
"rate_limit",
|
|
5
|
+
// The request did not fit the model: the conversation has to get shorter before it can run.
|
|
6
|
+
"too_large",
|
|
5
7
|
"invalid_response",
|
|
6
8
|
"config",
|
|
7
9
|
"corrupt_log",
|
|
@@ -28,3 +30,10 @@ export class OpenshainError extends Error {
|
|
|
28
30
|
export function isOpenshainError(value) {
|
|
29
31
|
return value instanceof OpenshainError;
|
|
30
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* A request the model refused for its size. Both APIs answer 400 for it, and the only thing that
|
|
35
|
+
* separates it from a wrong setting is what the message says, so the words are matched loosely.
|
|
36
|
+
*/
|
|
37
|
+
export function isTooLarge(message) {
|
|
38
|
+
return /too long|too large|context[ _-]?length|maximum context|context window|reduce the length/i.test(message);
|
|
39
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,16 +2,23 @@ export { AUTHORITY_DIR_NAME, type Authority, type AuthorityRequest, DECISION_KIN
|
|
|
2
2
|
export { CONFIG_FILE_NAME, loadConfig, type ParseConfigOptions, parseConfig, } from "./config/load.ts";
|
|
3
3
|
export type { Config, ModelConfig, ToolProviderRef } from "./config/schema.ts";
|
|
4
4
|
export { LANGUAGES, type Language } from "./config/schema.ts";
|
|
5
|
-
export { ERROR_CODES, type ErrorCode, isOpenshainError, OpenshainError } from "./errors.ts";
|
|
5
|
+
export { ERROR_CODES, type ErrorCode, isOpenshainError, isTooLarge, OpenshainError, } from "./errors.ts";
|
|
6
6
|
export { type EventId, newEventId, newWorkId, parseEventId, parseWorkId, type WorkId, } from "./ids.ts";
|
|
7
|
+
export { buildIndex, hashKnowledgeInput, INDEX_FORMAT_VERSION, type IndexState, type IndexUnit, type KnowledgeIndex, type Manifest, readIndex, serializeIndex, writeIndex, } from "./knowledge/build.ts";
|
|
8
|
+
export { type Checked, checkKnowledge, hasKnowledge, KNOWLEDGE_DIR_NAME, } from "./knowledge/check.ts";
|
|
9
|
+
export { type LoadedRule as LoadedKnowledgeRule, type Rule as KnowledgeRule, RuleSchema as KnowledgeRuleSchema, RulesFileSchema, type Scope as KnowledgeScope, ScopeSchema as KnowledgeScopeSchema, type Source as KnowledgeSource, type SourceFrontMatter, SourceFrontMatterSchema, } from "./knowledge/schema.ts";
|
|
10
|
+
export { type Hit, inEffect, MIN_QUERY_LENGTH, type SearchOptions, search, } from "./knowledge/search.ts";
|
|
11
|
+
export { knowledgePath, readKnowledgeFile, writeKnowledgeFile, } from "./knowledge/store.ts";
|
|
7
12
|
export type { ModelDescription, ModelMessage, ModelProvider, ModelRequest, ModelResponse, UserPart, } from "./model/types.ts";
|
|
8
13
|
export { type CallOptions, type CreateRuntimeOptions, createRuntime, createToolCaller, createToolRegistry, MAX_TOOL_TEXT_CHARS, type PendingApprovalResult, REVIEW_DIR_NAME, type Runtime, type RuntimeProviders, type ToolSummary, } from "./runtime.ts";
|
|
9
14
|
export { jsonSchemas, type SchemaName } from "./schemas.ts";
|
|
15
|
+
export { businessDate, companyTime, hostTimezone, isTimezone } from "./time.ts";
|
|
10
16
|
export { ASK_USER, RUNTIME_PROVIDER_ID } from "./tool/ask-user.ts";
|
|
17
|
+
export { MAX_READ_BYTES, MAX_WRITE_BYTES, readWorkspaceText, readWorkspaceTextIfAny, writeWorkspaceText, } from "./tool/files.ts";
|
|
11
18
|
export { loadToolModule } from "./tool/load-module.ts";
|
|
12
19
|
export { RESERVED_PATHS, resolveWorkspacePath } from "./tool/paths.ts";
|
|
13
20
|
export { type HiddenTool, type RegisteredTool, type RegisterOptions, ToolRegistry, } from "./tool/registry.ts";
|
|
14
|
-
export { ASK_USER_TOOL_NAME, type JsonSchema, RESERVED_TOOL_NAMES, TOOL_NAME_PATTERN, type ToolCall, type ToolContext, type ToolDefinition, type ToolEffect, type ToolProvider, type ToolResult, } from "./tool/types.ts";
|
|
21
|
+
export { ASK_USER_TOOL_NAME, type JsonSchema, type Observation, RESERVED_TOOL_NAMES, TOOL_NAME_PATTERN, type ToolCall, type ToolContext, type ToolDefinition, type ToolEffect, type ToolProvider, type ToolResult, } from "./tool/types.ts";
|
|
15
22
|
export { compileInputValidator, type InputValidation } from "./tool/validate.ts";
|
|
16
23
|
export { uuidv7 } from "./uuid.ts";
|
|
17
24
|
export { verifyArtifact } from "./work/artifacts.ts";
|
|
@@ -19,6 +26,6 @@ export { EVENTS_FILE_NAME, EventLog, type NewEvent } from "./work/event-log.ts";
|
|
|
19
26
|
export { type AnyEvent, type Artifact, type AssistantPart, canonical, type Event, type EventFile, EventFileSchema, type EventPayloads, type EventType, eventFromFile, eventToFile, isKnownEventType, type ModelUsage, parsePayloadFile, payloadFileSchemas, type ReviewPackage, type StopReason, TOOL_REJECTION_CODES, type ToolContent, type ToolRejectionCode, type UnknownEvent, } from "./work/events.ts";
|
|
20
27
|
export { countToolCalls, type FailureReason, type HistoryCall, type PendingApproval, type PendingQuestion, pendingApprovals, pendingQuestions, type WorkHistory, workHistory, } from "./work/history.ts";
|
|
21
28
|
export { acquireLock, LOCK_FILE_NAME, type Lock } from "./work/lock.ts";
|
|
22
|
-
export { buildProjection, type Projection, type ProjectionInput } from "./work/projection.ts";
|
|
29
|
+
export { buildProjection, type Projection, type ProjectionInput, RECENT_MESSAGES, } from "./work/projection.ts";
|
|
23
30
|
export { type CreateWorkInput, type ListResult, WORK_DIR_NAME, WORK_FILE_NAME, type WorkHandle, WorkStore, } from "./work/store.ts";
|
|
24
31
|
export { isTerminal, reduceWork, SESSION_WORK_TYPE, transition, WORK_STATUSES, type Work, type WorkFile, WorkFileSchema, WorkStatus, workToFile, } from "./work/work.ts";
|
package/dist/index.js
CHANGED
|
@@ -2,11 +2,18 @@
|
|
|
2
2
|
export { AUTHORITY_DIR_NAME, DECISION_KINDS, DECISIONS_DIR_NAME, DELEGATIONS_FILE_NAME, DecisionFileSchema, DelegationsFileSchema, evaluate, loadAuthority, matchGlob, OPEN_AUTHORITY, POLICY_FILE_NAME, PolicyFileSchema, writeDecision, } from "./authority/policy.js";
|
|
3
3
|
export { CONFIG_FILE_NAME, loadConfig, parseConfig, } from "./config/load.js";
|
|
4
4
|
export { LANGUAGES } from "./config/schema.js";
|
|
5
|
-
export { ERROR_CODES, isOpenshainError, OpenshainError } from "./errors.js";
|
|
5
|
+
export { ERROR_CODES, isOpenshainError, isTooLarge, OpenshainError, } from "./errors.js";
|
|
6
6
|
export { newEventId, newWorkId, parseEventId, parseWorkId, } from "./ids.js";
|
|
7
|
+
export { buildIndex, hashKnowledgeInput, INDEX_FORMAT_VERSION, readIndex, serializeIndex, writeIndex, } from "./knowledge/build.js";
|
|
8
|
+
export { checkKnowledge, hasKnowledge, KNOWLEDGE_DIR_NAME, } from "./knowledge/check.js";
|
|
9
|
+
export { RuleSchema as KnowledgeRuleSchema, RulesFileSchema, ScopeSchema as KnowledgeScopeSchema, SourceFrontMatterSchema, } from "./knowledge/schema.js";
|
|
10
|
+
export { inEffect, MIN_QUERY_LENGTH, search, } from "./knowledge/search.js";
|
|
11
|
+
export { knowledgePath, readKnowledgeFile, writeKnowledgeFile, } from "./knowledge/store.js";
|
|
7
12
|
export { createRuntime, createToolCaller, createToolRegistry, MAX_TOOL_TEXT_CHARS, REVIEW_DIR_NAME, } from "./runtime.js";
|
|
8
13
|
export { jsonSchemas } from "./schemas.js";
|
|
14
|
+
export { businessDate, companyTime, hostTimezone, isTimezone } from "./time.js";
|
|
9
15
|
export { ASK_USER, RUNTIME_PROVIDER_ID } from "./tool/ask-user.js";
|
|
16
|
+
export { MAX_READ_BYTES, MAX_WRITE_BYTES, readWorkspaceText, readWorkspaceTextIfAny, writeWorkspaceText, } from "./tool/files.js";
|
|
10
17
|
export { loadToolModule } from "./tool/load-module.js";
|
|
11
18
|
export { RESERVED_PATHS, resolveWorkspacePath } from "./tool/paths.js";
|
|
12
19
|
export { ToolRegistry, } from "./tool/registry.js";
|
|
@@ -18,6 +25,6 @@ export { EVENTS_FILE_NAME, EventLog } from "./work/event-log.js";
|
|
|
18
25
|
export { canonical, EventFileSchema, eventFromFile, eventToFile, isKnownEventType, parsePayloadFile, payloadFileSchemas, TOOL_REJECTION_CODES, } from "./work/events.js";
|
|
19
26
|
export { countToolCalls, pendingApprovals, pendingQuestions, workHistory, } from "./work/history.js";
|
|
20
27
|
export { acquireLock, LOCK_FILE_NAME } from "./work/lock.js";
|
|
21
|
-
export { buildProjection } from "./work/projection.js";
|
|
28
|
+
export { buildProjection, RECENT_MESSAGES, } from "./work/projection.js";
|
|
22
29
|
export { WORK_DIR_NAME, WORK_FILE_NAME, WorkStore, } from "./work/store.js";
|
|
23
30
|
export { isTerminal, reduceWork, SESSION_WORK_TYPE, transition, WORK_STATUSES, WorkFileSchema, WorkStatus, workToFile, } from "./work/work.js";
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Checked } from "./check.ts";
|
|
2
|
+
import type { Scope } from "./schema.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Turning what a person wrote into the index the runtime serves. The index is a build artifact:
|
|
5
|
+
* the same input always makes the same bytes, and the manifest says which input it came from and
|
|
6
|
+
* what the index itself hashes to, so a rewritten index is not served.
|
|
7
|
+
*/
|
|
8
|
+
/** Raised when the index is read by a runtime that indexes differently than the one that wrote it. */
|
|
9
|
+
export declare const INDEX_FORMAT_VERSION = 1;
|
|
10
|
+
/** One thing the search can return: a rule, or one section of a source. */
|
|
11
|
+
export interface IndexUnit {
|
|
12
|
+
/** `rule:<id>` or `source:<id>#<heading>`; unique in the index. */
|
|
13
|
+
key: string;
|
|
14
|
+
kind: "rule" | "source";
|
|
15
|
+
/** The id a person wrote, which citations name. */
|
|
16
|
+
ref: string;
|
|
17
|
+
heading: string;
|
|
18
|
+
/** What the unit says: the statement of a rule, or the text of a section. */
|
|
19
|
+
text: string;
|
|
20
|
+
scope: Scope | null;
|
|
21
|
+
/** The professions a rule is for, or null for every profession. A source is for all of them. */
|
|
22
|
+
professions: string[] | null;
|
|
23
|
+
expertise: string;
|
|
24
|
+
from: string;
|
|
25
|
+
to: string | null;
|
|
26
|
+
/** For a rule, the source it cites. */
|
|
27
|
+
source?: {
|
|
28
|
+
id: string;
|
|
29
|
+
section?: string;
|
|
30
|
+
};
|
|
31
|
+
/** For a source, where it came from. */
|
|
32
|
+
provenance?: {
|
|
33
|
+
publisher: string;
|
|
34
|
+
title: string;
|
|
35
|
+
version?: string;
|
|
36
|
+
retrieved_at: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export interface KnowledgeIndex {
|
|
40
|
+
format: number;
|
|
41
|
+
units: IndexUnit[];
|
|
42
|
+
/**
|
|
43
|
+
* The groups of characters to the units that contain them. Two-character groups answer a
|
|
44
|
+
* question too short to have three.
|
|
45
|
+
*/
|
|
46
|
+
postings: {
|
|
47
|
+
pairs: Record<string, number[]>;
|
|
48
|
+
triples: Record<string, number[]>;
|
|
49
|
+
};
|
|
50
|
+
/** How many distinct three-character grams each unit has, for the length correction. */
|
|
51
|
+
sizes: number[];
|
|
52
|
+
}
|
|
53
|
+
export interface Manifest {
|
|
54
|
+
format: number;
|
|
55
|
+
input_sha256: string;
|
|
56
|
+
index_sha256: string;
|
|
57
|
+
units: number;
|
|
58
|
+
rules: number;
|
|
59
|
+
sources: number;
|
|
60
|
+
built_at: string;
|
|
61
|
+
}
|
|
62
|
+
/** Text as the index compares it: full width and half width alike, one case, no spaces. */
|
|
63
|
+
export declare function normalize(text: string): string;
|
|
64
|
+
/** The distinct groups of `n` characters in the text. */
|
|
65
|
+
export declare function grams(text: string, n: number): Set<string>;
|
|
66
|
+
/**
|
|
67
|
+
* The index of a checked set. A rule that another rule replaces is closed the day before the
|
|
68
|
+
* newer one starts, so the two are never in effect together.
|
|
69
|
+
*/
|
|
70
|
+
export declare function buildIndex(checked: Checked): KnowledgeIndex;
|
|
71
|
+
/** The same bytes for the same index: keys in a fixed order, two spaces, a newline at the end. */
|
|
72
|
+
export declare function serializeIndex(index: KnowledgeIndex): string;
|
|
73
|
+
/**
|
|
74
|
+
* The hash of what a person wrote, read from the files themselves rather than from what was
|
|
75
|
+
* parsed out of them. The runtime recomputes this before it trusts an index.
|
|
76
|
+
*/
|
|
77
|
+
export declare function hashKnowledgeInput(workspaceRoot: string): Promise<string>;
|
|
78
|
+
/**
|
|
79
|
+
* Writes the index and then the manifest, each through a temporary file. The manifest lands last
|
|
80
|
+
* and is the mark that the index beside it is whole: a reader that finds a manifest finds an
|
|
81
|
+
* index that was fully written.
|
|
82
|
+
*/
|
|
83
|
+
export declare function writeIndex(workspaceRoot: string, index: KnowledgeIndex, input: {
|
|
84
|
+
hash: string;
|
|
85
|
+
rules: number;
|
|
86
|
+
sources: number;
|
|
87
|
+
}, now?: Date): Promise<Manifest>;
|
|
88
|
+
/** What the runtime reads before it serves knowledge, or a reason not to serve any. */
|
|
89
|
+
export type IndexState = {
|
|
90
|
+
ok: true;
|
|
91
|
+
index: KnowledgeIndex;
|
|
92
|
+
manifest: Manifest;
|
|
93
|
+
} | {
|
|
94
|
+
ok: false;
|
|
95
|
+
reason: string;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Reads the index only if it is the one the manifest describes and the manifest describes the
|
|
99
|
+
* files that are there now. An index rewritten on its own, a manifest rewritten on its own, and
|
|
100
|
+
* an index built by another version of the runtime all come back as a reason not to serve it.
|
|
101
|
+
*/
|
|
102
|
+
export declare function readIndex(workspaceRoot: string): Promise<IndexState>;
|
|
@@ -0,0 +1,279 @@
|
|
|
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.js";
|
|
5
|
+
import { KNOWLEDGE_DIR_NAME } from "./check.js";
|
|
6
|
+
import { readKnowledgeFile, writeKnowledgeFile } from "./store.js";
|
|
7
|
+
/**
|
|
8
|
+
* Turning what a person wrote into the index the runtime serves. The index is a build artifact:
|
|
9
|
+
* the same input always makes the same bytes, and the manifest says which input it came from and
|
|
10
|
+
* what the index itself hashes to, so a rewritten index is not served.
|
|
11
|
+
*/
|
|
12
|
+
/** Raised when the index is read by a runtime that indexes differently than the one that wrote it. */
|
|
13
|
+
export const INDEX_FORMAT_VERSION = 1;
|
|
14
|
+
const BUILD_DIR = "build";
|
|
15
|
+
const INDEX_FILE = "index.json";
|
|
16
|
+
const MANIFEST_FILE = "manifest.json";
|
|
17
|
+
/** Text as the index compares it: full width and half width alike, one case, no spaces. */
|
|
18
|
+
export function normalize(text) {
|
|
19
|
+
return text.normalize("NFKC").toLowerCase().replace(/\s+/gu, "");
|
|
20
|
+
}
|
|
21
|
+
/** The distinct groups of `n` characters in the text. */
|
|
22
|
+
export function grams(text, n) {
|
|
23
|
+
const chars = [...normalize(text)];
|
|
24
|
+
const out = new Set();
|
|
25
|
+
for (let i = 0; i + n <= chars.length; i++)
|
|
26
|
+
out.add(chars.slice(i, i + n).join(""));
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The index of a checked set. A rule that another rule replaces is closed the day before the
|
|
31
|
+
* newer one starts, so the two are never in effect together.
|
|
32
|
+
*/
|
|
33
|
+
export function buildIndex(checked) {
|
|
34
|
+
const closed = closeSuperseded(checked.rules);
|
|
35
|
+
const units = [
|
|
36
|
+
...closed.map(ruleUnit),
|
|
37
|
+
...checked.sources.flatMap((source) => sectionUnits(source)),
|
|
38
|
+
].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
39
|
+
const postings = { pairs: {}, triples: {} };
|
|
40
|
+
const sizes = [];
|
|
41
|
+
for (const [at, unit] of units.entries()) {
|
|
42
|
+
// What a unit is matched on: its own words, and its heading when that is not the words
|
|
43
|
+
// themselves. The id is left out; it would only make a unit look longer than it reads.
|
|
44
|
+
// Aliases are already part of a rule's text, and nothing else bridges words that share no
|
|
45
|
+
// characters with it.
|
|
46
|
+
const matter = unit.heading === unit.text ? unit.text : `${unit.text} ${unit.heading}`;
|
|
47
|
+
add(postings.pairs, grams(matter, 2), at);
|
|
48
|
+
add(postings.triples, grams(matter, 3), at);
|
|
49
|
+
sizes.push(grams(matter, 3).size);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
format: INDEX_FORMAT_VERSION,
|
|
53
|
+
units,
|
|
54
|
+
postings: { pairs: settled(postings.pairs), triples: settled(postings.triples) },
|
|
55
|
+
sizes,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Notes that the unit at `at` holds each of these groups. */
|
|
59
|
+
function add(postings, of, at) {
|
|
60
|
+
for (const gram of of) {
|
|
61
|
+
const units = postings[gram];
|
|
62
|
+
if (units)
|
|
63
|
+
units.push(at);
|
|
64
|
+
else
|
|
65
|
+
postings[gram] = [at];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** The same postings in a fixed order, so that the same input writes the same bytes. */
|
|
69
|
+
function settled(postings) {
|
|
70
|
+
return Object.fromEntries(Object.entries(postings)
|
|
71
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
72
|
+
.map(([gram, units]) => [gram, [...units].sort((x, y) => x - y)]));
|
|
73
|
+
}
|
|
74
|
+
/** A rule replaced by another ends the day before that one begins. */
|
|
75
|
+
function closeSuperseded(rules) {
|
|
76
|
+
const replacedBy = new Map();
|
|
77
|
+
for (const rule of rules)
|
|
78
|
+
if (rule.supersedes)
|
|
79
|
+
replacedBy.set(rule.supersedes, rule);
|
|
80
|
+
return rules.map((rule) => {
|
|
81
|
+
const next = replacedBy.get(rule.id);
|
|
82
|
+
if (!next)
|
|
83
|
+
return rule;
|
|
84
|
+
const end = dayBefore(next.effective_from);
|
|
85
|
+
return {
|
|
86
|
+
...rule,
|
|
87
|
+
effective_to: rule.effective_to === null ? end : minDay(rule.effective_to, end),
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function dayBefore(day) {
|
|
92
|
+
const at = new Date(`${day}T00:00:00Z`);
|
|
93
|
+
at.setUTCDate(at.getUTCDate() - 1);
|
|
94
|
+
return at.toISOString().slice(0, 10);
|
|
95
|
+
}
|
|
96
|
+
const minDay = (a, b) => (a < b ? a : b);
|
|
97
|
+
function ruleUnit(rule) {
|
|
98
|
+
return {
|
|
99
|
+
key: `rule:${rule.id}`,
|
|
100
|
+
kind: "rule",
|
|
101
|
+
ref: rule.id,
|
|
102
|
+
heading: rule.statement,
|
|
103
|
+
text: [rule.statement, ...(rule.aliases ?? [])].join(" "),
|
|
104
|
+
scope: rule.scope ?? null,
|
|
105
|
+
professions: rule.applies_to?.profession ?? null,
|
|
106
|
+
expertise: rule.expertise,
|
|
107
|
+
from: rule.effective_from,
|
|
108
|
+
to: rule.effective_to,
|
|
109
|
+
source: {
|
|
110
|
+
id: rule.source.id,
|
|
111
|
+
...(rule.source.section !== undefined && { section: rule.source.section }),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** A source becomes one unit per heading; a source with no heading becomes one unit. */
|
|
116
|
+
function sectionUnits(source) {
|
|
117
|
+
const provenance = {
|
|
118
|
+
publisher: source.publisher,
|
|
119
|
+
title: source.title,
|
|
120
|
+
...(source.version !== undefined && { version: source.version }),
|
|
121
|
+
retrieved_at: source.retrieved_at,
|
|
122
|
+
};
|
|
123
|
+
const common = {
|
|
124
|
+
kind: "source",
|
|
125
|
+
ref: source.id,
|
|
126
|
+
scope: source.scope ?? null,
|
|
127
|
+
professions: null,
|
|
128
|
+
expertise: source.expertise,
|
|
129
|
+
from: source.effective_from,
|
|
130
|
+
to: source.effective_to,
|
|
131
|
+
provenance,
|
|
132
|
+
};
|
|
133
|
+
const sections = split(source.body);
|
|
134
|
+
if (sections.length === 0) {
|
|
135
|
+
return [{ ...common, key: `source:${source.id}#`, heading: source.title, text: source.body }];
|
|
136
|
+
}
|
|
137
|
+
// Two sections of one document may carry the same heading. A key names one unit, so the
|
|
138
|
+
// second one of a name says which it is.
|
|
139
|
+
const seen = new Map();
|
|
140
|
+
return sections.map((section) => {
|
|
141
|
+
const nth = (seen.get(section.heading) ?? 0) + 1;
|
|
142
|
+
seen.set(section.heading, nth);
|
|
143
|
+
return {
|
|
144
|
+
...common,
|
|
145
|
+
key: `source:${source.id}#${section.heading}${nth === 1 ? "" : ` (${nth})`}`,
|
|
146
|
+
heading: section.heading,
|
|
147
|
+
text: section.text,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/** The body cut at its markdown headings. Text before the first heading joins the first section. */
|
|
152
|
+
function split(body) {
|
|
153
|
+
const lines = body.split("\n");
|
|
154
|
+
const sections = [];
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
157
|
+
if (heading)
|
|
158
|
+
sections.push({ heading: heading[2], text: [] });
|
|
159
|
+
else
|
|
160
|
+
sections.at(-1)?.text.push(line);
|
|
161
|
+
}
|
|
162
|
+
return sections.map((section) => ({
|
|
163
|
+
heading: section.heading,
|
|
164
|
+
text: section.text.join("\n").trim(),
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
/** The same bytes for the same index: keys in a fixed order, two spaces, a newline at the end. */
|
|
168
|
+
export function serializeIndex(index) {
|
|
169
|
+
const units = index.units.map((unit) => ordered(unit));
|
|
170
|
+
const body = {
|
|
171
|
+
format: index.format,
|
|
172
|
+
postings: index.postings,
|
|
173
|
+
sizes: index.sizes,
|
|
174
|
+
units,
|
|
175
|
+
};
|
|
176
|
+
return `${JSON.stringify(body, null, 2)}\n`;
|
|
177
|
+
}
|
|
178
|
+
/** An object with its keys in code point order, all the way down. */
|
|
179
|
+
function ordered(value) {
|
|
180
|
+
const out = {};
|
|
181
|
+
for (const key of Object.keys(value).sort()) {
|
|
182
|
+
const inner = value[key];
|
|
183
|
+
out[key] =
|
|
184
|
+
inner && typeof inner === "object" && !Array.isArray(inner)
|
|
185
|
+
? ordered(inner)
|
|
186
|
+
: inner;
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
191
|
+
/**
|
|
192
|
+
* The hash of what a person wrote, read from the files themselves rather than from what was
|
|
193
|
+
* parsed out of them. The runtime recomputes this before it trusts an index.
|
|
194
|
+
*/
|
|
195
|
+
export async function hashKnowledgeInput(workspaceRoot) {
|
|
196
|
+
const dir = join(workspaceRoot, KNOWLEDGE_DIR_NAME);
|
|
197
|
+
const parts = [];
|
|
198
|
+
for (const [sub, extension] of [
|
|
199
|
+
["rules", ".yaml"],
|
|
200
|
+
["sources", ".md"],
|
|
201
|
+
]) {
|
|
202
|
+
let names;
|
|
203
|
+
try {
|
|
204
|
+
names = (await readdir(join(dir, sub))).filter((n) => n.endsWith(extension)).sort();
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
for (const name of names) {
|
|
210
|
+
// The same guarded read the checks use, so hashing and checking see one set of files: a
|
|
211
|
+
// file too large to read, or a link that leads out, is refused here as it is there.
|
|
212
|
+
const text = await readWorkspaceTextIfAny(dir, join(sub, name));
|
|
213
|
+
parts.push(`${sub}/${name}\n${text === undefined ? "unreadable" : sha256(text)}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return sha256(parts.join("\n"));
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Writes the index and then the manifest, each through a temporary file. The manifest lands last
|
|
220
|
+
* and is the mark that the index beside it is whole: a reader that finds a manifest finds an
|
|
221
|
+
* index that was fully written.
|
|
222
|
+
*/
|
|
223
|
+
export async function writeIndex(workspaceRoot, index, input, now = new Date()) {
|
|
224
|
+
const serialized = serializeIndex(index);
|
|
225
|
+
const manifest = {
|
|
226
|
+
format: INDEX_FORMAT_VERSION,
|
|
227
|
+
input_sha256: input.hash,
|
|
228
|
+
index_sha256: sha256(serialized),
|
|
229
|
+
units: index.units.length,
|
|
230
|
+
rules: input.rules,
|
|
231
|
+
sources: input.sources,
|
|
232
|
+
built_at: now.toISOString(),
|
|
233
|
+
};
|
|
234
|
+
await writeKnowledgeFile(workspaceRoot, [BUILD_DIR, INDEX_FILE], serialized);
|
|
235
|
+
await writeKnowledgeFile(workspaceRoot, [BUILD_DIR, MANIFEST_FILE], `${JSON.stringify(manifest, null, 2)}\n`);
|
|
236
|
+
return manifest;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Reads the index only if it is the one the manifest describes and the manifest describes the
|
|
240
|
+
* files that are there now. An index rewritten on its own, a manifest rewritten on its own, and
|
|
241
|
+
* an index built by another version of the runtime all come back as a reason not to serve it.
|
|
242
|
+
*/
|
|
243
|
+
export async function readIndex(workspaceRoot) {
|
|
244
|
+
const stale = {
|
|
245
|
+
ok: false,
|
|
246
|
+
reason: "the index does not match the files it was built from; run `openshain knowledge build`",
|
|
247
|
+
};
|
|
248
|
+
let manifest;
|
|
249
|
+
let serialized;
|
|
250
|
+
try {
|
|
251
|
+
// Read nothing before knowing its size: these two files are as writable as any other in the
|
|
252
|
+
// folder, and an index of a company's knowledge is far below this.
|
|
253
|
+
const manifestText = await readKnowledgeFile(workspaceRoot, [BUILD_DIR, MANIFEST_FILE]);
|
|
254
|
+
const indexText = await readKnowledgeFile(workspaceRoot, [BUILD_DIR, INDEX_FILE]);
|
|
255
|
+
if (manifestText === undefined || indexText === undefined)
|
|
256
|
+
throw new Error("no index");
|
|
257
|
+
manifest = JSON.parse(manifestText);
|
|
258
|
+
serialized = indexText;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return { ok: false, reason: "there is no index; run `openshain knowledge build`" };
|
|
262
|
+
}
|
|
263
|
+
if (manifest.format !== INDEX_FORMAT_VERSION) {
|
|
264
|
+
return {
|
|
265
|
+
ok: false,
|
|
266
|
+
reason: "the index was built by another version of openshain; run `openshain knowledge build`",
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
if (sha256(serialized) !== manifest.index_sha256)
|
|
270
|
+
return stale;
|
|
271
|
+
if ((await hashKnowledgeInput(workspaceRoot)) !== manifest.input_sha256)
|
|
272
|
+
return stale;
|
|
273
|
+
try {
|
|
274
|
+
return { ok: true, index: JSON.parse(serialized), manifest };
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return stale;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type LoadedRule, type Source } from "./schema.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Reading and checking what a person wrote under `knowledge/`. Every problem is collected, never
|
|
4
|
+
* thrown at the first one: a person fixing a set of files should see all of it in one pass.
|
|
5
|
+
*/
|
|
6
|
+
export declare const KNOWLEDGE_DIR_NAME = "knowledge";
|
|
7
|
+
/** How much a build reads in total, and how many files it reads, whatever is in the folder. */
|
|
8
|
+
export declare const MAX_KNOWLEDGE_FILES = 2000;
|
|
9
|
+
export declare const MAX_KNOWLEDGE_BYTES: number;
|
|
10
|
+
export interface Checked {
|
|
11
|
+
rules: LoadedRule[];
|
|
12
|
+
sources: Source[];
|
|
13
|
+
/** Everything wrong with what was read, each as `file:line:col field: message` or `file: message`. */
|
|
14
|
+
problems: string[];
|
|
15
|
+
}
|
|
16
|
+
/** True when the workspace has a `knowledge/` directory to read at all. */
|
|
17
|
+
export declare function hasKnowledge(workspaceRoot: string): Promise<boolean>;
|
|
18
|
+
/**
|
|
19
|
+
* Reads `knowledge/rules/*.yaml` and `knowledge/sources/*.md` and checks them against each other.
|
|
20
|
+
* The rules that come back are the ones a build would index; when `problems` is not empty, nothing
|
|
21
|
+
* should be written.
|
|
22
|
+
*/
|
|
23
|
+
export declare function checkKnowledge(workspaceRoot: string, options?: {
|
|
24
|
+
/**
|
|
25
|
+
* Rules to check as though they were already written, with the file they would go in. A
|
|
26
|
+
* command that adds one asks this first, so that a rule which does not hold up is never
|
|
27
|
+
* written at all.
|
|
28
|
+
*/
|
|
29
|
+
adding?: LoadedRule[];
|
|
30
|
+
}): Promise<Checked>;
|