@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.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The guidance bullets the curator's review prompt shows for the
3
+ * knowledge target (spec §6.3, plus the freshness ruling: fields that go
4
+ * out of date carry a horizon so staleness is visible).
5
+ */
6
+ export function guidance() {
7
+ return [
8
+ "Record the inquiry and its why (the purpose, in the user's words where possible).",
9
+ "Find before creating: knowledge_find first, so entities de-duplicate across inquiries through identity keys.",
10
+ "Reuse or extend an existing type before defining a new one; type names are lowercase singular phrases.",
11
+ "When defining a type, give each field that goes out of date a horizonDays (prices ~30, opening hours ~180, addresses none).",
12
+ "Cite every field: each value carries its source recall handle and observed date.",
13
+ "Record the user's verdicts and outcomes, not your own guesses.",
14
+ "Capture nothing from transient errors or one-off chatter.",
15
+ ];
16
+ }
@@ -0,0 +1,46 @@
1
+ import type { ToolHookCall, ToolExecutionResult } from "@earendil-works/pi-durable";
2
+ /**
3
+ * The per-conversation "researched since last review" flag, per user:
4
+ * `<user state>/research.json` maps conversationId (string key) to
5
+ * `{ lastResearchAt, requestedAt? }` (epoch ms).
6
+ */
7
+ export interface ResearchFlags {
8
+ [conversationId: string]: {
9
+ lastResearchAt: number;
10
+ requestedAt?: number;
11
+ };
12
+ }
13
+ /** The window a queued review suppresses duplicate requests. */
14
+ export declare const RESEARCH_WINDOW_MS: number;
15
+ export declare function readResearchFlags(stateDir: string): ResearchFlags;
16
+ export interface ResearchDeps {
17
+ /** Live conversation meta (internal flag + owner). */
18
+ conversationMeta(conversationId: number): Promise<{
19
+ owner?: string;
20
+ internal?: true;
21
+ }>;
22
+ /** The directory user's state dir (research.json lives there). */
23
+ userStateDir(user: string): string;
24
+ /** The extension's per-user settings (researchTools). */
25
+ userSettings(user: string): Promise<Record<string, unknown>>;
26
+ now(): number;
27
+ }
28
+ /**
29
+ * The afterTool hook: for a non-internal conversation with an owner and a
30
+ * tool name matching the owner's researchTools globs, stamp
31
+ * lastResearchAt. Never alters the result; errors are logged, never
32
+ * thrown (the hook must not break tool execution).
33
+ */
34
+ export declare function createResearch(deps: ResearchDeps): (call: ToolHookCall, _result: ToolExecutionResult, api: {
35
+ conversationId: number;
36
+ }, _context: unknown) => Promise<undefined>;
37
+ /**
38
+ * Whether the target wants a review of this conversation (one per
39
+ * research burst): true when research happened and no request was made
40
+ * within the last RESEARCH_WINDOW_MS; stamps requestedAt so further
41
+ * settles while a review is queued don't enqueue a second one. A review
42
+ * that never completes is re-requested after the window.
43
+ */
44
+ export declare function requestReview(stateDir: string, conversationId: number, now: number): boolean;
45
+ /** The review consumed the flag: delete the entry. */
46
+ export declare function clearReview(stateDir: string, conversationId: number): void;
@@ -0,0 +1,81 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { matchesGlob, resolveUserSettings } from "./config.js";
4
+ /** The window a queued review suppresses duplicate requests. */
5
+ export const RESEARCH_WINDOW_MS = 15 * 60 * 1000;
6
+ export function readResearchFlags(stateDir) {
7
+ try {
8
+ const raw = JSON.parse(readFileSync(join(stateDir, "research.json"), "utf8"));
9
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
10
+ return {};
11
+ return raw;
12
+ }
13
+ catch {
14
+ return {};
15
+ }
16
+ }
17
+ function writeResearchFlags(stateDir, flags) {
18
+ mkdirSync(stateDir, { recursive: true });
19
+ writeFileSync(join(stateDir, "research.json"), JSON.stringify(flags, null, "\t") + "\n");
20
+ }
21
+ /**
22
+ * The afterTool hook: for a non-internal conversation with an owner and a
23
+ * tool name matching the owner's researchTools globs, stamp
24
+ * lastResearchAt. Never alters the result; errors are logged, never
25
+ * thrown (the hook must not break tool execution).
26
+ */
27
+ export function createResearch(deps) {
28
+ return async function afterTool(call, _result, api, _context) {
29
+ try {
30
+ const meta = await deps.conversationMeta(api.conversationId);
31
+ if (meta.internal === true || meta.owner === undefined)
32
+ return undefined;
33
+ const settings = await resolveUserSettings({
34
+ get: () => deps.userSettings(meta.owner),
35
+ });
36
+ if (!settings.researchTools.some((glob) => matchesGlob(glob, call.name))) {
37
+ return undefined;
38
+ }
39
+ const stateDir = deps.userStateDir(meta.owner);
40
+ const flags = readResearchFlags(stateDir);
41
+ const key = String(api.conversationId);
42
+ const entry = flags[key] ?? { lastResearchAt: 0 };
43
+ flags[key] = { ...entry, lastResearchAt: deps.now() };
44
+ writeResearchFlags(stateDir, flags);
45
+ }
46
+ catch (error) {
47
+ console.error("knowledge: research flag failed", error);
48
+ }
49
+ return undefined;
50
+ };
51
+ }
52
+ /**
53
+ * Whether the target wants a review of this conversation (one per
54
+ * research burst): true when research happened and no request was made
55
+ * within the last RESEARCH_WINDOW_MS; stamps requestedAt so further
56
+ * settles while a review is queued don't enqueue a second one. A review
57
+ * that never completes is re-requested after the window.
58
+ */
59
+ export function requestReview(stateDir, conversationId, now) {
60
+ const flags = readResearchFlags(stateDir);
61
+ const key = String(conversationId);
62
+ const entry = flags[key];
63
+ if (entry === undefined || entry.lastResearchAt === 0)
64
+ return false;
65
+ if (entry.requestedAt !== undefined &&
66
+ now - entry.requestedAt < RESEARCH_WINDOW_MS) {
67
+ return false;
68
+ }
69
+ flags[key] = { ...entry, requestedAt: now };
70
+ writeResearchFlags(stateDir, flags);
71
+ return true;
72
+ }
73
+ /** The review consumed the flag: delete the entry. */
74
+ export function clearReview(stateDir, conversationId) {
75
+ const flags = readResearchFlags(stateDir);
76
+ const key = String(conversationId);
77
+ if (flags[key] === undefined)
78
+ return;
79
+ delete flags[key];
80
+ writeResearchFlags(stateDir, flags);
81
+ }
@@ -0,0 +1,44 @@
1
+ import { type TSchema } from "@earendil-works/pi-ai";
2
+ export type FieldType = "string" | "number" | "boolean" | "date" | "url" | "money" | "enum" | "list";
3
+ export interface FieldDef {
4
+ name: string;
5
+ type: FieldType;
6
+ /** Allowed values when type is "enum". */
7
+ values?: string[];
8
+ /** Item type when type is "list". */
9
+ item?: Exclude<FieldType, "list">;
10
+ /** Identity fields participate in the entity's identity keys. */
11
+ identity?: boolean;
12
+ /** Freshness horizon in days, for fields that age. */
13
+ horizonDays?: number;
14
+ description?: string;
15
+ }
16
+ export interface TypeDef {
17
+ /** Lowercase singular phrase, e.g. "clinic" or "service visit". */
18
+ name: string;
19
+ description: string;
20
+ /** Parent type name (same hierarchy = one identity family). */
21
+ parent?: string;
22
+ fields: FieldDef[];
23
+ }
24
+ /** Type names are lowercase singular phrases. */
25
+ export declare const TYPE_NAME_PATTERN: RegExp;
26
+ export declare const TypeDefSchema: TSchema;
27
+ /**
28
+ * Validate one field value against its definition. Returns undefined when
29
+ * the value is valid, otherwise a short human-readable error text.
30
+ */
31
+ export declare function validateValue(def: FieldDef, value: unknown): string | undefined;
32
+ /** Minimal type row shape the hierarchy helpers need. */
33
+ export interface TypeHierarchyRow {
34
+ id: number;
35
+ parentId: number | null;
36
+ /** Field defs, parsed (FieldDef[]) or still the stored JSON string. */
37
+ fields: string | FieldDef[];
38
+ }
39
+ /**
40
+ * Effective (own + inherited) field defs for a type: walk the parent
41
+ * chain root-first; a child field with the same name shadows the
42
+ * ancestor's def.
43
+ */
44
+ export declare function effectiveFields(typesById: Map<number, TypeHierarchyRow>, typeId: number): FieldDef[];
package/dist/schema.js ADDED
@@ -0,0 +1,145 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ const FIELD_TYPES = [
3
+ "string",
4
+ "number",
5
+ "boolean",
6
+ "date",
7
+ "url",
8
+ "money",
9
+ "enum",
10
+ "list",
11
+ ];
12
+ const LIST_ITEM_TYPES = [
13
+ "string",
14
+ "number",
15
+ "boolean",
16
+ "date",
17
+ "url",
18
+ "money",
19
+ "enum",
20
+ ];
21
+ /** Type names are lowercase singular phrases. */
22
+ export const TYPE_NAME_PATTERN = /^[a-z][a-z0-9 -]{0,63}$/;
23
+ const FieldDefSchema = Type.Object({
24
+ name: Type.String({ minLength: 1 }),
25
+ type: Type.Union(FIELD_TYPES.map((type) => Type.Literal(type))),
26
+ values: Type.Optional(Type.Array(Type.String())),
27
+ item: Type.Optional(Type.Union(LIST_ITEM_TYPES.map((type) => Type.Literal(type)))),
28
+ identity: Type.Optional(Type.Boolean()),
29
+ horizonDays: Type.Optional(Type.Number()),
30
+ description: Type.Optional(Type.String()),
31
+ });
32
+ export const TypeDefSchema = Type.Object({
33
+ name: Type.String({ pattern: TYPE_NAME_PATTERN.source }),
34
+ description: Type.String(),
35
+ parent: Type.Optional(Type.String({ pattern: TYPE_NAME_PATTERN.source })),
36
+ fields: Type.Array(FieldDefSchema),
37
+ });
38
+ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
39
+ function checkScalar(type, value) {
40
+ switch (type) {
41
+ case "string":
42
+ return typeof value === "string" ? undefined : "expected a string";
43
+ case "number":
44
+ return typeof value === "number" && Number.isFinite(value)
45
+ ? undefined
46
+ : "expected a number";
47
+ case "boolean":
48
+ return typeof value === "boolean" ? undefined : "expected a boolean";
49
+ case "date": {
50
+ if (typeof value !== "string" || !ISO_DATE_PATTERN.test(value)) {
51
+ return "expected an ISO date (YYYY-MM-DD)";
52
+ }
53
+ const parsed = new Date(`${value}T00:00:00Z`);
54
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) {
55
+ return "expected a valid calendar date";
56
+ }
57
+ return undefined;
58
+ }
59
+ case "url":
60
+ if (typeof value !== "string")
61
+ return "expected a url string";
62
+ try {
63
+ new URL(value);
64
+ return undefined;
65
+ }
66
+ catch {
67
+ return "expected a valid url";
68
+ }
69
+ case "money":
70
+ return checkMoney(value);
71
+ }
72
+ }
73
+ function checkMoney(value) {
74
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
75
+ return "expected money { amount, currency }";
76
+ }
77
+ const raw = value;
78
+ if (typeof raw.amount !== "number" || !Number.isFinite(raw.amount)) {
79
+ return "money amount must be a number";
80
+ }
81
+ if (typeof raw.currency !== "string" ||
82
+ !/^[A-Za-z]{3}$/.test(raw.currency)) {
83
+ return "money currency must be a 3-letter code";
84
+ }
85
+ return undefined;
86
+ }
87
+ /**
88
+ * Validate one field value against its definition. Returns undefined when
89
+ * the value is valid, otherwise a short human-readable error text.
90
+ */
91
+ export function validateValue(def, value) {
92
+ if (def.type === "enum") {
93
+ if (typeof value !== "string")
94
+ return "expected an enum string";
95
+ if (def.values === undefined || !def.values.includes(value)) {
96
+ return `expected one of: ${(def.values ?? []).join(", ")}`;
97
+ }
98
+ return undefined;
99
+ }
100
+ if (def.type === "list") {
101
+ if (!Array.isArray(value))
102
+ return "expected a list";
103
+ const itemType = def.item ?? "string";
104
+ for (const item of value) {
105
+ // List-of-enum: membership comes from the owning def's values
106
+ // (the item type carries no values of its own); permissive only
107
+ // when no values are declared.
108
+ if (itemType === "enum" && def.values !== undefined) {
109
+ if (typeof item !== "string" || !def.values.includes(item)) {
110
+ return `list item: expected one of: ${def.values.join(", ")}`;
111
+ }
112
+ continue;
113
+ }
114
+ const error = checkScalar(itemType === "enum" ? "string" : itemType, item);
115
+ if (error !== undefined)
116
+ return `list item: ${error}`;
117
+ }
118
+ return undefined;
119
+ }
120
+ return checkScalar(def.type, value);
121
+ }
122
+ /**
123
+ * Effective (own + inherited) field defs for a type: walk the parent
124
+ * chain root-first; a child field with the same name shadows the
125
+ * ancestor's def.
126
+ */
127
+ export function effectiveFields(typesById, typeId) {
128
+ const chain = [];
129
+ let current = typesById.get(typeId);
130
+ while (current !== undefined) {
131
+ chain.unshift(current);
132
+ current =
133
+ current.parentId === null ? undefined : typesById.get(current.parentId);
134
+ }
135
+ const byName = new Map();
136
+ for (const type of chain) {
137
+ const fields = typeof type.fields === "string"
138
+ ? JSON.parse(type.fields)
139
+ : type.fields;
140
+ for (const field of fields) {
141
+ byName.set(field.name, field);
142
+ }
143
+ }
144
+ return [...byName.values()];
145
+ }
@@ -0,0 +1,23 @@
1
+ import type { CurationTarget } from "@sigrun/sigrun/extension";
2
+ import type { KnowledgeDb } from "./db.js";
3
+ import { type MirrorHost } from "./mirror.js";
4
+ export interface TargetDeps {
5
+ host: MirrorHost & {
6
+ stores: {
7
+ user(user: string): {
8
+ paths: {
9
+ state: string;
10
+ };
11
+ };
12
+ };
13
+ };
14
+ dbForUser(user: string): KnowledgeDb;
15
+ now(): number;
16
+ }
17
+ /**
18
+ * The knowledge curation target (curator P7): knowledge_record is the
19
+ * wrapped write surface; knowledge_find is a read tool the review may
20
+ * call (never wrapped). undo reverts a change and re-mirrors every
21
+ * touched entity. wantsReview/reviewed drive the research flag.
22
+ */
23
+ export declare function createTarget(deps: TargetDeps): CurationTarget;
package/dist/target.js ADDED
@@ -0,0 +1,29 @@
1
+ import { undoChange } from "./undo.js";
2
+ import { mirrorEntities } from "./mirror.js";
3
+ import { maintain } from "./maintain.js";
4
+ import { guidance } from "./prompts.js";
5
+ import { requestReview, clearReview } from "./research.js";
6
+ /**
7
+ * The knowledge curation target (curator P7): knowledge_record is the
8
+ * wrapped write surface; knowledge_find is a read tool the review may
9
+ * call (never wrapped). undo reverts a change and re-mirrors every
10
+ * touched entity. wantsReview/reviewed drive the research flag.
11
+ */
12
+ export function createTarget(deps) {
13
+ return {
14
+ name: "knowledge",
15
+ tools: ["knowledge_record"],
16
+ readTools: ["knowledge_find"],
17
+ guidance: () => guidance(),
18
+ async undo(user, changeId, _ctx) {
19
+ const db = deps.dbForUser(user);
20
+ const result = undoChange(db, changeId, deps.now());
21
+ await mirrorEntities(deps.host, db, user, result.touched, deps.now());
22
+ },
23
+ wantsReview: (user, id) => requestReview(deps.host.stores.user(user).paths.state, id, deps.now()),
24
+ reviewed: (user, id) => {
25
+ clearReview(deps.host.stores.user(user).paths.state, id);
26
+ },
27
+ maintain: (user, ctx, budget) => maintain(user, ctx, budget, deps),
28
+ };
29
+ }
@@ -0,0 +1,45 @@
1
+ import { type ToolRegistration } from "@earendil-works/pi-durable";
2
+ import type { KnowledgeDb } from "./db.js";
3
+ type HookResult = {
4
+ status: "absent";
5
+ } | {
6
+ status: "ok";
7
+ value?: unknown;
8
+ } | {
9
+ status: "rejected" | "failed";
10
+ [key: string]: unknown;
11
+ };
12
+ export interface ToolsHost {
13
+ identity: {
14
+ actingUser(conversationId: number): Promise<string | undefined>;
15
+ };
16
+ conversations: {
17
+ meta(id: number): Promise<{
18
+ owner?: string;
19
+ internal?: true;
20
+ sharing?: import("@sigrun/shared/access").Sharing;
21
+ }>;
22
+ entries(id: number, query: {
23
+ order?: "ascending" | "descending";
24
+ }, limit: number, cursor?: unknown): Promise<{
25
+ items: readonly {
26
+ conversationId: number;
27
+ }[];
28
+ next?: unknown;
29
+ }>;
30
+ };
31
+ publishedHooks: {
32
+ call(name: string, payload: unknown, opts?: {
33
+ user?: string;
34
+ conversationId?: number;
35
+ }): Promise<HookResult>;
36
+ };
37
+ }
38
+ export interface ToolsDeps {
39
+ host: ToolsDepsHost;
40
+ dbForUser(user: string): KnowledgeDb;
41
+ now(): number;
42
+ }
43
+ export type ToolsDepsHost = ToolsHost;
44
+ export declare function createTools(deps: ToolsDeps): readonly ToolRegistration[];
45
+ export {};