@retinue/agentkit 0.1.0 → 0.2.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.
Files changed (77) hide show
  1. package/README.md +59 -277
  2. package/dist/adapters/embeddings/openai.d.ts +45 -0
  3. package/dist/adapters/embeddings/openai.js +109 -0
  4. package/dist/agents/agent.d.ts +22 -1
  5. package/dist/agents/agent.js +97 -11
  6. package/dist/agents/engine.d.ts +28 -0
  7. package/dist/agents/engine.js +194 -8
  8. package/dist/capabilities/index.d.ts +5 -1
  9. package/dist/capabilities/index.js +23 -0
  10. package/dist/capabilities/runtime.d.ts +8 -0
  11. package/dist/core/budget.d.ts +55 -0
  12. package/dist/core/budget.js +56 -0
  13. package/dist/core/content-parts.d.ts +8 -0
  14. package/dist/core/events.d.ts +68 -2
  15. package/dist/core/events.js +2 -0
  16. package/dist/core/index.d.ts +1 -0
  17. package/dist/core/index.js +1 -0
  18. package/dist/documents/index.d.ts +14 -0
  19. package/dist/documents/parsers/text.d.ts +16 -0
  20. package/dist/documents/parsers/text.js +54 -2
  21. package/dist/entries/guardrails.d.ts +14 -0
  22. package/dist/entries/guardrails.js +14 -0
  23. package/dist/entries/knowledge.d.ts +9 -0
  24. package/dist/entries/knowledge.js +8 -0
  25. package/dist/graphql/resolvers.d.ts +4 -0
  26. package/dist/graphql/resolvers.js +6 -0
  27. package/dist/graphql/schema.d.ts +1 -1
  28. package/dist/graphql/schema.js +44 -0
  29. package/dist/guardrails/index.d.ts +115 -0
  30. package/dist/guardrails/index.js +108 -0
  31. package/dist/guardrails/moderation.d.ts +53 -0
  32. package/dist/guardrails/moderation.js +75 -0
  33. package/dist/guardrails/pii.d.ts +75 -0
  34. package/dist/guardrails/pii.js +193 -0
  35. package/dist/knowledge/index.d.ts +1 -0
  36. package/dist/knowledge/index.js +1 -0
  37. package/dist/knowledge/navigate.d.ts +89 -0
  38. package/dist/knowledge/navigate.js +107 -0
  39. package/dist/knowledge/retrieval.d.ts +73 -5
  40. package/dist/knowledge/retrieval.js +82 -28
  41. package/dist/models/streaming.d.ts +22 -1
  42. package/dist/models/streaming.js +5 -1
  43. package/dist/security/checklist.js +9 -0
  44. package/dist/security/findings.js +18 -9
  45. package/dist/skills/catalogue.d.ts +49 -0
  46. package/dist/skills/catalogue.js +61 -0
  47. package/dist/skills/index.d.ts +1 -0
  48. package/dist/skills/index.js +1 -0
  49. package/dist/telemetry/spans.js +12 -0
  50. package/dist/toolkit/files.d.ts +125 -0
  51. package/dist/toolkit/files.js +320 -0
  52. package/dist/toolkit/index.d.ts +4 -0
  53. package/dist/toolkit/index.js +2 -0
  54. package/dist/toolkit/sandbox.d.ts +119 -0
  55. package/dist/toolkit/sandbox.js +239 -0
  56. package/dist/toolkit/web.d.ts +13 -0
  57. package/dist/toolkit/web.js +7 -1
  58. package/dist/tools/budget.d.ts +28 -0
  59. package/dist/tools/budget.js +35 -0
  60. package/dist/tools/credentials.d.ts +57 -0
  61. package/dist/tools/credentials.js +54 -0
  62. package/dist/tools/define.d.ts +31 -0
  63. package/dist/tools/define.js +23 -0
  64. package/dist/tools/find.d.ts +109 -0
  65. package/dist/tools/find.js +210 -0
  66. package/dist/tools/index.d.ts +14 -2
  67. package/dist/tools/index.js +4 -0
  68. package/dist/tools/library/fs.d.ts +24 -0
  69. package/dist/tools/library/fs.js +102 -0
  70. package/dist/tools/library/index.d.ts +29 -2
  71. package/dist/tools/library/index.js +40 -0
  72. package/dist/tools/library/shell.d.ts +45 -0
  73. package/dist/tools/library/shell.js +70 -0
  74. package/dist/tools/meta-tools.js +8 -0
  75. package/dist/tools/registry.d.ts +113 -0
  76. package/dist/tools/registry.js +180 -4
  77. package/package.json +5 -1
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Guardrails — REQ-046 (#205), task #211.
3
+ *
4
+ * A seam for checks a deployment needs and this runtime does not ship: PII redaction, moderation, a topic
5
+ * restriction, an output schema. Without it, a deployment that needs any of those has to edit the engine.
6
+ *
7
+ * The injection half of "guardrails" is already built and lives elsewhere (`security/prompt-safety.ts`), and it
8
+ * is deliberately *not* a guardrail in this sense: containment is structural — untrusted content is wrapped in a
9
+ * nonce-delimited envelope whether or not anything recognises an attack — whereas everything here is
10
+ * *inspection*, which can only act on what it detects. Conflating the two would invite someone to switch off
11
+ * containment because a detector is present.
12
+ *
13
+ * ## Three decisions that make this worth having
14
+ *
15
+ * **Tool calls are outputs.** A guardrail that inspects only the final message can be walked straight past by
16
+ * putting the data in a tool argument. Checking prose and not arguments is checking the boring half, so
17
+ * `GuardrailOutput` is a discriminated union of a message *and* a tool call, and the tool-call case is enforced
18
+ * at the one choke point every call goes through.
19
+ *
20
+ * **Fail closed.** A guardrail that throws refuses the turn, attributed to the guardrail that threw. The
21
+ * opposite default is how a guardrail silently stops guarding the day its dependency times out — and the run
22
+ * looks entirely normal afterwards, which is the property that makes it dangerous.
23
+ *
24
+ * **Every verdict is recorded, and never the value.** A redaction that leaves no trace is indistinguishable from
25
+ * the model never having been told, which makes an incident unreconstructable. So a record names *what* was
26
+ * redacted — the field, the entity type — and never what it contained, or the audit trail becomes the leak.
27
+ */
28
+ /** What a guardrail may conclude. */
29
+ export const GUARDRAIL_OUTCOMES = ["pass", "redacted", "refused"];
30
+ const REFUSED_BY_THROW = "guardrail_failed";
31
+ const subjectOf = (value) => {
32
+ if (typeof value === "object" && value !== null && "kind" in value) {
33
+ const kind = value.kind;
34
+ return kind === "tool-call" || kind === "tool-result" ? kind : "message";
35
+ }
36
+ return "input";
37
+ };
38
+ /**
39
+ * Run a list in declared order, threading the value through.
40
+ *
41
+ * Threading is what makes two redacting guardrails compose: the second inspects what the first produced, so one
42
+ * cannot undo the other by inspecting the original and returning its own edit of it. Order is the caller's
43
+ * declaration and is never sorted here — a guardrail set whose order depends on object key iteration is a set
44
+ * whose behaviour changes when someone reformats the config.
45
+ *
46
+ * A refusal short-circuits: the remaining guardrails are not consulted, because the turn is over and running
47
+ * them would spend money to annotate a decision already taken.
48
+ *
49
+ * Two exported entry points over one core rather than a `hook: "inspectInput" | "inspectOutput"` parameter. The
50
+ * parameterised version does not typecheck — indexing a union of two method signatures gives a function callable
51
+ * with neither argument type — and the `never` cast that silences it would have erased exactly the distinction
52
+ * the two subjects exist to keep.
53
+ */
54
+ const applyEach = async (guardrails, select, value, context) => {
55
+ const records = [];
56
+ let current = value;
57
+ for (const guardrail of guardrails) {
58
+ const inspect = select(guardrail);
59
+ if (inspect === undefined)
60
+ continue;
61
+ let verdict;
62
+ try {
63
+ verdict = await inspect(current, context);
64
+ }
65
+ catch (error) {
66
+ // Fail closed, and say which one. A guardrail whose dependency timed out must not become a guardrail that
67
+ // passed everything: the whole point is that its absence is visible.
68
+ records.push({
69
+ guardrail: guardrail.name,
70
+ subject: subjectOf(current),
71
+ outcome: "refused",
72
+ code: REFUSED_BY_THROW,
73
+ threw: true,
74
+ });
75
+ return {
76
+ outcome: "refused",
77
+ by: guardrail.name,
78
+ code: REFUSED_BY_THROW,
79
+ message: `guardrail ${guardrail.name} could not complete: ${error instanceof Error ? error.message : String(error)}`,
80
+ records,
81
+ };
82
+ }
83
+ if (verdict.kind === "pass") {
84
+ records.push({ guardrail: guardrail.name, subject: subjectOf(current), outcome: "pass" });
85
+ continue;
86
+ }
87
+ if (verdict.kind === "redacted") {
88
+ records.push({ guardrail: guardrail.name, subject: subjectOf(current), outcome: "redacted", what: verdict.what });
89
+ current = verdict.value;
90
+ continue;
91
+ }
92
+ records.push({ guardrail: guardrail.name, subject: subjectOf(current), outcome: "refused", code: verdict.code });
93
+ return { outcome: "refused", by: guardrail.name, code: verdict.code, message: verdict.message, records };
94
+ }
95
+ return { outcome: "allowed", value: current, records };
96
+ };
97
+ /** Before the model sees the turn. */
98
+ export const applyInputGuardrails = (guardrails, input, context) => applyEach(guardrails, (g) => (g.inspectInput ? (v, c) => g.inspectInput(v, c) : undefined), input, context);
99
+ /** Before anything leaves the model — a message *or* a tool call. */
100
+ export const applyOutputGuardrails = (guardrails, output, context) => applyEach(guardrails, (g) => (g.inspectOutput ? (v, c) => g.inspectOutput(v, c) : undefined), output, context);
101
+ /**
102
+ * Whether a record could carry an inspected value — used by the test that asserts it never does.
103
+ *
104
+ * Here rather than in the test file because it states the invariant next to the type it constrains: a record has
105
+ * a fixed shape, and adding a field that holds content is the change this is meant to make somebody notice.
106
+ */
107
+ export const recordCarriesOnlyMetadata = (record) => Object.keys(record).every((key) => ["guardrail", "subject", "outcome", "what", "code", "threw"].includes(key));
108
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Moderation, as an adapter — REQ-046 (#205), task #212, AC-4.
3
+ *
4
+ * **Off unless declared, and it takes the classifier rather than choosing one.** That is the whole design
5
+ * decision, and it is a cost decision rather than a technical one: a model call on every turn doubles the
6
+ * latency floor and adds a per-turn charge, and whether that is worth it depends on what a deployment is for.
7
+ * A runtime that imposed it would be spending somebody else's money on a policy they did not choose.
8
+ *
9
+ * So `classify` is supplied by the host. OpenAI's moderation endpoint is one implementation; a local classifier,
10
+ * a keyword list, or a shared service are others. None of them is a dependency of this package.
11
+ *
12
+ * ## The cost, stated
13
+ *
14
+ * One classifier call per inspected subject. With `subjects: ["input", "message"]` — the default — that is two
15
+ * calls per turn, in series with the model rather than parallel to it, because a turn that has already been
16
+ * answered cannot be un-answered. Add roughly the classifier's own latency twice to every turn.
17
+ *
18
+ * Reducing that is a real option and is why `subjects` is configurable: inspecting only `input` halves the cost
19
+ * and leaves generated content unchecked, which is the right trade for an internal tool and the wrong one for
20
+ * anything public.
21
+ *
22
+ * ## Failure is handled by the port, not here
23
+ *
24
+ * A classifier that times out throws, and `applyInputGuardrails` turns a throw into a refusal attributed to this
25
+ * guardrail. That is deliberate and it is the expensive-looking choice: a moderation outage stops turns. The
26
+ * alternative is a moderation outage that silently stops moderating, which is the one nobody notices.
27
+ */
28
+ import type { Guardrail } from "./index.js";
29
+ export type ModerationResult = {
30
+ readonly flagged: boolean;
31
+ /** Category names from the classifier. Used in the refusal message and the record; never the content. */
32
+ readonly categories?: readonly string[];
33
+ };
34
+ /** What the host supplies. Throwing is a refusal — see the module comment. */
35
+ export type ModerationClassifier = (text: string) => Promise<ModerationResult> | ModerationResult;
36
+ /** Which subjects to spend a classifier call on. */
37
+ export declare const MODERATION_SUBJECTS: readonly ["input", "message", "tool-result"];
38
+ export type ModerationSubject = (typeof MODERATION_SUBJECTS)[number];
39
+ export type ModerationOptions = {
40
+ readonly classify: ModerationClassifier;
41
+ /**
42
+ * Defaults to `["input", "message"]` — two calls per turn.
43
+ *
44
+ * `tool-result` is off by default because a tool result is usually structured data rather than prose, and
45
+ * classifying JSON produces confident nonsense. Turn it on when tools return free text somebody will read.
46
+ */
47
+ readonly subjects?: readonly ModerationSubject[];
48
+ /** Minimum text length worth a call. Defaults to 1: a classifier call on an empty string is pure cost. */
49
+ readonly minLength?: number;
50
+ readonly name?: string;
51
+ };
52
+ export declare const createModerationGuardrail: (options: ModerationOptions) => Guardrail;
53
+ //# sourceMappingURL=moderation.d.ts.map
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Moderation, as an adapter — REQ-046 (#205), task #212, AC-4.
3
+ *
4
+ * **Off unless declared, and it takes the classifier rather than choosing one.** That is the whole design
5
+ * decision, and it is a cost decision rather than a technical one: a model call on every turn doubles the
6
+ * latency floor and adds a per-turn charge, and whether that is worth it depends on what a deployment is for.
7
+ * A runtime that imposed it would be spending somebody else's money on a policy they did not choose.
8
+ *
9
+ * So `classify` is supplied by the host. OpenAI's moderation endpoint is one implementation; a local classifier,
10
+ * a keyword list, or a shared service are others. None of them is a dependency of this package.
11
+ *
12
+ * ## The cost, stated
13
+ *
14
+ * One classifier call per inspected subject. With `subjects: ["input", "message"]` — the default — that is two
15
+ * calls per turn, in series with the model rather than parallel to it, because a turn that has already been
16
+ * answered cannot be un-answered. Add roughly the classifier's own latency twice to every turn.
17
+ *
18
+ * Reducing that is a real option and is why `subjects` is configurable: inspecting only `input` halves the cost
19
+ * and leaves generated content unchecked, which is the right trade for an internal tool and the wrong one for
20
+ * anything public.
21
+ *
22
+ * ## Failure is handled by the port, not here
23
+ *
24
+ * A classifier that times out throws, and `applyInputGuardrails` turns a throw into a refusal attributed to this
25
+ * guardrail. That is deliberate and it is the expensive-looking choice: a moderation outage stops turns. The
26
+ * alternative is a moderation outage that silently stops moderating, which is the one nobody notices.
27
+ */
28
+ /** Which subjects to spend a classifier call on. */
29
+ export const MODERATION_SUBJECTS = ["input", "message", "tool-result"];
30
+ const textOf = (output) => {
31
+ if (output.kind === "message")
32
+ return output.text;
33
+ if (output.kind === "tool-result")
34
+ return typeof output.output === "string" ? output.output : null;
35
+ // A tool call's arguments are not prose. Classifying a JSON object is a call spent on a question the
36
+ // classifier was not trained for — the PII guardrail is the one that reads arguments.
37
+ return null;
38
+ };
39
+ export const createModerationGuardrail = (options) => {
40
+ const subjects = options.subjects ?? ["input", "message"];
41
+ const minLength = options.minLength ?? 1;
42
+ const judge = async (text) => {
43
+ if (text.trim().length < minLength)
44
+ return { kind: "pass" };
45
+ const result = await options.classify(text);
46
+ if (!result.flagged)
47
+ return { kind: "pass" };
48
+ const categories = result.categories ?? [];
49
+ return {
50
+ kind: "refused",
51
+ code: "moderation",
52
+ // Categories, never the content. The message is shown to a person and stored in an event.
53
+ message: categories.length > 0
54
+ ? `That content was flagged as ${categories.join(", ")}.`
55
+ : "That content was flagged by moderation.",
56
+ };
57
+ };
58
+ return {
59
+ name: options.name ?? "moderation",
60
+ ...(subjects.includes("input")
61
+ ? { inspectInput: (input) => judge(input.text) }
62
+ : {}),
63
+ ...(subjects.some((s) => s === "message" || s === "tool-result")
64
+ ? {
65
+ inspectOutput: async (output) => {
66
+ if (!subjects.includes(output.kind))
67
+ return { kind: "pass" };
68
+ const text = textOf(output);
69
+ return text === null ? { kind: "pass" } : judge(text);
70
+ },
71
+ }
72
+ : {}),
73
+ };
74
+ };
75
+ //# sourceMappingURL=moderation.js.map
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Personal data, detected and redacted — REQ-046 (#205), task #212.
3
+ *
4
+ * Deterministic and **offline**. No network call, no model call: a guardrail that costs a round trip per turn is
5
+ * one a deployment switches off under load, and the moment it is off is the moment it was needed. Everything
6
+ * here is a pattern plus, where one exists, a checksum.
7
+ *
8
+ * ## Why a checksum matters more than a pattern
9
+ *
10
+ * A sixteen-digit order number matches every "card number" regex ever written. Flagging it teaches people that
11
+ * this guardrail cries wolf, and a guardrail people have learned to ignore is worse than none — they route
12
+ * around it. So card numbers are Luhn-checked and IBANs mod-97-checked, and a candidate that fails its checksum
13
+ * is *not* personal data, it is a number.
14
+ *
15
+ * ## Referential consistency, without state
16
+ *
17
+ * A placeholder is derived from a hash of the value, so the same value always yields the same placeholder —
18
+ * `[email:7a3f19]`. That is not a cosmetic choice. A model asked to compare two records, handed two *different*
19
+ * placeholders for one email, concludes the records differ and reasons wrongly about data it was never allowed
20
+ * to see. Deriving from the value keeps equality and nothing else.
21
+ *
22
+ * The alternative — a counter per turn — would need state keyed by run and would break the moment a run
23
+ * resumed in a different process, which is the normal case for a durable runtime.
24
+ *
25
+ * A hash placeholder does permit equality testing, and that is the intended trade: equality is exactly the
26
+ * property being preserved. It leaks no plaintext and is not reversible.
27
+ */
28
+ import type { Guardrail } from "./index.js";
29
+ /** What to look for. Named so a record can say `email` without saying which email. */
30
+ export declare const PII_ENTITIES: readonly ["email", "phone", "card_number", "iban", "ssn", "ip_address"];
31
+ export type PiiEntity = (typeof PII_ENTITIES)[number];
32
+ /**
33
+ * What to do when something is found.
34
+ *
35
+ * `redact` by default, and the reasoning is worth stating because the other choice is defensible: refusing is
36
+ * safer and ruder. A support agent pasting a customer's email into a chat has done something ordinary, and a
37
+ * runtime that refuses the turn teaches them to paste it somewhere with no guardrail at all. Redaction keeps the
38
+ * conversation working while the value never reaches the model.
39
+ *
40
+ * `refuse` is right where the data must not have been sent at all — a card number, under most policies — so the
41
+ * default is per-entity rather than global.
42
+ */
43
+ export type PiiAction = "redact" | "refuse";
44
+ export type PiiOptions = {
45
+ /** Which entities to look for. Defaults to all of them. */
46
+ readonly entities?: readonly PiiEntity[];
47
+ /** Per-entity action. Anything unlisted uses `defaultAction`. */
48
+ readonly actions?: Partial<Record<PiiEntity, PiiAction>>;
49
+ /** Defaults to `redact` — see `PiiAction`. */
50
+ readonly defaultAction?: PiiAction;
51
+ };
52
+ /** Six hex characters of a salted-by-entity hash: enough to distinguish values, short enough to read. */
53
+ export declare const placeholderFor: (entity: PiiEntity, value: string) => string;
54
+ export type PiiFinding = {
55
+ readonly entity: PiiEntity;
56
+ readonly value: string;
57
+ };
58
+ /**
59
+ * Every entity in a string, longest match first so a redaction cannot corrupt an overlapping one.
60
+ *
61
+ * Exported because it is the whole detector and it deserves to be tested directly rather than through a
62
+ * guardrail's verdict.
63
+ */
64
+ export declare const findPii: (text: string, entities: readonly PiiEntity[]) => readonly PiiFinding[];
65
+ /** Replace every finding with its derived placeholder. Same value ⇒ same placeholder, always. */
66
+ export declare const redactText: (text: string, findings: readonly PiiFinding[]) => string;
67
+ /**
68
+ * A guardrail that finds personal data in the turn, in tool arguments and in tool results.
69
+ *
70
+ * All three, because each is a real path: the person types it, the model puts it in an argument, or a tool reads
71
+ * a document that contains it. A guardrail covering one of the three is a guardrail whose coverage nobody can
72
+ * state.
73
+ */
74
+ export declare const createPiiGuardrail: (options?: PiiOptions) => Guardrail;
75
+ //# sourceMappingURL=pii.d.ts.map
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Personal data, detected and redacted — REQ-046 (#205), task #212.
3
+ *
4
+ * Deterministic and **offline**. No network call, no model call: a guardrail that costs a round trip per turn is
5
+ * one a deployment switches off under load, and the moment it is off is the moment it was needed. Everything
6
+ * here is a pattern plus, where one exists, a checksum.
7
+ *
8
+ * ## Why a checksum matters more than a pattern
9
+ *
10
+ * A sixteen-digit order number matches every "card number" regex ever written. Flagging it teaches people that
11
+ * this guardrail cries wolf, and a guardrail people have learned to ignore is worse than none — they route
12
+ * around it. So card numbers are Luhn-checked and IBANs mod-97-checked, and a candidate that fails its checksum
13
+ * is *not* personal data, it is a number.
14
+ *
15
+ * ## Referential consistency, without state
16
+ *
17
+ * A placeholder is derived from a hash of the value, so the same value always yields the same placeholder —
18
+ * `[email:7a3f19]`. That is not a cosmetic choice. A model asked to compare two records, handed two *different*
19
+ * placeholders for one email, concludes the records differ and reasons wrongly about data it was never allowed
20
+ * to see. Deriving from the value keeps equality and nothing else.
21
+ *
22
+ * The alternative — a counter per turn — would need state keyed by run and would break the moment a run
23
+ * resumed in a different process, which is the normal case for a durable runtime.
24
+ *
25
+ * A hash placeholder does permit equality testing, and that is the intended trade: equality is exactly the
26
+ * property being preserved. It leaks no plaintext and is not reversible.
27
+ */
28
+ import { createHash } from "node:crypto";
29
+ /** What to look for. Named so a record can say `email` without saying which email. */
30
+ export const PII_ENTITIES = ["email", "phone", "card_number", "iban", "ssn", "ip_address"];
31
+ const LUHN_OK = (digits) => {
32
+ let sum = 0;
33
+ let double = false;
34
+ for (let i = digits.length - 1; i >= 0; i -= 1) {
35
+ let d = Number(digits[i]);
36
+ if (Number.isNaN(d))
37
+ return false;
38
+ if (double)
39
+ d = d * 2 > 9 ? d * 2 - 9 : d * 2;
40
+ sum += d;
41
+ double = !double;
42
+ }
43
+ return digits.length >= 13 && sum % 10 === 0;
44
+ };
45
+ /** ISO 7064 mod-97: move the first four characters to the end, letters to digits, remainder must be 1. */
46
+ const IBAN_OK = (value) => {
47
+ const normalized = value.replace(/[\s-]/g, "").toUpperCase();
48
+ if (!/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(normalized))
49
+ return false;
50
+ const rearranged = normalized.slice(4) + normalized.slice(0, 4);
51
+ const expanded = [...rearranged].map((c) => (/[A-Z]/.test(c) ? String(c.charCodeAt(0) - 55) : c)).join("");
52
+ let remainder = 0;
53
+ for (const digit of expanded)
54
+ remainder = (remainder * 10 + Number(digit)) % 97;
55
+ return remainder === 1;
56
+ };
57
+ /**
58
+ * A phone number, as distinct from any other run of digits.
59
+ *
60
+ * The corpus caught this: the first pattern matched an order number, an epoch timestamp and an invoice
61
+ * reference, giving 78.6% precision. A guardrail that fires on invoice numbers is one somebody switches off, and
62
+ * for this kind of check **precision matters more than recall** — a false positive is visible on every turn,
63
+ * a false negative is invisible until it matters, and only one of the two gets the guardrail disabled.
64
+ *
65
+ * Two conditions, both needed:
66
+ *
67
+ * - **9 to 15 digits.** Below nine and it is a date, an error code or a quantity; above fifteen and it is longer
68
+ * than E.164 permits, which usually means a card the Luhn check already rejected.
69
+ * - **A leading `+`, or at least two separators.** Humans group phone numbers — `020 7946 0958` — and machines
70
+ * do not group serial numbers. Two separators rather than one, because one is `INV-2026-0043`.
71
+ *
72
+ * A date like `2026-08-27` has two separators and is excluded by the digit count; a bare `1756300000` has the
73
+ * digits and is excluded by the grouping.
74
+ */
75
+ const PHONE_OK = (candidate) => {
76
+ const digits = (candidate.match(/\d/g) ?? []).length;
77
+ if (digits < 9 || digits > 15)
78
+ return false;
79
+ const separators = (candidate.match(/[\s\-()]/g) ?? []).length;
80
+ return candidate.trimStart().startsWith("+") || separators >= 2;
81
+ };
82
+ /**
83
+ * Detectors, each a pattern and an optional validator.
84
+ *
85
+ * Order matters: `card_number` runs before `phone`, because a long digit run matches both and a card number is
86
+ * the more consequential reading. Getting that backwards would redact a card as a phone number and apply the
87
+ * phone policy to it.
88
+ */
89
+ const DETECTORS = [
90
+ { entity: "email", pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
91
+ { entity: "iban", pattern: /\b[A-Z]{2}\d{2}[A-Z0-9 ]{10,34}\b/g, valid: IBAN_OK },
92
+ { entity: "card_number", pattern: /\b(?:\d[ -]?){13,19}\b/g, valid: (m) => LUHN_OK(m.replace(/[ -]/g, "")) },
93
+ { entity: "ssn", pattern: /\b\d{3}-\d{2}-\d{4}\b/g },
94
+ { entity: "ip_address", pattern: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g },
95
+ // Last: the loosest pattern, so a card or an IBAN has already claimed its digits. And validated, because
96
+ // "a run of digits" is not a phone number — see PHONE_OK.
97
+ { entity: "phone", pattern: /\+?\d[\d\s\-()]{7,20}\d/g, valid: PHONE_OK },
98
+ ];
99
+ /** Six hex characters of a salted-by-entity hash: enough to distinguish values, short enough to read. */
100
+ export const placeholderFor = (entity, value) => `[${entity}:${createHash("sha256").update(`${entity}:${value}`).digest("hex").slice(0, 6)}]`;
101
+ /**
102
+ * Every entity in a string, longest match first so a redaction cannot corrupt an overlapping one.
103
+ *
104
+ * Exported because it is the whole detector and it deserves to be tested directly rather than through a
105
+ * guardrail's verdict.
106
+ */
107
+ export const findPii = (text, entities) => {
108
+ const found = [];
109
+ const claimed = [];
110
+ for (const detector of DETECTORS) {
111
+ if (!entities.includes(detector.entity))
112
+ continue;
113
+ for (const match of text.matchAll(detector.pattern)) {
114
+ const start = match.index ?? 0;
115
+ const end = start + match[0].length;
116
+ // A span already claimed by an earlier (more specific) detector is not re-read as something looser.
117
+ if (claimed.some((c) => start < c.end && end > c.start))
118
+ continue;
119
+ const value = match[0].trim();
120
+ if (detector.valid && !detector.valid(value))
121
+ continue;
122
+ claimed.push({ start, end });
123
+ found.push({ entity: detector.entity, value });
124
+ }
125
+ }
126
+ return found;
127
+ };
128
+ /** Replace every finding with its derived placeholder. Same value ⇒ same placeholder, always. */
129
+ export const redactText = (text, findings) => {
130
+ let out = text;
131
+ // Longest first: replacing a short value that is a substring of a longer one would leave a fragment behind.
132
+ for (const finding of [...findings].sort((a, b) => b.value.length - a.value.length)) {
133
+ out = out.split(finding.value).join(placeholderFor(finding.entity, finding.value));
134
+ }
135
+ return out;
136
+ };
137
+ /** Walk any JSON-ish value, redacting strings. Tool arguments and results are objects, not prose. */
138
+ const redactDeep = (value, entities, found) => {
139
+ if (typeof value === "string") {
140
+ const hits = findPii(value, entities);
141
+ found.push(...hits);
142
+ return hits.length === 0 ? value : redactText(value, hits);
143
+ }
144
+ if (Array.isArray(value))
145
+ return value.map((v) => redactDeep(v, entities, found));
146
+ if (value !== null && typeof value === "object") {
147
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactDeep(v, entities, found)]));
148
+ }
149
+ return value;
150
+ };
151
+ /**
152
+ * A guardrail that finds personal data in the turn, in tool arguments and in tool results.
153
+ *
154
+ * All three, because each is a real path: the person types it, the model puts it in an argument, or a tool reads
155
+ * a document that contains it. A guardrail covering one of the three is a guardrail whose coverage nobody can
156
+ * state.
157
+ */
158
+ export const createPiiGuardrail = (options = {}) => {
159
+ const entities = options.entities ?? [...PII_ENTITIES];
160
+ const defaultAction = options.defaultAction ?? "redact";
161
+ const actionFor = (entity) => options.actions?.[entity] ?? defaultAction;
162
+ const decide = (findings, redacted) => {
163
+ if (findings.length === 0)
164
+ return { kind: "pass" };
165
+ const refusing = [...new Set(findings.filter((f) => actionFor(f.entity) === "refuse").map((f) => f.entity))];
166
+ if (refusing.length > 0) {
167
+ return {
168
+ kind: "refused",
169
+ code: "pii_present",
170
+ // Names the entity type, never the value — the message is shown to a person and stored in an event.
171
+ message: `That content contains ${refusing.join(", ")}. Please remove it and try again.`,
172
+ };
173
+ }
174
+ return { kind: "redacted", value: redacted(), what: [...new Set(findings.map((f) => f.entity))] };
175
+ };
176
+ return {
177
+ name: "pii",
178
+ inspectInput(input) {
179
+ const findings = findPii(input.text, entities);
180
+ return decide(findings, () => ({ ...input, text: redactText(input.text, findings) }));
181
+ },
182
+ inspectOutput(output, _context) {
183
+ if (output.kind === "message") {
184
+ const findings = findPii(output.text, entities);
185
+ return decide(findings, () => ({ ...output, text: redactText(output.text, findings) }));
186
+ }
187
+ const found = [];
188
+ const payload = redactDeep(output.kind === "tool-call" ? output.input : output.output, entities, found);
189
+ return decide(found, () => output.kind === "tool-call" ? { ...output, input: payload } : { ...output, output: payload });
190
+ },
191
+ };
192
+ };
193
+ //# sourceMappingURL=pii.js.map
@@ -116,4 +116,5 @@ export declare const createEmbeddingPipeline: (deps: EmbeddingPipelineDeps) => {
116
116
  export type EmbeddingPipeline = ReturnType<typeof createEmbeddingPipeline>;
117
117
  export * from "./chunking.js";
118
118
  export * from "./retrieval.js";
119
+ export * from "./navigate.js";
119
120
  //# sourceMappingURL=index.d.ts.map
@@ -163,4 +163,5 @@ export const createEmbeddingPipeline = (deps) => {
163
163
  };
164
164
  export * from "./chunking.js";
165
165
  export * from "./retrieval.js";
166
+ export * from "./navigate.js";
166
167
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Retrieval without vectors — REQ-050 (#209), task #219, AC-4.
3
+ *
4
+ * A **spike**, and the deliverable is a decision with numbers rather than a subsystem. See
5
+ * `docs/26-retrieval-quality.md` for what it scored.
6
+ *
7
+ * ## The idea being tested
8
+ *
9
+ * Embedding-based retrieval matches a query against fragments of text and hopes the fragments it surfaces are the
10
+ * ones that answer it. A person looking something up in a manual does something else entirely: they read the
11
+ * table of contents, decide which chapter is relevant, and then read it. That needs no index, no embedding cost
12
+ * and no re-indexing when a document changes — and its citations name a *document* somebody chose rather than a
13
+ * fragment a cosine distance surfaced.
14
+ *
15
+ * The cost is a model call per query, and latency measured in seconds rather than milliseconds.
16
+ *
17
+ * ## Two things this prototype found immediately
18
+ *
19
+ * **`KnowledgeStore` cannot enumerate its sources.** There is `listBySource`, `get`, `deleteSource` and
20
+ * `staleSources`, and no way to ask "what documents are in here". That is correct for a vector-based design —
21
+ * nothing needed it — and it is exactly what a navigating retriever needs first. So the outline arrives through
22
+ * a port the *host* supplies (`OutlineCatalogue`), which is honest but means this mode is not a drop-in for a
23
+ * deployment that already has hybrid retrieval working.
24
+ *
25
+ * **It does fit behind `RetrievalMode`**, which the issue asked to test. `createRetriever` gains one optional
26
+ * dependency and a fourth mode; every caller — `search_knowledge` included — is unchanged, and a deployment that
27
+ * has not wired a navigator gets a named refusal rather than a silent fall back to semantic search. If it had
28
+ * *not* fit, that would have been the finding; it fits.
29
+ */
30
+ import type { ExecutionContext } from "../core/context.js";
31
+ import type { KnowledgeChunk, KnowledgeStore } from "../persistence/index.js";
32
+ import type { RetrievalOutcome } from "./retrieval.js";
33
+ /** What a chooser sees of one document: enough to decide, and not the document itself. */
34
+ export type SourceOutline = {
35
+ readonly sourceType: KnowledgeChunk["sourceType"];
36
+ readonly sourceId: string;
37
+ readonly title: string;
38
+ /** The heading trail, in document order. This is the table of contents a person would read. */
39
+ readonly headings: readonly string[];
40
+ };
41
+ /**
42
+ * Where the outline comes from.
43
+ *
44
+ * A port because the store cannot answer it (see the note above). A host that keeps documents in its own tables —
45
+ * which most do, since `KnowledgeStore` holds *chunks* — already has this list.
46
+ */
47
+ export interface OutlineCatalogue {
48
+ list(context: {
49
+ readonly tenantId: ExecutionContext["tenantId"];
50
+ readonly authSubjects: readonly string[];
51
+ }): Promise<readonly SourceOutline[]>;
52
+ }
53
+ /**
54
+ * Whatever decides which documents to read. A model, in practice.
55
+ *
56
+ * A port rather than a model call, for the reason every model call in this package is a port: the platform must
57
+ * not acquire a provider, and a test must be able to pin the choice.
58
+ */
59
+ export interface DocumentChooser {
60
+ readonly id: string;
61
+ choose(input: {
62
+ readonly query: string;
63
+ readonly catalogue: readonly SourceOutline[];
64
+ readonly limit: number;
65
+ }): Promise<readonly string[]>;
66
+ }
67
+ export type NavigatorDeps = {
68
+ readonly store: KnowledgeStore;
69
+ readonly catalogue: OutlineCatalogue;
70
+ readonly chooser: DocumentChooser;
71
+ /** Documents the chooser may pick. More than a handful and the model is guessing rather than choosing. */
72
+ readonly maxSources?: number;
73
+ /** Chunks read per chosen document. A whole 200-page document would not fit the caller's context. */
74
+ readonly maxChunksPerSource?: number;
75
+ };
76
+ export declare const DEFAULT_MAX_SOURCES = 3;
77
+ export declare const DEFAULT_MAX_CHUNKS_PER_SOURCE = 40;
78
+ export interface Navigator {
79
+ readonly id: string;
80
+ navigate(context: {
81
+ readonly tenantId: ExecutionContext["tenantId"];
82
+ }, input: {
83
+ readonly query: string;
84
+ readonly authSubjects: readonly string[];
85
+ readonly limit: number;
86
+ }): Promise<RetrievalOutcome>;
87
+ }
88
+ export declare const createNavigator: (deps: NavigatorDeps) => Navigator;
89
+ //# sourceMappingURL=navigate.d.ts.map