@hraness/oh 0.2.3
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/LICENSE +21 -0
- package/README.md +598 -0
- package/dist/canonical.d.ts +32 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +3419 -0
- package/dist/contract.d.ts +33 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/graph.d.ts +67 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1988 -0
- package/dist/libsql.d.ts +57 -0
- package/dist/libsql.d.ts.map +1 -0
- package/dist/libsql.js +2662 -0
- package/dist/memory.d.ts +366 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +3650 -0
- package/dist/ontology.d.ts +242 -0
- package/dist/ontology.d.ts.map +1 -0
- package/dist/operation.d.ts +24 -0
- package/dist/operation.d.ts.map +1 -0
- package/dist/projection-public.d.ts +59 -0
- package/dist/projection-public.d.ts.map +1 -0
- package/dist/projection-public.js +1682 -0
- package/dist/projection-suss.d.ts +17 -0
- package/dist/projection-suss.d.ts.map +1 -0
- package/dist/projection-suss.js +1721 -0
- package/dist/projection.d.ts +315 -0
- package/dist/projection.d.ts.map +1 -0
- package/dist/schema.d.ts +45 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/sdk.d.ts +51 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +3072 -0
- package/dist/search.d.ts +34 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/semantic.d.ts +83 -0
- package/dist/semantic.d.ts.map +1 -0
- package/dist/semantic.js +706 -0
- package/dist/sqlite/driver.d.ts +6 -0
- package/dist/sqlite/driver.d.ts.map +1 -0
- package/dist/sqlite/index.d.ts +5 -0
- package/dist/sqlite/index.d.ts.map +1 -0
- package/dist/sqlite/index.js +2840 -0
- package/dist/sqlite/migrations.d.ts +10 -0
- package/dist/sqlite/migrations.d.ts.map +1 -0
- package/dist/sqlite/port.d.ts +40 -0
- package/dist/sqlite/port.d.ts.map +1 -0
- package/dist/sqlite/runtime.d.ts +23 -0
- package/dist/sqlite/runtime.d.ts.map +1 -0
- package/dist/sqlite/store.d.ts +85 -0
- package/dist/sqlite/store.d.ts.map +1 -0
- package/dist/store.d.ts +236 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +996 -0
- package/dist/sync.d.ts +57 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +1328 -0
- package/package.json +121 -0
- package/skills/oh/SKILL.md +206 -0
- package/skills/oh/agents/openai.yaml +4 -0
- package/spec/README.md +74 -0
- package/spec/manifest.json +41 -0
- package/spec/v1/canonical-json.md +59 -0
- package/spec/v1/contract.json +28 -0
- package/spec/v1/contract.schema.json +58 -0
- package/spec/v1/embedding-profile.json +11 -0
- package/spec/v1/embedding.md +56 -0
- package/spec/v1/graph.md +87 -0
- package/spec/v1/memory.md +193 -0
- package/spec/v1/migration.md +92 -0
- package/spec/v1/ontology.json +55 -0
- package/spec/v1/ontology.md +80 -0
- package/spec/v1/operation.schema.json +138 -0
- package/spec/v1/projection-identity.schema.json +58 -0
- package/spec/v1/projection-query.schema.json +60 -0
- package/spec/v1/projection-result.schema.json +452 -0
- package/spec/v1/projection-rule-pack.schema.json +182 -0
- package/spec/v1/projection.md +165 -0
- package/spec/v1/record.schema.json +95 -0
- package/spec/v1/schema-evolution.md +51 -0
- package/spec/v1/schema-revision.schema.json +178 -0
- package/spec/v1/storage.md +88 -0
- package/spec/v1/store.md +131 -0
- package/spec/v1/sync-bundle.schema.json +51 -0
- package/spec/v1/sync.md +67 -0
- package/src/canonical.test.ts +46 -0
- package/src/canonical.ts +203 -0
- package/src/cli.test.ts +103 -0
- package/src/cli.ts +308 -0
- package/src/contract.ts +87 -0
- package/src/contracts.test.ts +147 -0
- package/src/graph.ts +248 -0
- package/src/index.ts +8 -0
- package/src/libsql.test.ts +657 -0
- package/src/libsql.ts +1687 -0
- package/src/memory.test.ts +783 -0
- package/src/memory.ts +1684 -0
- package/src/ontology.ts +573 -0
- package/src/operation.ts +80 -0
- package/src/projection-public.ts +53 -0
- package/src/projection-suss.ts +129 -0
- package/src/projection.test.ts +418 -0
- package/src/projection.ts +1457 -0
- package/src/schema.ts +156 -0
- package/src/sdk.ts +96 -0
- package/src/search.ts +66 -0
- package/src/semantic.test.ts +480 -0
- package/src/semantic.ts +333 -0
- package/src/sqlite/driver.ts +47 -0
- package/src/sqlite/index.ts +4 -0
- package/src/sqlite/migrations.test.ts +44 -0
- package/src/sqlite/migrations.ts +178 -0
- package/src/sqlite/port.test.ts +127 -0
- package/src/sqlite/port.ts +120 -0
- package/src/sqlite/runtime.test.ts +68 -0
- package/src/sqlite/runtime.ts +53 -0
- package/src/sqlite/store.test.ts +295 -0
- package/src/sqlite/store.ts +988 -0
- package/src/store.test.ts +121 -0
- package/src/store.ts +701 -0
- package/src/sync.test.ts +117 -0
- package/src/sync.ts +227 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
boundedText,
|
|
3
|
+
canonicalJson,
|
|
4
|
+
canonicalSha256,
|
|
5
|
+
hasExactKeys,
|
|
6
|
+
isPlainRecord,
|
|
7
|
+
orderedUnique,
|
|
8
|
+
parseSha256Hex,
|
|
9
|
+
safeCode,
|
|
10
|
+
type JsonObject,
|
|
11
|
+
type Sha256Hex,
|
|
12
|
+
} from "./canonical";
|
|
13
|
+
import { parseKnowledgeSchemaRefV1, type KnowledgeSchemaRefV1 } from "./ontology";
|
|
14
|
+
|
|
15
|
+
export const OH_SCHEMA_FORMAT_VERSION_V1 = 1 as const;
|
|
16
|
+
export const OH_SCHEMA_KINDS_V1 = ["concept", "mapping", "predicate", "shape", "unit", "vocabulary"] as const;
|
|
17
|
+
export type KnowledgeSchemaKindV1 = (typeof OH_SCHEMA_KINDS_V1)[number];
|
|
18
|
+
|
|
19
|
+
export type KnowledgeLocalizedTextV1 = Readonly<{ language: string; text: string; v: 1 }>;
|
|
20
|
+
|
|
21
|
+
export type KnowledgeSchemaRevisionInputV1 = Readonly<{
|
|
22
|
+
body: JsonObject;
|
|
23
|
+
code: string;
|
|
24
|
+
compatibility: "additive" | "breaking";
|
|
25
|
+
description: readonly KnowledgeLocalizedTextV1[];
|
|
26
|
+
kind: KnowledgeSchemaKindV1;
|
|
27
|
+
labels: readonly KnowledgeLocalizedTextV1[];
|
|
28
|
+
namespace: string;
|
|
29
|
+
previousSchemaSha256: Sha256Hex | null;
|
|
30
|
+
revision: number;
|
|
31
|
+
v: 1;
|
|
32
|
+
}>;
|
|
33
|
+
|
|
34
|
+
export type KnowledgeSchemaRevisionV1 = KnowledgeSchemaRevisionInputV1 & Readonly<{
|
|
35
|
+
schemaSha256: Sha256Hex;
|
|
36
|
+
}>;
|
|
37
|
+
|
|
38
|
+
export type KnowledgeVocabularyRevisionV1 = Readonly<{
|
|
39
|
+
namespace: string;
|
|
40
|
+
revision: number;
|
|
41
|
+
schemaRefs: readonly KnowledgeSchemaRefV1[];
|
|
42
|
+
v: 1;
|
|
43
|
+
vocabularySha256: Sha256Hex;
|
|
44
|
+
}>;
|
|
45
|
+
|
|
46
|
+
function parseLocalizedTexts(value: unknown): readonly KnowledgeLocalizedTextV1[] | null {
|
|
47
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 128) return null;
|
|
48
|
+
const output: KnowledgeLocalizedTextV1[] = [];
|
|
49
|
+
for (const item of value) {
|
|
50
|
+
if (!isPlainRecord(item) || !hasExactKeys(item, ["language", "text", "v"]) || item.v !== 1) return null;
|
|
51
|
+
const language = typeof item.language === "string"
|
|
52
|
+
&& /^(?:und|[a-z]{2,3}(?:-[a-z0-9]{2,8})*)$/u.test(item.language)
|
|
53
|
+
? item.language : null;
|
|
54
|
+
const text = boundedText(item.text, 16_384);
|
|
55
|
+
if (language === null || text === null) return null;
|
|
56
|
+
output.push({ language, text, v: 1 });
|
|
57
|
+
}
|
|
58
|
+
return orderedUnique(output, canonicalJson) ? output : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseSchemaInput(value: unknown): KnowledgeSchemaRevisionInputV1 | null {
|
|
62
|
+
if (!isPlainRecord(value) || !hasExactKeys(value, ["body", "code", "compatibility", "description",
|
|
63
|
+
"kind", "labels", "namespace", "previousSchemaSha256", "revision", "v"]) || value.v !== 1
|
|
64
|
+
|| !isPlainRecord(value.body)) return null;
|
|
65
|
+
try { canonicalJson(value.body); } catch { return null; }
|
|
66
|
+
const code = safeCode(value.code);
|
|
67
|
+
const namespace = safeCode(value.namespace);
|
|
68
|
+
const kind = OH_SCHEMA_KINDS_V1.find((candidate) => candidate === value.kind);
|
|
69
|
+
const labels = parseLocalizedTexts(value.labels);
|
|
70
|
+
const description = parseLocalizedTexts(value.description);
|
|
71
|
+
const previousSchemaSha256 = value.previousSchemaSha256 === null ? null : parseSha256Hex(value.previousSchemaSha256);
|
|
72
|
+
const revision = Number.isSafeInteger(value.revision) && (value.revision as number) > 0
|
|
73
|
+
? value.revision as number : null;
|
|
74
|
+
const compatibility = value.compatibility === "additive" || value.compatibility === "breaking"
|
|
75
|
+
? value.compatibility : null;
|
|
76
|
+
return code !== null && namespace !== null && kind !== undefined && labels !== null
|
|
77
|
+
&& description !== null && (value.previousSchemaSha256 === null || previousSchemaSha256 !== null)
|
|
78
|
+
&& revision !== null && compatibility !== null
|
|
79
|
+
&& ((revision === 1) === (previousSchemaSha256 === null))
|
|
80
|
+
&& (revision !== 1 || compatibility === "additive")
|
|
81
|
+
? { body: value.body as JsonObject, code, compatibility, description, kind, labels,
|
|
82
|
+
namespace, previousSchemaSha256, revision, v: 1 }
|
|
83
|
+
: null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createKnowledgeSchemaRevisionV1(input: KnowledgeSchemaRevisionInputV1): KnowledgeSchemaRevisionV1 {
|
|
87
|
+
const parsed = parseSchemaInput(input);
|
|
88
|
+
if (parsed === null) throw new TypeError("Invalid schema revision input.");
|
|
89
|
+
return { ...parsed, schemaSha256: canonicalSha256(parsed) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function parseKnowledgeSchemaRevisionV1(value: unknown): KnowledgeSchemaRevisionV1 | null {
|
|
93
|
+
if (!isPlainRecord(value) || !Object.hasOwn(value, "schemaSha256")) return null;
|
|
94
|
+
const schemaSha256 = parseSha256Hex(value.schemaSha256);
|
|
95
|
+
const { schemaSha256: _digest, ...input } = value;
|
|
96
|
+
const parsed = parseSchemaInput(input);
|
|
97
|
+
return schemaSha256 !== null && parsed !== null && canonicalSha256(parsed) === schemaSha256
|
|
98
|
+
? { ...parsed, schemaSha256 } : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function knowledgeSchemaRefV1(schema: KnowledgeSchemaRevisionV1): KnowledgeSchemaRefV1 {
|
|
102
|
+
return { code: schema.code, namespace: schema.namespace, revision: schema.revision,
|
|
103
|
+
schemaSha256: schema.schemaSha256, v: 1 };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function additiveBodyRetainsPrior(prior: JsonObject, next: JsonObject): boolean {
|
|
107
|
+
return Object.entries(prior).every(([key, value]) => Object.hasOwn(next, key)
|
|
108
|
+
&& canonicalJson(next[key]) === canonicalJson(value));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Checks the immutable chain and the meaning of an additive evolution claim. */
|
|
112
|
+
export function verifyKnowledgeSchemaEvolutionV1(
|
|
113
|
+
prior: KnowledgeSchemaRevisionV1,
|
|
114
|
+
next: KnowledgeSchemaRevisionV1,
|
|
115
|
+
): Readonly<{ ok: true }> | Readonly<{ ok: false; reason: string }> {
|
|
116
|
+
if (parseKnowledgeSchemaRevisionV1(prior) === null || parseKnowledgeSchemaRevisionV1(next) === null) {
|
|
117
|
+
return { ok: false, reason: "invalid-schema" };
|
|
118
|
+
}
|
|
119
|
+
if (prior.namespace !== next.namespace || prior.code !== next.code || prior.kind !== next.kind) {
|
|
120
|
+
return { ok: false, reason: "identity-changed" };
|
|
121
|
+
}
|
|
122
|
+
if (next.revision !== prior.revision + 1 || next.previousSchemaSha256 !== prior.schemaSha256) {
|
|
123
|
+
return { ok: false, reason: "chain-broken" };
|
|
124
|
+
}
|
|
125
|
+
if (next.compatibility === "additive" && !additiveBodyRetainsPrior(prior.body, next.body)) {
|
|
126
|
+
return { ok: false, reason: "false-additive-claim" };
|
|
127
|
+
}
|
|
128
|
+
return { ok: true };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function createKnowledgeVocabularyRevisionV1(input: Omit<KnowledgeVocabularyRevisionV1, "vocabularySha256">): KnowledgeVocabularyRevisionV1 {
|
|
132
|
+
const namespace = safeCode(input.namespace);
|
|
133
|
+
if (namespace === null || input.v !== 1 || !Number.isSafeInteger(input.revision) || input.revision < 1
|
|
134
|
+
|| !Array.isArray(input.schemaRefs) || input.schemaRefs.length > 65_536) {
|
|
135
|
+
throw new TypeError("Invalid vocabulary revision input.");
|
|
136
|
+
}
|
|
137
|
+
const refs: KnowledgeSchemaRefV1[] = [];
|
|
138
|
+
for (const candidate of input.schemaRefs) {
|
|
139
|
+
const parsed = parseKnowledgeSchemaRefV1(candidate);
|
|
140
|
+
if (!parsed.ok || parsed.value.namespace !== namespace) throw new TypeError("Invalid vocabulary schema reference.");
|
|
141
|
+
refs.push(parsed.value);
|
|
142
|
+
}
|
|
143
|
+
if (!orderedUnique(refs, canonicalJson)) throw new TypeError("Vocabulary schema references must be ordered and unique.");
|
|
144
|
+
const payload = { namespace, revision: input.revision, schemaRefs: refs, v: 1 as const };
|
|
145
|
+
return { ...payload, vocabularySha256: canonicalSha256(payload) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function parseKnowledgeVocabularyRevisionV1(value: unknown): KnowledgeVocabularyRevisionV1 | null {
|
|
149
|
+
if (!isPlainRecord(value) || !hasExactKeys(value, ["namespace", "revision", "schemaRefs", "v", "vocabularySha256"])) return null;
|
|
150
|
+
const digest = parseSha256Hex(value.vocabularySha256);
|
|
151
|
+
try {
|
|
152
|
+
const created = createKnowledgeVocabularyRevisionV1({ namespace: value.namespace as string,
|
|
153
|
+
revision: value.revision as number, schemaRefs: value.schemaRefs as KnowledgeSchemaRefV1[], v: value.v as 1 });
|
|
154
|
+
return digest !== null && created.vocabularySha256 === digest ? { ...created, vocabularySha256: digest } : null;
|
|
155
|
+
} catch { return null; }
|
|
156
|
+
}
|
package/src/sdk.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { opaqueId, type JsonValue } from "./canonical";
|
|
2
|
+
import { createKnowledgeGraphRecordV1, type KnowledgeGraphRecordKindV1,
|
|
3
|
+
type KnowledgeGraphRecordV1 } from "./graph";
|
|
4
|
+
import { searchOhV1, type OhSearchModeV1, type OhSearchResponseV1 } from "./search";
|
|
5
|
+
import type { OhSemanticSearchBackendV1 } from "./semantic";
|
|
6
|
+
import { OhSqliteStore, type OhHeadV1, type OhReplayVerificationV1 } from "./sqlite/store";
|
|
7
|
+
import { synchronizeOhStoreV1, type OhOperationSyncTransportV1, type OhSyncResultV1 } from "./sync";
|
|
8
|
+
import type { OhOperationV1 } from "./operation";
|
|
9
|
+
|
|
10
|
+
export type OhOpenOptionsV1 = Readonly<{
|
|
11
|
+
databasePath?: string;
|
|
12
|
+
semanticBackend?: OhSemanticSearchBackendV1;
|
|
13
|
+
spaceId?: string;
|
|
14
|
+
}>;
|
|
15
|
+
|
|
16
|
+
export class Oh {
|
|
17
|
+
readonly store: OhSqliteStore;
|
|
18
|
+
readonly semanticBackend: OhSemanticSearchBackendV1 | undefined;
|
|
19
|
+
#closed = false;
|
|
20
|
+
|
|
21
|
+
private constructor(store: OhSqliteStore, semanticBackend?: OhSemanticSearchBackendV1) {
|
|
22
|
+
this.store = store;
|
|
23
|
+
this.semanticBackend = semanticBackend;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static open(options: OhOpenOptionsV1 = {}): Oh {
|
|
27
|
+
return new Oh(new OhSqliteStore({ path: options.databasePath ?? ".oh/oh.sqlite",
|
|
28
|
+
...(options.spaceId === undefined ? {} : { spaceId: options.spaceId }) }),
|
|
29
|
+
options.semanticBackend);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
head(): OhHeadV1 { return this.store.head(); }
|
|
33
|
+
|
|
34
|
+
put(input: Readonly<{
|
|
35
|
+
actorId?: string;
|
|
36
|
+
dependencies?: readonly string[];
|
|
37
|
+
expectedHead?: Pick<OhHeadV1, "generation" | "operationSha256">;
|
|
38
|
+
instant?: string;
|
|
39
|
+
key: string;
|
|
40
|
+
kind: KnowledgeGraphRecordKindV1;
|
|
41
|
+
operationId?: string;
|
|
42
|
+
value: JsonValue;
|
|
43
|
+
}>): OhOperationV1 {
|
|
44
|
+
const record = createKnowledgeGraphRecordV1({ dependencies: [...(input.dependencies ?? [])].sort(),
|
|
45
|
+
key: input.key, kind: input.kind, v: 1, value: input.value });
|
|
46
|
+
const head = this.store.head();
|
|
47
|
+
return this.store.commit({ actorId: input.actorId ?? "agent.local", changes: [{ kind: "put", record, v: 1 }],
|
|
48
|
+
expectedHead: input.expectedHead ?? head, ...(input.instant === undefined ? {} : { instant: input.instant }),
|
|
49
|
+
operationId: input.operationId ?? opaqueId("op_") });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
tombstone(input: Readonly<{
|
|
53
|
+
actorId?: string;
|
|
54
|
+
expectedHead?: Pick<OhHeadV1, "generation" | "operationSha256">;
|
|
55
|
+
instant?: string;
|
|
56
|
+
key: string;
|
|
57
|
+
operationId?: string;
|
|
58
|
+
}>): OhOperationV1 {
|
|
59
|
+
const current = this.store.get(input.key);
|
|
60
|
+
if (current === null) throw new Error(`No record exists at ${input.key}.`);
|
|
61
|
+
const head = this.store.head();
|
|
62
|
+
return this.store.commit({ actorId: input.actorId ?? "agent.local",
|
|
63
|
+
changes: [{ key: input.key, kind: "tombstone", priorSha256: current.recordSha256, v: 1 }],
|
|
64
|
+
expectedHead: input.expectedHead ?? head, ...(input.instant === undefined ? {} : { instant: input.instant }),
|
|
65
|
+
operationId: input.operationId ?? opaqueId("op_") });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get(key: string): KnowledgeGraphRecordV1 | null { return this.store.get(key); }
|
|
69
|
+
|
|
70
|
+
list(options?: Parameters<OhSqliteStore["list"]>[0]): readonly KnowledgeGraphRecordV1[] {
|
|
71
|
+
return this.store.list(options);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async indexSemantic(): Promise<Readonly<{ indexed: number; v: 1 }>> {
|
|
75
|
+
if (this.semanticBackend === undefined) throw new Error("No local semantic backend is configured.");
|
|
76
|
+
return await this.semanticBackend.index(this.store.snapshotRecords());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async search(query: string, options: Readonly<{ limit?: number; mode?: OhSearchModeV1 }> = {}): Promise<OhSearchResponseV1> {
|
|
80
|
+
return await searchOhV1({ ...(this.semanticBackend === undefined ? {} : { backend: this.semanticBackend }),
|
|
81
|
+
...(options.limit === undefined ? {} : { limit: options.limit }),
|
|
82
|
+
...(options.mode === undefined ? {} : { mode: options.mode }), query, store: this.store });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async sync(transport: OhOperationSyncTransportV1, options?: Parameters<typeof synchronizeOhStoreV1>[2]): Promise<OhSyncResultV1> {
|
|
86
|
+
return await synchronizeOhStoreV1(this.store, transport, options);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
verify(): OhReplayVerificationV1 { return this.store.verifyReplay(); }
|
|
90
|
+
|
|
91
|
+
async close(): Promise<void> {
|
|
92
|
+
if (this.#closed) return;
|
|
93
|
+
this.#closed = true;
|
|
94
|
+
try { await this.semanticBackend?.close(); } finally { this.store.close(); }
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { KnowledgeGraphRecordV1 } from "./graph";
|
|
2
|
+
import type { OhSemanticSearchBackendV1 } from "./semantic";
|
|
3
|
+
import type { OhSqliteStore } from "./sqlite/store";
|
|
4
|
+
|
|
5
|
+
export type OhSearchModeV1 = "hybrid" | "keyword" | "semantic";
|
|
6
|
+
export type OhSearchDiagnosticV1 = Readonly<{
|
|
7
|
+
code: "semantic-unavailable";
|
|
8
|
+
message: string;
|
|
9
|
+
v: 1;
|
|
10
|
+
}>;
|
|
11
|
+
export type OhSearchResultV1 = Readonly<{
|
|
12
|
+
evidence: readonly Readonly<{ lane: "keyword" | "semantic"; rank: number; score: number; v: 1 }>[];
|
|
13
|
+
record: KnowledgeGraphRecordV1;
|
|
14
|
+
score: number;
|
|
15
|
+
v: 1;
|
|
16
|
+
}>;
|
|
17
|
+
export type OhSearchResponseV1 = Readonly<{
|
|
18
|
+
diagnostics: readonly OhSearchDiagnosticV1[];
|
|
19
|
+
mode: OhSearchModeV1;
|
|
20
|
+
results: readonly OhSearchResultV1[];
|
|
21
|
+
v: 1;
|
|
22
|
+
}>;
|
|
23
|
+
|
|
24
|
+
export async function searchOhV1(input: Readonly<{
|
|
25
|
+
backend?: OhSemanticSearchBackendV1;
|
|
26
|
+
limit?: number;
|
|
27
|
+
mode?: OhSearchModeV1;
|
|
28
|
+
query: string;
|
|
29
|
+
store: OhSqliteStore;
|
|
30
|
+
}>): Promise<OhSearchResponseV1> {
|
|
31
|
+
const limit = input.limit ?? 10;
|
|
32
|
+
const mode = input.mode ?? "keyword";
|
|
33
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new RangeError("Search limit must be 1 through 100.");
|
|
34
|
+
const keyword = mode === "semantic" ? [] : input.store.searchKeyword(input.query, Math.min(100, limit * 3));
|
|
35
|
+
let semantic: Awaited<ReturnType<OhSemanticSearchBackendV1["search"]>> = [];
|
|
36
|
+
const diagnostics: OhSearchDiagnosticV1[] = [];
|
|
37
|
+
if (mode !== "keyword") {
|
|
38
|
+
if (input.backend === undefined) {
|
|
39
|
+
diagnostics.push({ code: "semantic-unavailable", message: "No local semantic backend is configured.", v: 1 });
|
|
40
|
+
} else {
|
|
41
|
+
try { semantic = await input.backend.search(input.query, Math.min(100, limit * 3), input.store); }
|
|
42
|
+
catch (error) {
|
|
43
|
+
diagnostics.push({ code: "semantic-unavailable",
|
|
44
|
+
message: error instanceof Error ? error.message : "Local semantic search failed.", v: 1 });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const byKey = new Map<string, { evidence: OhSearchResultV1["evidence"][number][]; score: number }>();
|
|
49
|
+
const add = (key: string, lane: "keyword" | "semantic", rank: number, laneScore: number): void => {
|
|
50
|
+
const contribution = (lane === "keyword" ? 2 : 1) / (60 + rank);
|
|
51
|
+
const current = byKey.get(key) ?? { evidence: [], score: 0 };
|
|
52
|
+
current.evidence.push({ lane, rank, score: laneScore, v: 1 });
|
|
53
|
+
current.score += contribution;
|
|
54
|
+
byKey.set(key, current);
|
|
55
|
+
};
|
|
56
|
+
keyword.forEach((result, index) => add(result.key, "keyword", index + 1, result.score));
|
|
57
|
+
semantic.forEach((result, index) => add(result.key, "semantic", index + 1, result.score));
|
|
58
|
+
const results: OhSearchResultV1[] = [];
|
|
59
|
+
for (const [key, rank] of [...byKey.entries()].sort((left, right) => right[1].score - left[1].score || left[0].localeCompare(right[0]))) {
|
|
60
|
+
const record = input.store.get(key);
|
|
61
|
+
if (record === null) continue;
|
|
62
|
+
results.push({ evidence: rank.evidence, record, score: rank.score, v: 1 });
|
|
63
|
+
if (results.length === limit) break;
|
|
64
|
+
}
|
|
65
|
+
return { diagnostics, mode, results, v: 1 };
|
|
66
|
+
}
|