@sigrun/knowledge 0.0.0-stage → 0.1.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/dist/card.js ADDED
@@ -0,0 +1,83 @@
1
+ import { effectiveFields } from "./schema.js";
2
+ const DAY_MS = 24 * 60 * 60 * 1000;
3
+ /** Parse an observedAt as a date-only string or a full ISO timestamp. */
4
+ function parseObserved(observedAt) {
5
+ const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
6
+ const parsed = Date.parse(dateOnly.test(observedAt) ? `${observedAt}T00:00:00Z` : observedAt);
7
+ return Number.isNaN(parsed) ? null : parsed;
8
+ }
9
+ function formatField(value) {
10
+ if (typeof value === "object" &&
11
+ value !== null &&
12
+ !Array.isArray(value) &&
13
+ typeof value.amount === "number") {
14
+ const money = value;
15
+ return `${money.amount} ${money.currency}`;
16
+ }
17
+ if (Array.isArray(value))
18
+ return value.map(formatField).join(", ");
19
+ return String(value);
20
+ }
21
+ /**
22
+ * Render the review card for one entity: title, one line per field with
23
+ * its observed date (and a stale mark past the field's horizon), the
24
+ * inquiries the entity has roles in, and the cited recall handles.
25
+ */
26
+ export function renderCard(db, entityId, now) {
27
+ const row = db
28
+ .raw()
29
+ .prepare(`SELECT e.id, e.name, e.fields, e.typeId, t.name AS typeName
30
+ FROM entities e JOIN types t ON t.id = e.typeId WHERE e.id = ?`)
31
+ .get(entityId);
32
+ if (row === undefined) {
33
+ throw new Error(`renderCard: unknown entity "${entityId}"`);
34
+ }
35
+ // Inherited fields carry their defs (horizonDays) down the hierarchy.
36
+ const typeRows = db
37
+ .raw()
38
+ .prepare("SELECT id, parentId, fields FROM types")
39
+ // SAFETY: the types table columns are fixed by db.ts's schema;
40
+ // node:sqlite types rows generically.
41
+ .all();
42
+ const typeFields = effectiveFields(new Map(typeRows.map((type) => [type.id, type])), row.typeId);
43
+ const fields = JSON.parse(row.fields);
44
+ const sources = new Set();
45
+ // The card body leads with the title line: recall indexes a doc's TEXT
46
+ // (never its title), so the type/entity name must appear here for the
47
+ // card to be findable by name.
48
+ const title = `${row.typeName} — ${row.name}`;
49
+ const lines = [title];
50
+ for (const [name, field] of Object.entries(fields)) {
51
+ sources.add(field.source);
52
+ const def = typeFields.find((candidate) => candidate.name === name);
53
+ // observedAt may be date-only or a full ISO timestamp: parse both
54
+ // for staleness, but always render the date part.
55
+ const observedMs = parseObserved(field.observedAt);
56
+ const observedDate = observedMs === null
57
+ ? field.observedAt
58
+ : new Date(observedMs).toISOString().slice(0, 10);
59
+ let observed = `observed ${observedDate}`;
60
+ if (def?.horizonDays !== undefined &&
61
+ observedMs !== null &&
62
+ now - observedMs > def.horizonDays * DAY_MS) {
63
+ observed += ", stale";
64
+ }
65
+ lines.push(`${name}: ${formatField(field.value)} (${observed})`);
66
+ }
67
+ const roles = db
68
+ .raw()
69
+ .prepare(`SELECT i.question AS question, r.role AS role, r.reason AS reason
70
+ FROM roles r JOIN inquiries i ON i.id = r.inquiryId
71
+ WHERE r.entityId = ? AND i.archived = 0 ORDER BY i.question`)
72
+ .all(entityId);
73
+ for (const role of roles) {
74
+ const reason = role.reason === null ? "" : ` — ${role.reason}`;
75
+ lines.push(`${role.question}: ${role.role}${reason}`);
76
+ }
77
+ return {
78
+ title,
79
+ text: lines.join("\n"),
80
+ date: new Date(now).toISOString(),
81
+ sources: [...sources],
82
+ };
83
+ }
@@ -0,0 +1,15 @@
1
+ export interface SettingsReader {
2
+ get(): Promise<Record<string, unknown>>;
3
+ }
4
+ export interface UserKnowledgeSettings {
5
+ researchTools: readonly string[];
6
+ }
7
+ /** Test hook: clear the warn-once registry between tests. */
8
+ export declare function resetWarnedForTests(): void;
9
+ /**
10
+ * Match a tool name against a glob where `*` is the only wildcard and
11
+ * matches any run of characters (including none). Everything else in the
12
+ * pattern is literal.
13
+ */
14
+ export declare function matchesGlob(pattern: string, name: string): boolean;
15
+ export declare function resolveUserSettings(store: SettingsReader): Promise<UserKnowledgeSettings>;
package/dist/config.js ADDED
@@ -0,0 +1,44 @@
1
+ const DEFAULT_RESEARCH_TOOLS = [
2
+ "*/search*",
3
+ "*/fetch*",
4
+ "*/crawl*",
5
+ "*/md",
6
+ "web_search",
7
+ "web_fetch",
8
+ ];
9
+ function warnInvalid(setting) {
10
+ // Warn once per setting name: Task 10 resolves settings per tool call,
11
+ // so a misconfigured user must not spam the log on every call.
12
+ if (warned.has(setting))
13
+ return;
14
+ warned.add(setting);
15
+ console.warn(`knowledge: invalid ${setting} setting; using its default`);
16
+ }
17
+ const warned = new Set();
18
+ /** Test hook: clear the warn-once registry between tests. */
19
+ export function resetWarnedForTests() {
20
+ warned.clear();
21
+ }
22
+ /**
23
+ * Match a tool name against a glob where `*` is the only wildcard and
24
+ * matches any run of characters (including none). Everything else in the
25
+ * pattern is literal.
26
+ */
27
+ export function matchesGlob(pattern, name) {
28
+ const source = pattern
29
+ .split("*")
30
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
31
+ .join(".*");
32
+ return new RegExp(`^${source}$`).test(name);
33
+ }
34
+ export async function resolveUserSettings(store) {
35
+ const raw = await store.get();
36
+ if (raw.researchTools !== undefined) {
37
+ if (Array.isArray(raw.researchTools) &&
38
+ raw.researchTools.every((tool) => typeof tool === "string" && tool !== "")) {
39
+ return { researchTools: [...raw.researchTools] };
40
+ }
41
+ warnInvalid("researchTools");
42
+ }
43
+ return { researchTools: [...DEFAULT_RESEARCH_TOOLS] };
44
+ }
package/dist/db.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ /**
3
+ * The per-user knowledge store. One database per directory user at
4
+ * join(host.stores.user(user).paths.data, "knowledge.db").
5
+ *
6
+ * `typeFamilyRoot` on entity_keys is the topmost ancestor type id, so
7
+ * identity keys de-duplicate across a type hierarchy.
8
+ */
9
+ export declare class KnowledgeDb {
10
+ private readonly db;
11
+ private constructor();
12
+ static open(path: string): KnowledgeDb;
13
+ private migrate;
14
+ /** Raw handle for the scaffold; table accessors land with Task 9. */
15
+ raw(): DatabaseSync;
16
+ transaction<T>(fn: () => T): T;
17
+ close(): void;
18
+ }
package/dist/db.js ADDED
@@ -0,0 +1,156 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ const SCHEMA = `
5
+ CREATE TABLE IF NOT EXISTS types (
6
+ id INTEGER PRIMARY KEY,
7
+ name TEXT NOT NULL UNIQUE,
8
+ description TEXT NOT NULL,
9
+ parentId INTEGER REFERENCES types(id),
10
+ fields TEXT NOT NULL,
11
+ version INTEGER NOT NULL,
12
+ archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1))
13
+ );
14
+ CREATE TABLE IF NOT EXISTS entities (
15
+ id TEXT PRIMARY KEY,
16
+ typeId INTEGER NOT NULL REFERENCES types(id),
17
+ name TEXT NOT NULL,
18
+ keys TEXT NOT NULL DEFAULT '[]',
19
+ fields TEXT NOT NULL DEFAULT '{}',
20
+ attributes TEXT NOT NULL DEFAULT '{}',
21
+ cls TEXT NOT NULL DEFAULT 'normal' CHECK (cls IN ('normal', 'local-only')),
22
+ indexedAt INTEGER,
23
+ archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1))
24
+ );
25
+ CREATE TABLE IF NOT EXISTS entity_keys (
26
+ id INTEGER PRIMARY KEY,
27
+ entityId TEXT NOT NULL REFERENCES entities(id),
28
+ typeFamilyRoot INTEGER NOT NULL REFERENCES types(id),
29
+ key TEXT NOT NULL,
30
+ normalized TEXT NOT NULL,
31
+ UNIQUE (typeFamilyRoot, key, normalized)
32
+ );
33
+ CREATE TABLE IF NOT EXISTS inquiries (
34
+ id INTEGER PRIMARY KEY,
35
+ question TEXT NOT NULL,
36
+ why TEXT,
37
+ startedAt INTEGER NOT NULL,
38
+ touchedAt INTEGER NOT NULL,
39
+ conversations TEXT NOT NULL DEFAULT '[]',
40
+ outcome TEXT,
41
+ archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1))
42
+ );
43
+ CREATE TABLE IF NOT EXISTS roles (
44
+ id INTEGER PRIMARY KEY,
45
+ inquiryId INTEGER NOT NULL REFERENCES inquiries(id),
46
+ entityId TEXT NOT NULL REFERENCES entities(id),
47
+ role TEXT NOT NULL,
48
+ reason TEXT,
49
+ UNIQUE (inquiryId, entityId)
50
+ );
51
+ CREATE TABLE IF NOT EXISTS versions (
52
+ seq INTEGER PRIMARY KEY,
53
+ changeId TEXT NOT NULL,
54
+ "table" TEXT NOT NULL,
55
+ rowId TEXT NOT NULL,
56
+ before TEXT,
57
+ after TEXT,
58
+ at INTEGER NOT NULL
59
+ );
60
+ CREATE TABLE IF NOT EXISTS applied (
61
+ callId TEXT PRIMARY KEY,
62
+ changeId TEXT NOT NULL,
63
+ result TEXT NOT NULL
64
+ );
65
+ CREATE TABLE IF NOT EXISTS maintenance (
66
+ id INTEGER PRIMARY KEY,
67
+ kind TEXT NOT NULL,
68
+ changeId TEXT,
69
+ payload TEXT NOT NULL DEFAULT '{}',
70
+ cursor TEXT,
71
+ at INTEGER NOT NULL
72
+ );
73
+ CREATE TABLE IF NOT EXISTS schema_version (
74
+ version INTEGER NOT NULL
75
+ );
76
+ `;
77
+ const SCHEMA_VERSION = 2;
78
+ /** Ordered migrations from version N to N+1 (index = from-version - 1). */
79
+ const MIGRATIONS = {
80
+ // v2: inquiries gain `archived` — records are never deleted, only
81
+ // archived, and undo of an inquiry create needs somewhere to mark it.
82
+ 1: "ALTER TABLE inquiries ADD COLUMN archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1))",
83
+ };
84
+ /**
85
+ * The per-user knowledge store. One database per directory user at
86
+ * join(host.stores.user(user).paths.data, "knowledge.db").
87
+ *
88
+ * `typeFamilyRoot` on entity_keys is the topmost ancestor type id, so
89
+ * identity keys de-duplicate across a type hierarchy.
90
+ */
91
+ export class KnowledgeDb {
92
+ db;
93
+ constructor(db) {
94
+ this.db = db;
95
+ }
96
+ static open(path) {
97
+ if (path !== ":memory:")
98
+ mkdirSync(dirname(path), { recursive: true });
99
+ const db = new DatabaseSync(path);
100
+ try {
101
+ const opened = new KnowledgeDb(db);
102
+ opened.migrate();
103
+ return opened;
104
+ }
105
+ catch (error) {
106
+ db.close();
107
+ throw error;
108
+ }
109
+ }
110
+ migrate() {
111
+ this.transaction(() => {
112
+ this.db.exec("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)");
113
+ const current = this.db
114
+ .prepare("SELECT version FROM schema_version")
115
+ .get();
116
+ if (current === undefined) {
117
+ this.db.exec(SCHEMA);
118
+ this.db
119
+ .prepare("INSERT INTO schema_version (version) VALUES (?)")
120
+ .run(SCHEMA_VERSION);
121
+ return;
122
+ }
123
+ for (let version = current.version; version < SCHEMA_VERSION; version++) {
124
+ const migration = MIGRATIONS[version];
125
+ if (migration === undefined) {
126
+ throw new Error(`knowledge: missing migration from schema v${version}`);
127
+ }
128
+ this.db.exec(migration);
129
+ }
130
+ if (current.version < SCHEMA_VERSION) {
131
+ this.db
132
+ .prepare("UPDATE schema_version SET version = ?")
133
+ .run(SCHEMA_VERSION);
134
+ }
135
+ });
136
+ }
137
+ /** Raw handle for the scaffold; table accessors land with Task 9. */
138
+ raw() {
139
+ return this.db;
140
+ }
141
+ transaction(fn) {
142
+ this.db.exec("BEGIN IMMEDIATE");
143
+ try {
144
+ const result = fn();
145
+ this.db.exec("COMMIT");
146
+ return result;
147
+ }
148
+ catch (error) {
149
+ this.db.exec("ROLLBACK");
150
+ throw error;
151
+ }
152
+ }
153
+ close() {
154
+ this.db.close();
155
+ }
156
+ }
@@ -0,0 +1,11 @@
1
+ import type { SigrunExtensionResult, SigrunHost } from "@sigrun/sigrun/extension";
2
+ import { KnowledgeDb } from "./db.js";
3
+ export interface FactoryDeps {
4
+ openDb?: (path: string) => KnowledgeDb;
5
+ now?: () => number;
6
+ }
7
+ /**
8
+ * The loader injects the host only when factory.length > 0: keep the host
9
+ * bare and the optional test dependencies second, so the arity stays one.
10
+ */
11
+ export default function createKnowledge(host: SigrunHost, deps?: FactoryDeps): SigrunExtensionResult;
package/dist/index.js ADDED
@@ -0,0 +1,52 @@
1
+ import { join } from "node:path";
2
+ import { defineExtension, hook, ToolTask } from "@earendil-works/pi-durable";
3
+ import { KnowledgeDb } from "./db.js";
4
+ import { createTools } from "./tools.js";
5
+ import { createTarget } from "./target.js";
6
+ import { createResearch } from "./research.js";
7
+ /**
8
+ * The loader injects the host only when factory.length > 0: keep the host
9
+ * bare and the optional test dependencies second, so the arity stays one.
10
+ */
11
+ export default function createKnowledge(host, deps = {}) {
12
+ // One store per directory user, opened on first use. Core does not
13
+ // create paths.data; KnowledgeDb.open creates its parent.
14
+ const databases = new Map();
15
+ function dbForUser(user) {
16
+ let database = databases.get(user);
17
+ if (database === undefined) {
18
+ database = (deps.openDb ?? KnowledgeDb.open)(join(host.stores.user(user).paths.data, "knowledge.db"));
19
+ databases.set(user, database);
20
+ }
21
+ return database;
22
+ }
23
+ const now = deps.now ?? Date.now;
24
+ const tools = createTools({ host, dbForUser, now });
25
+ const target = createTarget({ host, dbForUser, now });
26
+ const researchFlag = createResearch({
27
+ conversationMeta: (id) => host.conversations.meta(id),
28
+ userStateDir: (user) => host.stores.user(user).paths.state,
29
+ userSettings: (user) => host.stores.user(user).settings.get(),
30
+ now,
31
+ });
32
+ const result = {
33
+ extension: defineExtension({
34
+ name: "@sigrun/knowledge",
35
+ tools,
36
+ hooks: [
37
+ hook(ToolTask, {
38
+ afterTool: async (call, toolResult, api) => researchFlag(call, toolResult, api, undefined),
39
+ }),
40
+ ],
41
+ }),
42
+ dbForUser,
43
+ curationTargets: [target],
44
+ harnessShutdown: () => {
45
+ for (const database of databases.values()) {
46
+ database.close();
47
+ }
48
+ databases.clear();
49
+ },
50
+ };
51
+ return result;
52
+ }
@@ -0,0 +1,21 @@
1
+ import type { HandlerContext, MaintenanceBudget, MaintenanceReport } from "@sigrun/sigrun/extension";
2
+ import type { KnowledgeDb } from "./db.js";
3
+ import { type MirrorHost } from "./mirror.js";
4
+ export interface MaintainDeps {
5
+ host: MirrorHost;
6
+ dbForUser(user: string): KnowledgeDb;
7
+ now(): number;
8
+ }
9
+ /** Jaccard similarity of two field-name sets. */
10
+ export declare function jaccard(a: readonly string[], b: readonly string[]): number;
11
+ /**
12
+ * The idle pass, in order, stopping at the deadline:
13
+ * 1. re-index up to 50 pending/stale entities (not reported);
14
+ * 2. continue an unfinished re-typing before proposing anything new;
15
+ * 3. propose one generalization via budget.run (needs runs >= 1);
16
+ * 4. apply it as a hierarchy in one change, folding field-less children
17
+ * (<= 50 entities per pass; the rest continue next pass under the
18
+ * same change id, reported with more: true).
19
+ * At most one generalization per pass.
20
+ */
21
+ export declare function maintain(user: string, ctx: HandlerContext, budget: MaintenanceBudget, deps: MaintainDeps): Promise<MaintenanceReport>;