@personaxis/core 0.11.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 (106) hide show
  1. package/LICENSE +21 -0
  2. package/dist/agent.d.ts +105 -0
  3. package/dist/agent.js +429 -0
  4. package/dist/appraisal.d.ts +196 -0
  5. package/dist/appraisal.js +159 -0
  6. package/dist/approval.d.ts +42 -0
  7. package/dist/approval.js +71 -0
  8. package/dist/blackboard.d.ts +86 -0
  9. package/dist/blackboard.js +139 -0
  10. package/dist/compile/assemble.d.ts +44 -0
  11. package/dist/compile/assemble.js +338 -0
  12. package/dist/compile/dist.d.ts +30 -0
  13. package/dist/compile/dist.js +66 -0
  14. package/dist/compile/faithfulness.d.ts +47 -0
  15. package/dist/compile/faithfulness.js +112 -0
  16. package/dist/compile/index.d.ts +10 -0
  17. package/dist/compile/index.js +10 -0
  18. package/dist/compile/targets.d.ts +62 -0
  19. package/dist/compile/targets.js +114 -0
  20. package/dist/config-layers.d.ts +42 -0
  21. package/dist/config-layers.js +59 -0
  22. package/dist/config-scan.d.ts +30 -0
  23. package/dist/config-scan.js +118 -0
  24. package/dist/context.d.ts +67 -0
  25. package/dist/context.js +178 -0
  26. package/dist/envelopes.d.ts +48 -0
  27. package/dist/envelopes.js +131 -0
  28. package/dist/events.d.ts +135 -0
  29. package/dist/events.js +19 -0
  30. package/dist/evolution-view.d.ts +43 -0
  31. package/dist/evolution-view.js +67 -0
  32. package/dist/generated/version.d.ts +1 -0
  33. package/dist/generated/version.js +2 -0
  34. package/dist/governance.d.ts +108 -0
  35. package/dist/governance.js +204 -0
  36. package/dist/heuristic-appraiser.d.ts +13 -0
  37. package/dist/heuristic-appraiser.js +48 -0
  38. package/dist/hooks.d.ts +52 -0
  39. package/dist/hooks.js +122 -0
  40. package/dist/index.d.ts +53 -0
  41. package/dist/index.js +53 -0
  42. package/dist/injection.d.ts +48 -0
  43. package/dist/injection.js +141 -0
  44. package/dist/live-sync.d.ts +60 -0
  45. package/dist/live-sync.js +85 -0
  46. package/dist/llm-appraiser.d.ts +31 -0
  47. package/dist/llm-appraiser.js +143 -0
  48. package/dist/lock.d.ts +26 -0
  49. package/dist/lock.js +99 -0
  50. package/dist/loop.d.ts +52 -0
  51. package/dist/loop.js +253 -0
  52. package/dist/memory-kinds.d.ts +67 -0
  53. package/dist/memory-kinds.js +106 -0
  54. package/dist/memory.d.ts +123 -0
  55. package/dist/memory.js +228 -0
  56. package/dist/model-config.d.ts +60 -0
  57. package/dist/model-config.js +102 -0
  58. package/dist/persona-theme.d.ts +57 -0
  59. package/dist/persona-theme.js +183 -0
  60. package/dist/persona.d.ts +88 -0
  61. package/dist/persona.js +93 -0
  62. package/dist/ports/index.d.ts +81 -0
  63. package/dist/ports/index.js +50 -0
  64. package/dist/provenance.d.ts +63 -0
  65. package/dist/provenance.js +132 -0
  66. package/dist/recompile-marker.d.ts +19 -0
  67. package/dist/recompile-marker.js +36 -0
  68. package/dist/registry.d.ts +99 -0
  69. package/dist/registry.js +168 -0
  70. package/dist/responder.d.ts +47 -0
  71. package/dist/responder.js +78 -0
  72. package/dist/sandbox.d.ts +120 -0
  73. package/dist/sandbox.js +265 -0
  74. package/dist/self-evolution.d.ts +148 -0
  75. package/dist/self-evolution.js +364 -0
  76. package/dist/session-writer.d.ts +52 -0
  77. package/dist/session-writer.js +104 -0
  78. package/dist/sessions.d.ts +96 -0
  79. package/dist/sessions.js +179 -0
  80. package/dist/sigil.d.ts +41 -0
  81. package/dist/sigil.js +92 -0
  82. package/dist/skill-lifecycle.d.ts +74 -0
  83. package/dist/skill-lifecycle.js +126 -0
  84. package/dist/skill-review.d.ts +25 -0
  85. package/dist/skill-review.js +67 -0
  86. package/dist/spec-edit.d.ts +30 -0
  87. package/dist/spec-edit.js +137 -0
  88. package/dist/state-engine.d.ts +40 -0
  89. package/dist/state-engine.js +50 -0
  90. package/dist/state-rebuild.d.ts +48 -0
  91. package/dist/state-rebuild.js +60 -0
  92. package/dist/sync.d.ts +32 -0
  93. package/dist/sync.js +95 -0
  94. package/dist/tool-calling.d.ts +59 -0
  95. package/dist/tool-calling.js +168 -0
  96. package/dist/tool-repair.d.ts +20 -0
  97. package/dist/tool-repair.js +119 -0
  98. package/dist/tools/exec.d.ts +46 -0
  99. package/dist/tools/exec.js +132 -0
  100. package/dist/tools/registry.d.ts +38 -0
  101. package/dist/tools/registry.js +162 -0
  102. package/dist/trace.d.ts +63 -0
  103. package/dist/trace.js +162 -0
  104. package/dist/verification.d.ts +65 -0
  105. package/dist/verification.js +190 -0
  106. package/package.json +38 -0
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Prompt-injection detection (F3 / T7 — plan/11-security).
3
+ *
4
+ * A layered, evasion-aware first line of defense over untrusted text (tool output,
5
+ * fetched content, project files, observations). Heuristic guardrails are exactly
6
+ * what production systems run *before* a model-based classifier; this engine is
7
+ * built to be that first line and to hand off to a model classifier when present.
8
+ *
9
+ * Layers:
10
+ * 1. Normalization — NFKC, strip/flag zero-width + bidi-override chars, fold a
11
+ * homoglyph (confusables) map. Adversaries obfuscate; we de-obfuscate first.
12
+ * 2. Decoding — base64 / hex blocks are decoded and recursively scanned, so an
13
+ * "ignore previous instructions" hidden inside base64 is still caught.
14
+ * 3. Weighted rules across categories (instruction-override, exfiltration,
15
+ * role-manipulation, tool-abuse, jailbreak, obfuscation, encoding-evasion).
16
+ * 4. Score aggregation -> clean | suspicious | malicious, with a confidence.
17
+ *
18
+ * Pluggable: pass a `classifier` to layer a model-based judgment on top.
19
+ */
20
+ export type InjectionSeverity = "info" | "suspicious" | "malicious";
21
+ export type InjectionCategory = "instruction-override" | "exfiltration" | "role-manipulation" | "tool-abuse" | "jailbreak" | "obfuscation" | "encoding-evasion";
22
+ export interface InjectionFinding {
23
+ rule: string;
24
+ category: InjectionCategory;
25
+ severity: InjectionSeverity;
26
+ weight: number;
27
+ match: string;
28
+ }
29
+ export interface InjectionScan {
30
+ verdict: "clean" | "suspicious" | "malicious";
31
+ /** Aggregate risk score (0..1+). */
32
+ score: number;
33
+ findings: InjectionFinding[];
34
+ /** Text after normalization (de-obfuscation) — what the rules actually saw. */
35
+ normalized: string;
36
+ /** Decoded base64/hex segments that were also scanned. */
37
+ decoded: string[];
38
+ }
39
+ export interface InjectionConfig {
40
+ suspiciousAt: number;
41
+ maliciousAt: number;
42
+ /** Optional model-based classifier; its score is fused with the heuristic. */
43
+ classifier?: (normalized: string) => {
44
+ score: number;
45
+ label?: string;
46
+ };
47
+ }
48
+ export declare function scanForInjection(text: string, config?: Partial<InjectionConfig>): InjectionScan;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Prompt-injection detection (F3 / T7 — plan/11-security).
3
+ *
4
+ * A layered, evasion-aware first line of defense over untrusted text (tool output,
5
+ * fetched content, project files, observations). Heuristic guardrails are exactly
6
+ * what production systems run *before* a model-based classifier; this engine is
7
+ * built to be that first line and to hand off to a model classifier when present.
8
+ *
9
+ * Layers:
10
+ * 1. Normalization — NFKC, strip/flag zero-width + bidi-override chars, fold a
11
+ * homoglyph (confusables) map. Adversaries obfuscate; we de-obfuscate first.
12
+ * 2. Decoding — base64 / hex blocks are decoded and recursively scanned, so an
13
+ * "ignore previous instructions" hidden inside base64 is still caught.
14
+ * 3. Weighted rules across categories (instruction-override, exfiltration,
15
+ * role-manipulation, tool-abuse, jailbreak, obfuscation, encoding-evasion).
16
+ * 4. Score aggregation -> clean | suspicious | malicious, with a confidence.
17
+ *
18
+ * Pluggable: pass a `classifier` to layer a model-based judgment on top.
19
+ */
20
+ const DEFAULT_CONFIG = { suspiciousAt: 0.4, maliciousAt: 0.9 };
21
+ const RULES = [
22
+ { re: /ignore\s+(all\s+|any\s+)?(previous|prior|above|earlier)\s+(instructions?|prompts?|messages?|context)/i, category: "instruction-override", severity: "malicious", weight: 1.0, rule: "ignore-previous" },
23
+ { re: /disregard\s+(the\s+|all\s+|any\s+)?(system|previous|above|prior|earlier)/i, category: "instruction-override", severity: "malicious", weight: 1.0, rule: "disregard" },
24
+ { re: /forget\s+(everything|all|your\s+(instructions|rules|guidelines))/i, category: "instruction-override", severity: "malicious", weight: 0.9, rule: "forget-everything" },
25
+ { re: /(reveal|print|show|repeat|output|leak)\s+(me\s+)?(your|the)\s+(system\s*prompt|instructions|persona|secret|hidden\s+(rules|prompt))/i, category: "exfiltration", severity: "malicious", weight: 1.0, rule: "prompt-exfil" },
26
+ { re: /(send|post|exfiltrate|upload|transmit|email)\s+.{0,40}\b(to|at|toward)\b\s*(https?:\/\/|[\w.-]+@)/i, category: "exfiltration", severity: "malicious", weight: 1.0, rule: "exfil-destination" },
27
+ { re: /override\s+(your\s+)?(safety|guardrails?|governance|constraints?|filters?|policy)/i, category: "instruction-override", severity: "malicious", weight: 1.0, rule: "override-safety" },
28
+ { re: /\bBEGIN\s+SYSTEM\b|<\s*\/?\s*system\s*>|\[\s*system\s*\]/i, category: "role-manipulation", severity: "suspicious", weight: 0.6, rule: "fake-system-block" },
29
+ { re: /you\s+are\s+now\s+(a|an|the|in)\b|act\s+as\s+(if\s+you\s+are\s+)?(a|an|the)\b/i, category: "role-manipulation", severity: "suspicious", weight: 0.5, rule: "role-override" },
30
+ { re: /from\s+now\s+on,?\s+(you|always|never|respond|answer|act)\b/i, category: "role-manipulation", severity: "suspicious", weight: 0.5, rule: "persistent-instruction" },
31
+ { re: /\b(developer\s+mode|jailbreak|DAN\b|do\s+anything\s+now|unfiltered|no\s+restrictions?)\b/i, category: "jailbreak", severity: "suspicious", weight: 0.6, rule: "jailbreak-keyword" },
32
+ { re: /(call|invoke|run|execute)\s+(the\s+)?(tool|function|command|shell|bash)\b.{0,40}(delete|rm\b|curl|wget|exfil|secret|cred)/i, category: "tool-abuse", severity: "malicious", weight: 0.9, rule: "tool-abuse" },
33
+ ];
34
+ // A compact confusables map (Cyrillic/Greek lookalikes -> Latin) for homoglyph folding.
35
+ const CONFUSABLES = {
36
+ "а": "a", "е": "e", "о": "o", "р": "p", "с": "c", "х": "x", "у": "y", "і": "i",
37
+ "Ι": "I", "Ο": "O", "А": "A", "Е": "E", "О": "O", "Р": "P", "С": "C",
38
+ "ο": "o", "α": "a", "ε": "e", "ρ": "p", "υ": "u",
39
+ };
40
+ const ZERO_WIDTH = new RegExp("[" + [0x200B, 0x200C, 0x200D, 0x2060, 0xFEFF].map((c) => String.fromCharCode(c)).join("") + "]", "g");
41
+ const BIDI_OVERRIDE = new RegExp("[" + [0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, 0x2067, 0x2068, 0x2069].map((c) => String.fromCharCode(c)).join("") + "]", "g");
42
+ function normalize(text) {
43
+ const obfuscations = [];
44
+ let t = text.normalize("NFKC");
45
+ // Use replace-and-compare (not .test) — a /g regex with .test() is stateful and
46
+ // would yield false negatives when the singleton regex is reused across scans.
47
+ const noZw = t.replace(ZERO_WIDTH, "");
48
+ if (noZw !== t) {
49
+ obfuscations.push({ rule: "zero-width-chars", category: "obfuscation", severity: "suspicious", weight: 0.4, match: "<zero-width>" });
50
+ t = noZw;
51
+ }
52
+ const noBidi = t.replace(BIDI_OVERRIDE, "");
53
+ if (noBidi !== t) {
54
+ obfuscations.push({ rule: "bidi-override", category: "obfuscation", severity: "suspicious", weight: 0.4, match: "<bidi-override>" });
55
+ t = noBidi;
56
+ }
57
+ let folded = "";
58
+ let homoglyphHit = false;
59
+ for (const ch of t) {
60
+ const sub = CONFUSABLES[ch];
61
+ if (sub) {
62
+ homoglyphHit = true;
63
+ folded += sub;
64
+ }
65
+ else
66
+ folded += ch;
67
+ }
68
+ if (homoglyphHit) {
69
+ obfuscations.push({ rule: "homoglyph", category: "obfuscation", severity: "suspicious", weight: 0.4, match: "<confusable-chars>" });
70
+ }
71
+ return { normalized: folded, obfuscations };
72
+ }
73
+ function decodeSegments(text) {
74
+ const out = [];
75
+ // base64 runs (length-aware to avoid plain words)
76
+ for (const m of text.matchAll(/[A-Za-z0-9+/]{16,}={0,2}/g)) {
77
+ try {
78
+ const decoded = Buffer.from(m[0], "base64").toString("utf8");
79
+ if (decoded && /[\x20-\x7e]/.test(decoded) && printableRatio(decoded) > 0.8)
80
+ out.push(decoded);
81
+ }
82
+ catch {
83
+ /* ignore */
84
+ }
85
+ }
86
+ // hex runs
87
+ for (const m of text.matchAll(/(?:[0-9a-fA-F]{2}){8,}/g)) {
88
+ try {
89
+ const decoded = Buffer.from(m[0], "hex").toString("utf8");
90
+ if (decoded && printableRatio(decoded) > 0.8)
91
+ out.push(decoded);
92
+ }
93
+ catch {
94
+ /* ignore */
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+ function printableRatio(s) {
100
+ if (!s.length)
101
+ return 0;
102
+ let p = 0;
103
+ for (const c of s)
104
+ if (c >= " " && c <= "~")
105
+ p++;
106
+ return p / s.length;
107
+ }
108
+ function applyRules(text, decodedOrigin = false) {
109
+ const findings = [];
110
+ for (const r of RULES) {
111
+ const m = text.match(r.re);
112
+ if (m) {
113
+ findings.push({
114
+ rule: decodedOrigin ? `encoded:${r.rule}` : r.rule,
115
+ category: decodedOrigin ? "encoding-evasion" : r.category,
116
+ severity: r.severity,
117
+ weight: decodedOrigin ? Math.min(1, r.weight + 0.2) : r.weight,
118
+ match: m[0].slice(0, 80),
119
+ });
120
+ }
121
+ }
122
+ return findings;
123
+ }
124
+ export function scanForInjection(text, config = {}) {
125
+ const cfg = { ...DEFAULT_CONFIG, ...config };
126
+ const { normalized, obfuscations } = normalize(text);
127
+ const findings = [...obfuscations, ...applyRules(normalized)];
128
+ const decoded = decodeSegments(normalized);
129
+ for (const d of decoded)
130
+ findings.push(...applyRules(d, true));
131
+ let score = findings.reduce((s, f) => s + f.weight, 0);
132
+ if (cfg.classifier) {
133
+ const c = cfg.classifier(normalized);
134
+ score = Math.max(score, c.score); // fuse: take the stronger signal
135
+ if (c.score >= cfg.suspiciousAt) {
136
+ findings.push({ rule: `classifier:${c.label ?? "flagged"}`, category: "instruction-override", severity: c.score >= cfg.maliciousAt ? "malicious" : "suspicious", weight: c.score, match: "<model>" });
137
+ }
138
+ }
139
+ const verdict = score >= cfg.maliciousAt ? "malicious" : score >= cfg.suspiciousAt ? "suspicious" : "clean";
140
+ return { verdict, score: Number(score.toFixed(3)), findings, normalized, decoded };
141
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Live-sync (F5 — plan/05-interop).
3
+ *
4
+ * When a persona's runtime state drifts, a host watching the compiled doc must be
5
+ * able to tell. The compiled `PERSONA.md` is a *purely qualitative* document, so we
6
+ * never inject numeric state into it. Instead the drift is synced deterministically:
7
+ *
8
+ * - write a `.live.json` notify marker (state hash + counts + current values + ts)
9
+ * a host watcher can poll to know "the persona changed";
10
+ * - self-heal: if an older version left a delimited LIVE-STATE block inside the
11
+ * compiled doc, strip it (state belongs in state.json / .live.json, not in prose).
12
+ *
13
+ * This is the loop's `recompile` hook. The qualitative recompile remains a separate,
14
+ * provider-backed step; nothing here invents or mutates prose.
15
+ */
16
+ import { type PersonaHandle, type StateFile } from "./persona.js";
17
+ export declare const LIVE_START = "<!-- PERSONAXIS:LIVE-STATE start -->";
18
+ export declare const LIVE_END = "<!-- PERSONAXIS:LIVE-STATE end -->";
19
+ /**
20
+ * Remove a residual LIVE-STATE block (and its surrounding blank space) from a compiled
21
+ * doc. Idempotent: returns the input unchanged when no block is present. Used to migrate
22
+ * docs written by older versions that injected numeric state into the prose.
23
+ */
24
+ export declare function stripLiveBlock(doc: string): string;
25
+ export interface LiveMarker {
26
+ ts: string;
27
+ state_hash: string;
28
+ mutations: number;
29
+ values: Record<string, number>;
30
+ }
31
+ export declare function stateHash(state: StateFile): string;
32
+ /** Write the notify marker beside the persona; returns it. */
33
+ export declare function writeLiveMarker(personaPath: string, state: StateFile): LiveMarker;
34
+ /**
35
+ * Sync runtime state: write the `.live.json` marker and self-heal the compiled doc by
36
+ * stripping any residual LIVE-STATE block. The compiled doc's prose is never written
37
+ * with numeric state.
38
+ */
39
+ export declare function liveSync(handle: PersonaHandle, compiledPath: string | undefined, state: StateFile): LiveMarker;
40
+ export interface RecompileHookOptions {
41
+ /** Path to the compiled doc to keep in sync (marker + live-block self-heal). */
42
+ compiledPath?: string;
43
+ /**
44
+ * F3.1 — optional DETERMINISTIC inline recompile. When provided, on drift the
45
+ * hook also rewrites `compiledPath` with this freshly-assembled document (the
46
+ * stage-1 assembler, no provider — cheap, no tokens). This is what makes the
47
+ * loop's `recompile` a real recompile rather than a marker: the compiled doc
48
+ * reflects the evolved spec immediately. A later `personaxis compile` re-polishes.
49
+ * Returns undefined to skip the rewrite for a given tick.
50
+ */
51
+ assemble?: (handle: PersonaHandle) => string | undefined;
52
+ }
53
+ /**
54
+ * Build a `recompile` hook for the LivingLoop: on numeric drift it writes the `.live.json`
55
+ * notify marker (and strips any residual live block from the compiled doc). When an
56
+ * `assemble` function is supplied (F3.1) it ALSO rewrites the compiled doc deterministically
57
+ * — a cheap, provider-free inline recompile. The qualitative LLM POLISH remains a separate,
58
+ * provider-backed step (`personaxis compile`).
59
+ */
60
+ export declare function makeRecompileHook(opts?: string | RecompileHookOptions): (handle: PersonaHandle) => Promise<void>;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Live-sync (F5 — plan/05-interop).
3
+ *
4
+ * When a persona's runtime state drifts, a host watching the compiled doc must be
5
+ * able to tell. The compiled `PERSONA.md` is a *purely qualitative* document, so we
6
+ * never inject numeric state into it. Instead the drift is synced deterministically:
7
+ *
8
+ * - write a `.live.json` notify marker (state hash + counts + current values + ts)
9
+ * a host watcher can poll to know "the persona changed";
10
+ * - self-heal: if an older version left a delimited LIVE-STATE block inside the
11
+ * compiled doc, strip it (state belongs in state.json / .live.json, not in prose).
12
+ *
13
+ * This is the loop's `recompile` hook. The qualitative recompile remains a separate,
14
+ * provider-backed step; nothing here invents or mutates prose.
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { readState } from "./persona.js";
20
+ export const LIVE_START = "<!-- PERSONAXIS:LIVE-STATE start -->";
21
+ export const LIVE_END = "<!-- PERSONAXIS:LIVE-STATE end -->";
22
+ /**
23
+ * Remove a residual LIVE-STATE block (and its surrounding blank space) from a compiled
24
+ * doc. Idempotent: returns the input unchanged when no block is present. Used to migrate
25
+ * docs written by older versions that injected numeric state into the prose.
26
+ */
27
+ export function stripLiveBlock(doc) {
28
+ const start = doc.indexOf(LIVE_START);
29
+ const end = doc.indexOf(LIVE_END);
30
+ if (start === -1 || end === -1 || end < start)
31
+ return doc;
32
+ const before = doc.slice(0, start).replace(/\s+$/, "");
33
+ const after = doc.slice(end + LIVE_END.length).replace(/^\s+/, "");
34
+ return after ? `${before}\n\n${after}` : `${before}\n`;
35
+ }
36
+ function markerPath(personaPath) {
37
+ return join(dirname(personaPath), ".live.json");
38
+ }
39
+ export function stateHash(state) {
40
+ return createHash("sha256").update(JSON.stringify(state.values)).digest("hex").slice(0, 16);
41
+ }
42
+ /** Write the notify marker beside the persona; returns it. */
43
+ export function writeLiveMarker(personaPath, state) {
44
+ const marker = {
45
+ ts: new Date().toISOString(),
46
+ state_hash: stateHash(state),
47
+ mutations: state.mutation_log.length,
48
+ values: state.values,
49
+ };
50
+ writeFileSync(markerPath(personaPath), JSON.stringify(marker, null, 2) + "\n", "utf-8");
51
+ return marker;
52
+ }
53
+ /**
54
+ * Sync runtime state: write the `.live.json` marker and self-heal the compiled doc by
55
+ * stripping any residual LIVE-STATE block. The compiled doc's prose is never written
56
+ * with numeric state.
57
+ */
58
+ export function liveSync(handle, compiledPath, state) {
59
+ if (compiledPath && existsSync(compiledPath)) {
60
+ const doc = readFileSync(compiledPath, "utf-8");
61
+ const cleaned = stripLiveBlock(doc);
62
+ if (cleaned !== doc)
63
+ writeFileSync(compiledPath, cleaned, "utf-8");
64
+ }
65
+ return writeLiveMarker(handle.personaPath, state);
66
+ }
67
+ /**
68
+ * Build a `recompile` hook for the LivingLoop: on numeric drift it writes the `.live.json`
69
+ * notify marker (and strips any residual live block from the compiled doc). When an
70
+ * `assemble` function is supplied (F3.1) it ALSO rewrites the compiled doc deterministically
71
+ * — a cheap, provider-free inline recompile. The qualitative LLM POLISH remains a separate,
72
+ * provider-backed step (`personaxis compile`).
73
+ */
74
+ export function makeRecompileHook(opts) {
75
+ const options = typeof opts === "string" ? { compiledPath: opts } : opts ?? {};
76
+ return async (handle) => {
77
+ const state = readState(handle.statePath);
78
+ liveSync(handle, options.compiledPath, state);
79
+ if (options.assemble && options.compiledPath && existsSync(options.compiledPath)) {
80
+ const doc = options.assemble(handle);
81
+ if (doc && doc.trim())
82
+ writeFileSync(options.compiledPath, doc.trimEnd() + "\n", "utf-8");
83
+ }
84
+ };
85
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * LLM appraiser — small-model feasibility via constrained decoding (F2).
3
+ *
4
+ * The model NEVER emits a state mutation by hand. It emits a structured appraisal
5
+ * signal under APPRAISAL_JSON_SCHEMA, enforced by the server's constrained
6
+ * decoding (llama.cpp / Ollama `response_format: json_schema`, or a GBNF grammar).
7
+ * The spec engine then clamps + governs. The model proposes signals; the code +
8
+ * the spec impose safety — viable on <=4B and safe at the same time.
9
+ *
10
+ * Talks to any OpenAI-compatible /chat/completions endpoint (Ollama, llama.cpp
11
+ * server, LM Studio, or a hosted model). Dependency-free (uses global fetch).
12
+ */
13
+ import { type AppraiseInput, type AppraisalSignal, type Appraiser } from "./appraisal.js";
14
+ export interface LlmAppraiserConfig {
15
+ /** OpenAI-compatible base URL, e.g. http://localhost:11434/v1 (Ollama). */
16
+ endpoint: string;
17
+ model: string;
18
+ /** Optional bearer token (hosted endpoints). Local servers need none. */
19
+ apiKey?: string;
20
+ /** Hard cap on the response; appraisal signals are tiny. */
21
+ maxTokens?: number;
22
+ /** Abort the request after this many ms so a hung endpoint never blocks a turn (default 30s). */
23
+ timeoutMs?: number;
24
+ /** Injected for tests; defaults to global fetch. */
25
+ fetchImpl?: typeof fetch;
26
+ }
27
+ export declare class LlmAppraiser implements Appraiser {
28
+ private readonly cfg;
29
+ constructor(cfg: LlmAppraiserConfig);
30
+ appraise(input: AppraiseInput): Promise<AppraisalSignal>;
31
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * LLM appraiser — small-model feasibility via constrained decoding (F2).
3
+ *
4
+ * The model NEVER emits a state mutation by hand. It emits a structured appraisal
5
+ * signal under APPRAISAL_JSON_SCHEMA, enforced by the server's constrained
6
+ * decoding (llama.cpp / Ollama `response_format: json_schema`, or a GBNF grammar).
7
+ * The spec engine then clamps + governs. The model proposes signals; the code +
8
+ * the spec impose safety — viable on <=4B and safe at the same time.
9
+ *
10
+ * Talks to any OpenAI-compatible /chat/completions endpoint (Ollama, llama.cpp
11
+ * server, LM Studio, or a hosted model). Dependency-free (uses global fetch).
12
+ */
13
+ import { APPRAISAL_JSON_SCHEMA, parseAppraisalSignal, portableJsonSchema, } from "./appraisal.js";
14
+ import { renderEvolutionView } from "./evolution-view.js";
15
+ const SYSTEM = `You are the appraisal module of a governed AI persona runtime.
16
+ Given the persona's identity and a new observation, output ONLY a JSON object that
17
+ matches the provided schema:
18
+ - "appraisal": a brief read of the situation;
19
+ - "mutations": optional small envelope nudges (field + signed delta in [-1,1] + reason);
20
+ - "memories": optional notes to remember (content + source);
21
+ - "selfEdits": optional durable edits to the persona SPEC, by dot-path. Each item is
22
+ { "targetPath": "<dot.path>", "toValue": <the full new value>, "rationale": "<why>" }.
23
+ You may ONLY target the editable sections listed below; identity/character/hard_limits/safety
24
+ are protected and rejected. Propose these RARELY — only when the observation clearly warrants a
25
+ lasting change. But when the user EXPLICITLY authorizes a durable change to an editable section,
26
+ you MUST express it as a selfEdit (the structured field) — never only in the "appraisal" prose.
27
+ The "toValue" is the replacement value, not a delta: for a scalar give the new number/string;
28
+ for an object give the whole new object. Worked example — user says "permanently lower your
29
+ uncertainty disclosure threshold to 0.10":
30
+ "selfEdits": [{ "targetPath": "cognition.uncertainty_policy.disclose_when_above",
31
+ "toValue": 0.10, "rationale": "user authorized a durable lower disclosure threshold" }]
32
+ - "preferences": optional stable user preferences you inferred (key + value);
33
+ - "confidence" in [0,1] (self-edits/preferences are only considered at confidence >= 0.6).
34
+ Propose only minimal, well-justified changes. You are NOT applying anything — the runtime
35
+ clamps, governs (mode + consensus + protected paths), and may queue your proposal.`;
36
+ export class LlmAppraiser {
37
+ cfg;
38
+ constructor(cfg) {
39
+ this.cfg = cfg;
40
+ }
41
+ async appraise(input) {
42
+ const fetchImpl = this.cfg.fetchImpl ?? fetch;
43
+ // F3.8: prefer the grounded evolution view (current values + envelopes + mode)
44
+ // over the bare field names, so the model proposes deltas against reality.
45
+ const evolutionBlock = input.evolutionView
46
+ ? renderEvolutionView(input.evolutionView)
47
+ : [
48
+ `# Mutable envelope fields you may nudge`,
49
+ input.mutableFields.join(", ") || "(none)",
50
+ ``,
51
+ `# Editable spec sections you may propose self-edits to`,
52
+ (input.editableSections ?? []).join(", ") || "(none — do not propose selfEdits)",
53
+ ].join("\n");
54
+ const userMsg = [
55
+ `# Persona identity (slot #1)`,
56
+ input.personaBody.slice(0, 4000),
57
+ ``,
58
+ evolutionBlock,
59
+ ``,
60
+ `# Observation [source: ${input.source}]`,
61
+ input.observation,
62
+ ].join("\n");
63
+ const base = {
64
+ model: this.cfg.model,
65
+ messages: [
66
+ { role: "system", content: SYSTEM },
67
+ { role: "user", content: userMsg },
68
+ ],
69
+ temperature: 0.4,
70
+ max_tokens: this.cfg.maxTokens ?? 512,
71
+ };
72
+ // Constrained decoding, most-constrained first. Endpoints accept different
73
+ // subsets: llama.cpp/Ollama and OpenAI take full `json_schema`; Cohere/Groq
74
+ // reject value-constraint keywords (so we send the portable schema) and some
75
+ // accept only `json_object`; the rest just need a JSON-shaped prompt. We degrade
76
+ // gracefully so a strict backend never kills the loop. Safety is downstream
77
+ // (clamp + parseAppraisalSignal), never the model's to enforce.
78
+ const strategies = [
79
+ {
80
+ type: "json_schema",
81
+ json_schema: {
82
+ name: "appraisal_signal",
83
+ strict: true,
84
+ schema: portableJsonSchema(APPRAISAL_JSON_SCHEMA),
85
+ },
86
+ },
87
+ { type: "json_object" },
88
+ undefined,
89
+ ];
90
+ let lastErr = "no response";
91
+ for (const responseFormat of strategies) {
92
+ const body = responseFormat ? { ...base, response_format: responseFormat } : base;
93
+ // Hard timeout so a slow/hung hosted endpoint never blocks the turn forever.
94
+ const ctrl = new AbortController();
95
+ const timer = setTimeout(() => ctrl.abort(), this.cfg.timeoutMs ?? 30_000);
96
+ let res;
97
+ try {
98
+ res = await fetchImpl(`${this.cfg.endpoint.replace(/\/$/, "")}/chat/completions`, {
99
+ method: "POST",
100
+ headers: {
101
+ "content-type": "application/json",
102
+ ...(this.cfg.apiKey ? { authorization: `Bearer ${this.cfg.apiKey}` } : {}),
103
+ },
104
+ body: JSON.stringify(body),
105
+ signal: ctrl.signal,
106
+ });
107
+ }
108
+ finally {
109
+ clearTimeout(timer);
110
+ }
111
+ if (res.ok) {
112
+ const json = (await res.json());
113
+ const content = json.choices?.[0]?.message?.content ?? "{}";
114
+ let parsed;
115
+ try {
116
+ parsed = JSON.parse(content);
117
+ }
118
+ catch {
119
+ // Some servers wrap JSON in prose; extract the first {...} block.
120
+ const m = content.match(/\{[\s\S]*\}/);
121
+ parsed = m ? JSON.parse(m[0]) : {};
122
+ }
123
+ return parseAppraisalSignal(parsed);
124
+ }
125
+ lastErr = `HTTP ${res.status}: ${await safeText(res)}`;
126
+ // Auth, rate-limit and server errors won't be fixed by relaxing the
127
+ // response_format — surface them immediately. Only 400/422 (unsupported
128
+ // response_format/schema) are worth retrying with a looser strategy.
129
+ if (res.status === 401 || res.status === 403 || res.status === 429 || res.status >= 500) {
130
+ break;
131
+ }
132
+ }
133
+ throw new Error(`LLM appraiser ${lastErr}`);
134
+ }
135
+ }
136
+ async function safeText(res) {
137
+ try {
138
+ return (await res.text()).slice(0, 200);
139
+ }
140
+ catch {
141
+ return "";
142
+ }
143
+ }
package/dist/lock.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Per-persona state lock — same-machine concurrency control (F1.4 / ADR-009).
3
+ *
4
+ * Multiple processes write a persona's state.json by design (REPL + serve + watch +
5
+ * MCP + hooks). Without a lock, read→modify→write races lose mutation_log entries —
6
+ * unacceptable for a governed, audited runtime.
7
+ *
8
+ * Mechanism: a lock DIRECTORY next to the file (`state.json.lock/`). mkdir is atomic
9
+ * on every OS/filesystem we target; the holder records `owner.json` {pid, ts}. A lock
10
+ * is stale (stealable) when its owner process is dead or its timestamp is older than
11
+ * STALE_MS (a holder never legitimately holds it that long: locks wrap only the
12
+ * mechanical read→apply→write section, never a model call). Waiters retry with a
13
+ * short sync sleep up to WAIT_TIMEOUT_MS, then fail loudly — silent lock-skipping
14
+ * would defeat the audit guarantee.
15
+ *
16
+ * This does NOT solve cross-machine sync (that is sync.ts's job) — only same-machine.
17
+ */
18
+ /**
19
+ * Acquire the lock for `targetPath` (e.g. a state.json), returning a release fn.
20
+ * Prefer `withStateLock` — it guarantees release.
21
+ */
22
+ export declare function acquireStateLock(targetPath: string): () => void;
23
+ /** Run `fn` holding the lock for `targetPath`. The lock is always released. */
24
+ export declare function withStateLock<T>(targetPath: string, fn: () => T): T;
25
+ /** True if a live (non-stale) lock currently exists for `targetPath`. */
26
+ export declare function stateLockHeld(targetPath: string): boolean;
package/dist/lock.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Per-persona state lock — same-machine concurrency control (F1.4 / ADR-009).
3
+ *
4
+ * Multiple processes write a persona's state.json by design (REPL + serve + watch +
5
+ * MCP + hooks). Without a lock, read→modify→write races lose mutation_log entries —
6
+ * unacceptable for a governed, audited runtime.
7
+ *
8
+ * Mechanism: a lock DIRECTORY next to the file (`state.json.lock/`). mkdir is atomic
9
+ * on every OS/filesystem we target; the holder records `owner.json` {pid, ts}. A lock
10
+ * is stale (stealable) when its owner process is dead or its timestamp is older than
11
+ * STALE_MS (a holder never legitimately holds it that long: locks wrap only the
12
+ * mechanical read→apply→write section, never a model call). Waiters retry with a
13
+ * short sync sleep up to WAIT_TIMEOUT_MS, then fail loudly — silent lock-skipping
14
+ * would defeat the audit guarantee.
15
+ *
16
+ * This does NOT solve cross-machine sync (that is sync.ts's job) — only same-machine.
17
+ */
18
+ import { mkdirSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs";
19
+ const STALE_MS = 10_000;
20
+ const WAIT_TIMEOUT_MS = 5_000;
21
+ const RETRY_SLEEP_MS = 25;
22
+ function sleepSync(ms) {
23
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
24
+ }
25
+ function pidAlive(pid) {
26
+ try {
27
+ process.kill(pid, 0);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ function readOwner(lockDir) {
35
+ try {
36
+ return JSON.parse(readFileSync(`${lockDir}/owner.json`, "utf-8"));
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ function isStale(lockDir) {
43
+ const owner = readOwner(lockDir);
44
+ // Unreadable/missing owner right after mkdir can be a holder mid-write; only
45
+ // treat as stale once the dir itself has been around longer than STALE_MS is
46
+ // unknowable without the owner file — fall back to "steal" (the dir with no
47
+ // owner.json for a full retry cycle is a crashed holder).
48
+ if (!owner)
49
+ return true;
50
+ if (!pidAlive(owner.pid))
51
+ return true;
52
+ return Date.now() - owner.ts > STALE_MS;
53
+ }
54
+ /**
55
+ * Acquire the lock for `targetPath` (e.g. a state.json), returning a release fn.
56
+ * Prefer `withStateLock` — it guarantees release.
57
+ */
58
+ export function acquireStateLock(targetPath) {
59
+ const lockDir = `${targetPath}.lock`;
60
+ const deadline = Date.now() + WAIT_TIMEOUT_MS;
61
+ // eslint-disable-next-line no-constant-condition
62
+ while (true) {
63
+ try {
64
+ mkdirSync(lockDir);
65
+ writeFileSync(`${lockDir}/owner.json`, JSON.stringify({ pid: process.pid, ts: Date.now() }), "utf-8");
66
+ return () => rmSync(lockDir, { recursive: true, force: true });
67
+ }
68
+ catch (err) {
69
+ if (err.code !== "EEXIST")
70
+ throw err;
71
+ if (isStale(lockDir)) {
72
+ rmSync(lockDir, { recursive: true, force: true });
73
+ continue; // retry mkdir immediately
74
+ }
75
+ if (Date.now() >= deadline) {
76
+ const owner = readOwner(lockDir);
77
+ throw new Error(`could not acquire state lock at ${lockDir} within ${WAIT_TIMEOUT_MS}ms ` +
78
+ `(held by pid ${owner?.pid ?? "unknown"}). Another personaxis process is ` +
79
+ `writing this persona; retry, or remove the lock dir if that process is gone.`);
80
+ }
81
+ sleepSync(RETRY_SLEEP_MS);
82
+ }
83
+ }
84
+ }
85
+ /** Run `fn` holding the lock for `targetPath`. The lock is always released. */
86
+ export function withStateLock(targetPath, fn) {
87
+ const release = acquireStateLock(targetPath);
88
+ try {
89
+ return fn();
90
+ }
91
+ finally {
92
+ release();
93
+ }
94
+ }
95
+ /** True if a live (non-stale) lock currently exists for `targetPath`. */
96
+ export function stateLockHeld(targetPath) {
97
+ const lockDir = `${targetPath}.lock`;
98
+ return existsSync(lockDir) && !isStale(lockDir);
99
+ }