@loomwork/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/src/prompt.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { UNTRUSTED_CONTENT_SYSTEM_NOTE } from "@loomwork/llm";
2
+ import type { LlmMessageInput, LlmRequestInput } from "@loomwork/llm";
3
+ import type { Passage } from "./types";
4
+
5
+ /**
6
+ * Retrieved passages as model messages: one `user` message per passage, each still
7
+ * carrying the taint it was ingested with.
8
+ *
9
+ * This shape is the whole safety argument, and it is the shape almost nobody writes first.
10
+ * The natural thing — join the passages into one string under a "here is the relevant
11
+ * documentation" header, and put it in the SYSTEM prompt — is refused by `guardRequest`
12
+ * with an `UntrustedContentError`, because the system prompt is the operator's voice and a
13
+ * corpus anyone can edit is not the operator. Two properties make this function the fix
14
+ * rather than a wrapper around the same mistake:
15
+ *
16
+ * - ONE message per passage, never a join. Taint is attached to a VALUE, so joining means
17
+ * unwrapping first, and provenance stops travelling exactly where it starts mattering.
18
+ * - the `user` role, which is the only position `guardRequest` fences rather than refuses.
19
+ *
20
+ * `guardRequest` then labels each fenced block with the passage's origin before any
21
+ * provider sees it, so the model is told which passages are data and where each came from.
22
+ */
23
+ export function passageMessages(passages: readonly Passage[]): LlmMessageInput[] {
24
+ return passages.map((passage) => ({ role: "user" as const, content: passage.text }));
25
+ }
26
+
27
+ export interface GroundedRequestOptions {
28
+ /** The operator's instructions. Trusted content — a plain string, never a passage. */
29
+ instructions: string;
30
+ /** The question, appended after the passages so it reads last. */
31
+ question: string;
32
+ passages: readonly Passage[];
33
+ maxTokens?: number;
34
+ temperature?: number;
35
+ }
36
+
37
+ /**
38
+ * A complete grounded-answer request: operator instructions plus the untrusted-content
39
+ * note in `system`, the retrieved passages as fenced `user` messages, the question last.
40
+ *
41
+ * `instructions` is typed `string` rather than `LlmContent` on purpose. The system slot is
42
+ * where a passage must never land, and a signature that accepts a tainted value there
43
+ * would move the refusal from compile time to run time — the check exists either way, but
44
+ * only one of the two tells you before you ship.
45
+ */
46
+ export function groundedRequest(opts: GroundedRequestOptions): LlmRequestInput {
47
+ return {
48
+ system: `${opts.instructions} ${UNTRUSTED_CONTENT_SYSTEM_NOTE}`,
49
+ messages: [
50
+ ...passageMessages(opts.passages),
51
+ { role: "user" as const, content: opts.question },
52
+ ],
53
+ ...(opts.maxTokens !== undefined && { maxTokens: opts.maxTokens }),
54
+ ...(opts.temperature !== undefined && { temperature: opts.temperature }),
55
+ };
56
+ }
package/src/types.ts ADDED
@@ -0,0 +1,97 @@
1
+ import type { TaintedValue } from "@loomwork/classify";
2
+
3
+ /**
4
+ * A source document, as the application already has it: an id it can name the document
5
+ * by again tomorrow, its text, and whatever metadata it wants carried onto every passage.
6
+ *
7
+ * There is deliberately no `source`, `url`, `author`, `section` or `updatedAt` here. Those
8
+ * are a vocabulary, they differ per corpus, and a framework that named them would be
9
+ * asking every app to translate into its dialect — put them in `meta`, which travels
10
+ * verbatim and is indexed like any other content.
11
+ */
12
+ export interface KnowledgeDoc {
13
+ /** Stable across revisions. This is what a re-ingest supersedes and what a citation names. */
14
+ id: string;
15
+ /** Optional label, carried onto every passage so a citation can read as a title. */
16
+ title?: string;
17
+ text: string;
18
+ /** Carried onto every passage verbatim. Must be JSON — it is stored in a MemoryEntry. */
19
+ meta?: Record<string, unknown>;
20
+ }
21
+
22
+ /**
23
+ * One retrieved (or just-ingested) piece of a document.
24
+ *
25
+ * `text` is TAINTED (ADR 0085) and stays tainted all the way into the model request. A
26
+ * corpus is edited by whoever can edit the source, so a passage is third-party content by
27
+ * definition — the same class as an MCP tool result — and `guardRequest` refuses it in
28
+ * every instruction position. Tainting at INGEST rather than at the prompt is what makes
29
+ * that hold on every path: the wrapper is plain JSON, so it survives the store, the
30
+ * recorded retrieval, and a replay that rebuilds the prompt from the log.
31
+ */
32
+ export interface Passage {
33
+ docId: string;
34
+ title?: string;
35
+ /** 0-based position within the document, in source order. */
36
+ index: number;
37
+ text: TaintedValue<string>;
38
+ meta?: Record<string, unknown>;
39
+ }
40
+
41
+ /**
42
+ * Splits a document into passage texts, in source order.
43
+ *
44
+ * Injected, never inferred. How a document splits is a property of the corpus — a runbook
45
+ * splits on paragraphs, a legal contract on numbered clauses, a chat export on turns — and
46
+ * the retrieval quality of a knowledge base is mostly decided here. {@link packParagraphs}
47
+ * is a default worth having, not a default worth hiding.
48
+ */
49
+ export type Chunker = (doc: KnowledgeDoc) => string[];
50
+
51
+ /**
52
+ * The manifest row for one document: what a corpus knows about a document without
53
+ * reading its passages.
54
+ *
55
+ * `chunkCount` is what makes removal possible at all — passage ids are derived
56
+ * (`docId#0…docId#n`), so the count IS the id list, in one small row instead of n.
57
+ *
58
+ * `fingerprint` is over the document's content, so re-ingesting an unchanged document is
59
+ * free: that is what turns {@link KnowledgeBase.sync} from "rebuild the corpus" into "write
60
+ * the documents that actually changed".
61
+ *
62
+ * `committed` is the crash-safety flag, and the reason ingest writes a manifest row TWICE.
63
+ * The dangerous interleaving is not a lost write, it is a manifest that claims a revision
64
+ * whose passages were never appended: the next sync would compare fingerprints, see a
65
+ * match, and skip the document forever — a corpus silently missing a document that every
66
+ * status view calls present. Writing `committed: false` first, and flipping it only after
67
+ * the passages land, makes the crash window converge on a re-run instead.
68
+ */
69
+ export interface DocRecord {
70
+ docId: string;
71
+ chunkCount: number;
72
+ fingerprint: string;
73
+ committed: boolean;
74
+ /**
75
+ * WHO manages this document — the writer that may revise or delete it. `undefined` is a
76
+ * source of its own (the corpus's default writer), not a wildcard.
77
+ *
78
+ * It exists because `sync` is a destructive reconcile over everything the manifest holds,
79
+ * and a corpus is not always written by one thing. An agent that writes down what it
80
+ * learned from feedback puts documents in the same scope as an authored corpus — and the
81
+ * nightly sync of a wiki export would then delete every one of them, because they are not
82
+ * in the set the wiki produced. Stamping each document with its writer makes that
83
+ * impossible instead of documented: a `sync` only ever reconciles its own documents.
84
+ */
85
+ source?: string;
86
+ /**
87
+ * Generation of this manifest ROW, not a version of the document.
88
+ *
89
+ * Manifest rows are only ever APPENDED — `docId#0`, `docId#1`, … — and the highest one
90
+ * wins, because the alternative (delete the old row, append the new one) has a crash
91
+ * window in which the document has no manifest row at all. That window is the worst of
92
+ * the three: passages nothing counts are passages nothing can remove. Superseded rows
93
+ * are pruned after the new one is durable, so a crash costs a stale row and never a
94
+ * missing one.
95
+ */
96
+ revision: number;
97
+ }