@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
package/dist/memory.js ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Governed episodic memory — bounded, provenance-tagged, append-only, auditable.
3
+ *
4
+ * The research is unambiguous (see plan/02-governed-memory, plan/11-security):
5
+ * once untrusted content reaches long-term memory it persists and is later
6
+ * retrieved as "trusted" context — the Zombie-Agent / memory-poisoning failure
7
+ * mode. Defenses adopted here:
8
+ * - every entry carries a provenance source (user/tool/internal/synthesis);
9
+ * - entries form a hash chain (prev_hash -> hash): tamper-evident, time-travel
10
+ * auditable, MemLineage-style append-only log;
11
+ * - the writer is dry-run friendly (verify before commit).
12
+ *
13
+ * Stored as JSON Lines at <personaDir>/memory/episodic.jsonl so it diffs cleanly
14
+ * in git and never mutates prior lines.
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ export function readMemoryTypes(frontmatter) {
20
+ const mem = frontmatter.memory;
21
+ const declared = mem?.types;
22
+ const flag = (k, dflt) => declared && typeof declared[k] === "boolean" ? declared[k] : dflt;
23
+ // No memory block at all → keep episodic on so existing personas still record.
24
+ const episodicDefault = mem === undefined;
25
+ return {
26
+ episodic: flag("episodic", episodicDefault),
27
+ semantic: flag("semantic", false),
28
+ procedural: flag("procedural", false),
29
+ autobiographical: flag("autobiographical", false),
30
+ user_preferences: flag("user_preferences", false),
31
+ evaluations: flag("evaluations", false),
32
+ };
33
+ }
34
+ function memoryPath(personaPath) {
35
+ return join(dirname(personaPath), "memory", "episodic.jsonl");
36
+ }
37
+ function sha256(s) {
38
+ return createHash("sha256").update(s).digest("hex");
39
+ }
40
+ /** LEGACY (≤0.10) chain hash: commits to the content bytes directly. */
41
+ function hashEntryLegacy(e) {
42
+ return sha256(JSON.stringify({ ts: e.ts, content: e.content, source: e.source, tags: e.tags, prev_hash: e.prev_hash }));
43
+ }
44
+ /** v1.0 chain hash: commits to content_hash, enabling erasure without a chain break. */
45
+ function hashEntryV1(e) {
46
+ return sha256(JSON.stringify({ ts: e.ts, content_hash: e.content_hash, source: e.source, tags: e.tags, prev_hash: e.prev_hash }));
47
+ }
48
+ export function readMemory(personaPath) {
49
+ const p = memoryPath(personaPath);
50
+ if (!existsSync(p))
51
+ return [];
52
+ return readFileSync(p, "utf-8")
53
+ .split("\n")
54
+ .filter((l) => l.trim().length > 0)
55
+ .map((l) => JSON.parse(l));
56
+ }
57
+ function lastHash(personaPath) {
58
+ const entries = readMemory(personaPath);
59
+ return entries.length > 0 ? entries[entries.length - 1].hash : "";
60
+ }
61
+ /** Build (but do not write) the next entry — the dry-run half of write-path audit.
62
+ * New entries are always v1.0 format (content_hash-anchored: erasure-capable). */
63
+ export function prepareMemoryEntry(personaPath, req) {
64
+ const base = {
65
+ ts: new Date().toISOString(),
66
+ content: req.content,
67
+ source: req.source,
68
+ tags: req.tags ?? [],
69
+ content_hash: sha256(req.content),
70
+ prev_hash: lastHash(personaPath),
71
+ };
72
+ return { ...base, hash: hashEntryV1(base) };
73
+ }
74
+ /** Commit a prepared entry to the append-only log. */
75
+ export function commitMemoryEntry(personaPath, entry) {
76
+ const p = memoryPath(personaPath);
77
+ mkdirSync(dirname(p), { recursive: true });
78
+ appendFileSync(p, JSON.stringify(entry) + "\n", "utf-8");
79
+ }
80
+ /**
81
+ * Honor deletion_policy.user_request_supported WITHOUT breaking the append-only
82
+ * chain: append a tombstone that supersedes a prior entry. The original line is
83
+ * never rewritten (chain stays verifiable); live readers filter it out. The
84
+ * deletion itself is thus auditable — you can prove what was removed and when.
85
+ */
86
+ export function tombstoneMemory(personaPath, targetHash, reason) {
87
+ const entry = prepareMemoryEntry(personaPath, {
88
+ content: `[deleted by user request: ${reason}]`,
89
+ source: "user",
90
+ tags: ["tombstone", `target:${targetHash}`],
91
+ });
92
+ commitMemoryEntry(personaPath, entry);
93
+ return entry;
94
+ }
95
+ /** Read memory with tombstoned entries (and the tombstones themselves) removed. */
96
+ export function readLiveMemory(personaPath) {
97
+ const all = readMemory(personaPath);
98
+ const tombstoned = new Set();
99
+ for (const e of all) {
100
+ const t = e.tags.find((x) => x.startsWith("target:"));
101
+ if (e.tags.includes("tombstone") && t)
102
+ tombstoned.add(t.slice("target:".length));
103
+ }
104
+ return all.filter((e) => !e.tags.includes("tombstone") && !tombstoned.has(e.hash));
105
+ }
106
+ /**
107
+ * Consolidate episodic memory into a semantic snapshot at <personaDir>/memory.md
108
+ * (the spec's `memory.types.semantic`). Deterministic + dependency-free: groups
109
+ * live (non-tombstoned) entries by provenance source into a digest. Idempotent —
110
+ * rewritten each call, so it always reflects the current live memory.
111
+ */
112
+ export function consolidateSemantic(personaPath, limit = 200) {
113
+ const entries = readLiveMemory(personaPath).slice(-limit);
114
+ const path = join(dirname(personaPath), "memory.md");
115
+ if (entries.length === 0) {
116
+ writeFileSync(path, "# Semantic memory\n\n_(empty)_\n", "utf-8");
117
+ return { ok: true, path, count: 0 };
118
+ }
119
+ const bySource = new Map();
120
+ for (const e of entries) {
121
+ const arr = bySource.get(e.source) ?? [];
122
+ arr.push(e);
123
+ bySource.set(e.source, arr);
124
+ }
125
+ const lines = ["# Semantic memory (consolidated)", "", `_generated ${new Date().toISOString()} from ${entries.length} episodic entr${entries.length === 1 ? "y" : "ies"}_`, ""];
126
+ for (const [source, arr] of bySource) {
127
+ lines.push(`## From ${source}`);
128
+ for (const e of arr)
129
+ lines.push(`- ${e.content.replace(/\n+/g, " ").trim()} \`#${e.hash.slice(0, 8)}\``);
130
+ lines.push("");
131
+ }
132
+ writeFileSync(path, lines.join("\n"), "utf-8");
133
+ return { ok: true, path, count: entries.length };
134
+ }
135
+ /** Read the consolidated semantic memory (memory.md), if present. */
136
+ export function readSemanticMemory(personaPath) {
137
+ const p = join(dirname(personaPath), "memory.md");
138
+ return existsSync(p) ? readFileSync(p, "utf-8") : "";
139
+ }
140
+ /**
141
+ * Verify the hash chain is intact (tamper / poisoning detection). Each entry is
142
+ * verified per its own format: v1.0 entries (content_hash present) recompute the
143
+ * chain hash over content_hash AND — unless redacted — check the content still
144
+ * matches its content_hash; legacy entries recompute over the content directly.
145
+ */
146
+ export function verifyMemoryChain(personaPath) {
147
+ const entries = readMemory(personaPath);
148
+ let prev = "";
149
+ for (let i = 0; i < entries.length; i++) {
150
+ const e = entries[i];
151
+ if (e.prev_hash !== prev)
152
+ return { ok: false, brokenAt: i };
153
+ if (typeof e.content_hash === "string") {
154
+ if (hashEntryV1(e) !== e.hash)
155
+ return { ok: false, brokenAt: i };
156
+ if (!e.redacted && sha256(e.content) !== e.content_hash)
157
+ return { ok: false, brokenAt: i };
158
+ }
159
+ else {
160
+ if (hashEntryLegacy(e) !== e.hash)
161
+ return { ok: false, brokenAt: i };
162
+ }
163
+ prev = e.hash;
164
+ }
165
+ return { ok: true };
166
+ }
167
+ const REDACTION_MARKER = "[redacted]";
168
+ /**
169
+ * REAL erasure (v1.0, D6): remove the content bytes of a prior entry while the
170
+ * chain stays verifiable — the chain hash commits to content_hash, which is
171
+ * retained. This is the ONLY sanctioned rewrite of a prior line, it is
172
+ * irreversible, and it is itself audited (a `redaction` record is appended).
173
+ * Complements tombstoneMemory (which hides but retains bytes): tombstone for
174
+ * retrieval removal, redact for right-to-erasure.
175
+ */
176
+ export function redactMemory(personaPath, targetHash, reason) {
177
+ const p = memoryPath(personaPath);
178
+ const entries = readMemory(personaPath);
179
+ const idx = entries.findIndex((e) => e.hash === targetHash);
180
+ if (idx === -1)
181
+ throw new Error(`no memory entry with hash ${targetHash}`);
182
+ const target = entries[idx];
183
+ if (typeof target.content_hash !== "string") {
184
+ throw new Error(`entry ${targetHash.slice(0, 8)} is legacy format (chain hash commits to its content); ` +
185
+ `run migrateMemoryChain() to re-anchor the log before redacting`);
186
+ }
187
+ entries[idx] = { ...target, content: REDACTION_MARKER, redacted: true };
188
+ writeFileSync(p, entries.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf-8");
189
+ const audit = tombstoneMemory(personaPath, targetHash, `redacted: ${reason}`);
190
+ return { redacted: entries[idx], audit };
191
+ }
192
+ /**
193
+ * Re-anchor a legacy (≤0.10) episodic log to the v1.0 content_hash format so its
194
+ * entries become redactable. Rewrites every line: adds content_hash, recomputes
195
+ * each chain hash, re-links prev_hash, and remaps tombstone `target:` tags to
196
+ * the new hashes. One-time, deliberate migration — the old hashes are replaced.
197
+ */
198
+ export function migrateMemoryChain(personaPath) {
199
+ const p = memoryPath(personaPath);
200
+ const entries = readMemory(personaPath);
201
+ if (entries.length === 0)
202
+ return { migrated: 0, remapped: {} };
203
+ const remapped = {};
204
+ let prev = "";
205
+ const next = [];
206
+ for (const e of entries) {
207
+ const base = {
208
+ ts: e.ts,
209
+ content: e.content,
210
+ source: e.source,
211
+ // Remap tombstone target tags to the already-migrated hash of their target.
212
+ tags: e.tags.map((t) => t.startsWith("target:") && remapped[t.slice("target:".length)]
213
+ ? `target:${remapped[t.slice("target:".length)]}`
214
+ : t),
215
+ content_hash: e.content_hash ?? sha256(e.content),
216
+ redacted: e.redacted,
217
+ prev_hash: prev,
218
+ };
219
+ if (base.redacted === undefined)
220
+ delete base.redacted;
221
+ const migrated = { ...base, hash: hashEntryV1(base) };
222
+ remapped[e.hash] = migrated.hash;
223
+ prev = migrated.hash;
224
+ next.push(migrated);
225
+ }
226
+ writeFileSync(p, next.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf-8");
227
+ return { migrated: next.length, remapped };
228
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Model resolution (Fase 2) — one config logic for dev AND prod, so nobody has to export env
3
+ * vars before every launch.
4
+ *
5
+ * A model can be configured in four layers; higher layers override lower ones:
6
+ *
7
+ * global.local ~/.personaxis/config.json (machine default; PERSONAXIS_HOME aware)
8
+ * project.local <cwd>/.personaxis/config.json (this project's default)
9
+ * global.personas[slug] ~/.personaxis/config.json (per-persona, machine-wide)
10
+ * project.personas[slug] <cwd>/.personaxis/config.json (per-persona, this project)
11
+ * frontmatter.runtime the persona's own personaxis.md (the persona declares its model)
12
+ * ENV PERSONAXIS_ENDPOINT/MODEL/API_KEY (top override — dev & prod secrets)
13
+ *
14
+ * SECRETS: the API key is NEVER required to live in a file. Preferred: name the env var holding it
15
+ * with `apiKeyEnv` (e.g. "COHERE_API_KEY"); resolveModel reads that env var. Fallbacks: the
16
+ * PERSONAXIS_API_KEY env var, then an inline `apiKey` (dev convenience — the config file must be
17
+ * gitignored). In production the key comes from the deploy's secret manager via the env var.
18
+ *
19
+ * Dependency-free (node:fs/os/path), so core stays framework-agnostic and every surface
20
+ * (REPL, MCP, serve, SDK) shares the exact same resolution.
21
+ */
22
+ export interface ModelSettings {
23
+ endpoint?: string;
24
+ model?: string;
25
+ /** Inline key (dev convenience; the file MUST be gitignored). Prefer `apiKeyEnv`. */
26
+ apiKey?: string;
27
+ /** Name of the env var that holds the key (preferred — the key never touches a file). */
28
+ apiKeyEnv?: string;
29
+ }
30
+ /** The slice of `config.json` this module reads. Other keys (provider/byok/remote) are ignored here. */
31
+ export interface ModelConfigFile {
32
+ local?: ModelSettings;
33
+ /** Per-persona overrides, keyed by slug. */
34
+ personas?: Record<string, ModelSettings>;
35
+ }
36
+ export interface ResolvedModel {
37
+ endpoint: string;
38
+ model: string;
39
+ apiKey?: string;
40
+ }
41
+ export declare function globalConfigPath(): string;
42
+ export declare function projectConfigPath(cwd?: string): string;
43
+ /** Extract a persona slug from a `.personaxis/personas/<slug>/…` path (last segment wins). */
44
+ export declare function slugFromPersonaPath(personaPath?: string): string | undefined;
45
+ export interface ResolveModelOptions {
46
+ /** Path to the persona (used for slug-based per-persona overrides). */
47
+ personaPath?: string;
48
+ /** Project root holding `.personaxis/config.json` (defaults to process.cwd()). */
49
+ cwd?: string;
50
+ /** The persona's frontmatter — its `runtime` block is a per-persona override. */
51
+ frontmatter?: Record<string, unknown>;
52
+ }
53
+ /**
54
+ * Resolve the effective model for a persona. Returns undefined (→ heuristic/offline) unless BOTH an
55
+ * endpoint and a model are configured. The API key is resolved from the env var named by
56
+ * `apiKeyEnv`, else PERSONAXIS_API_KEY, else an inline `apiKey` — in that order.
57
+ */
58
+ export declare function resolveModel(opts?: ResolveModelOptions): ResolvedModel | undefined;
59
+ /** A human-readable description of the resolved model (for `/model` and labels). */
60
+ export declare function describeModel(opts?: ResolveModelOptions): string;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Model resolution (Fase 2) — one config logic for dev AND prod, so nobody has to export env
3
+ * vars before every launch.
4
+ *
5
+ * A model can be configured in four layers; higher layers override lower ones:
6
+ *
7
+ * global.local ~/.personaxis/config.json (machine default; PERSONAXIS_HOME aware)
8
+ * project.local <cwd>/.personaxis/config.json (this project's default)
9
+ * global.personas[slug] ~/.personaxis/config.json (per-persona, machine-wide)
10
+ * project.personas[slug] <cwd>/.personaxis/config.json (per-persona, this project)
11
+ * frontmatter.runtime the persona's own personaxis.md (the persona declares its model)
12
+ * ENV PERSONAXIS_ENDPOINT/MODEL/API_KEY (top override — dev & prod secrets)
13
+ *
14
+ * SECRETS: the API key is NEVER required to live in a file. Preferred: name the env var holding it
15
+ * with `apiKeyEnv` (e.g. "COHERE_API_KEY"); resolveModel reads that env var. Fallbacks: the
16
+ * PERSONAXIS_API_KEY env var, then an inline `apiKey` (dev convenience — the config file must be
17
+ * gitignored). In production the key comes from the deploy's secret manager via the env var.
18
+ *
19
+ * Dependency-free (node:fs/os/path), so core stays framework-agnostic and every surface
20
+ * (REPL, MCP, serve, SDK) shares the exact same resolution.
21
+ */
22
+ import { existsSync, readFileSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { personaxisHome } from "./registry.js";
25
+ export function globalConfigPath() {
26
+ return join(personaxisHome(), "config.json");
27
+ }
28
+ export function projectConfigPath(cwd = process.cwd()) {
29
+ return join(cwd, ".personaxis", "config.json");
30
+ }
31
+ function readConfig(path) {
32
+ if (!existsSync(path))
33
+ return {};
34
+ try {
35
+ return JSON.parse(readFileSync(path, "utf-8"));
36
+ }
37
+ catch {
38
+ return {};
39
+ }
40
+ }
41
+ /** Extract a persona slug from a `.personaxis/personas/<slug>/…` path (last segment wins). */
42
+ export function slugFromPersonaPath(personaPath) {
43
+ if (!personaPath)
44
+ return undefined;
45
+ const matches = [...personaPath.matchAll(/[\\/]personas[\\/]([^\\/]+)/g)];
46
+ return matches.length ? matches[matches.length - 1][1] : undefined;
47
+ }
48
+ function envSettings() {
49
+ return {
50
+ ...(process.env.PERSONAXIS_ENDPOINT ? { endpoint: process.env.PERSONAXIS_ENDPOINT } : {}),
51
+ ...(process.env.PERSONAXIS_MODEL ? { model: process.env.PERSONAXIS_MODEL } : {}),
52
+ ...(process.env.PERSONAXIS_API_KEY ? { apiKey: process.env.PERSONAXIS_API_KEY } : {}),
53
+ };
54
+ }
55
+ /** Merge a list of settings low→high precedence (later wins), dropping undefined fields. */
56
+ function mergeSettings(layers) {
57
+ const out = {};
58
+ for (const layer of layers) {
59
+ if (!layer)
60
+ continue;
61
+ if (layer.endpoint)
62
+ out.endpoint = layer.endpoint;
63
+ if (layer.model)
64
+ out.model = layer.model;
65
+ if (layer.apiKey)
66
+ out.apiKey = layer.apiKey;
67
+ if (layer.apiKeyEnv)
68
+ out.apiKeyEnv = layer.apiKeyEnv;
69
+ }
70
+ return out;
71
+ }
72
+ /**
73
+ * Resolve the effective model for a persona. Returns undefined (→ heuristic/offline) unless BOTH an
74
+ * endpoint and a model are configured. The API key is resolved from the env var named by
75
+ * `apiKeyEnv`, else PERSONAXIS_API_KEY, else an inline `apiKey` — in that order.
76
+ */
77
+ export function resolveModel(opts = {}) {
78
+ const cwd = opts.cwd ?? process.cwd();
79
+ const global = readConfig(globalConfigPath());
80
+ const project = readConfig(projectConfigPath(cwd));
81
+ const slug = slugFromPersonaPath(opts.personaPath);
82
+ const runtime = opts.frontmatter?.runtime;
83
+ const merged = mergeSettings([
84
+ global.local,
85
+ project.local,
86
+ slug ? global.personas?.[slug] : undefined,
87
+ slug ? project.personas?.[slug] : undefined,
88
+ runtime,
89
+ envSettings(),
90
+ ]);
91
+ if (!merged.endpoint || !merged.model)
92
+ return undefined;
93
+ const apiKey = (merged.apiKeyEnv ? process.env[merged.apiKeyEnv] : undefined) ??
94
+ process.env.PERSONAXIS_API_KEY ??
95
+ merged.apiKey;
96
+ return { endpoint: merged.endpoint, model: merged.model, ...(apiKey ? { apiKey } : {}) };
97
+ }
98
+ /** A human-readable description of the resolved model (for `/model` and labels). */
99
+ export function describeModel(opts = {}) {
100
+ const m = resolveModel(opts);
101
+ return m ? `${m.model} @ ${m.endpoint}${m.apiKey ? " (key set)" : " (no key)"}` : "heuristic (offline — configure a model)";
102
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * PersonaTheme — a persona's whole visual + behavioral signature, derived
3
+ * DETERMINISTICALLY from its spec (plan/09-ascii-ux).
4
+ *
5
+ * The requirement was explicit: showing a persona must be differentiated per
6
+ * persona — its look AND its behavior in the terminal — never a generic spinner.
7
+ * So we map the quantitative layers to a theme:
8
+ *
9
+ * affect.valence -> palette hue (cold ↔ warm)
10
+ * affect.arousal -> palette brightness + breath amplitude
11
+ * extraversion -> breath rate + amplitude (how lively it "breathes")
12
+ * openness -> drift (how much the sigil explores between frames)
13
+ * emotionality -> jitter (instability)
14
+ * conscientiousness -> symmetry (crisp & ordered ↔ organic)
15
+ * identity hash -> seed (stable per persona) + glyph set
16
+ * voice -> terse | balanced | expansive output style
17
+ *
18
+ * Pure + dependency-free: returns data (ANSI-256 indices + params). The TUI/REPL
19
+ * paint it; the engine never prints.
20
+ */
21
+ import type { PersonaFrontmatter } from "./persona.js";
22
+ export interface PersonaTheme {
23
+ seed: number;
24
+ palette: {
25
+ primary: number;
26
+ secondary: number;
27
+ accent: number;
28
+ dim: number;
29
+ };
30
+ motion: {
31
+ breathRate: number;
32
+ amplitude: number;
33
+ jitter: number;
34
+ drift: number;
35
+ symmetry: number;
36
+ };
37
+ glyphs: string[];
38
+ voice: {
39
+ density: "terse" | "balanced" | "expansive";
40
+ flourish: number;
41
+ };
42
+ size: number;
43
+ }
44
+ export interface ThemedSigil {
45
+ grid: string[];
46
+ intensity: number;
47
+ }
48
+ /** Live "breathing" intensity from state, paced by the theme's motion params. */
49
+ export declare function themeIntensity(theme: PersonaTheme, values: Record<string, number>, frame?: number): number;
50
+ /**
51
+ * Render a persona's sigil for one animation frame. Motion params shape it:
52
+ * - drift: how much the glyph re-seeds between frames (openness = exploratory);
53
+ * - jitter: per-cell instability (emotionality);
54
+ * - symmetry: mirror vs organic (conscientiousness).
55
+ */
56
+ export declare function renderThemedSigil(theme: PersonaTheme, values: Record<string, number>, frame?: number): ThemedSigil;
57
+ export declare function personaTheme(fm: PersonaFrontmatter): PersonaTheme;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * PersonaTheme — a persona's whole visual + behavioral signature, derived
3
+ * DETERMINISTICALLY from its spec (plan/09-ascii-ux).
4
+ *
5
+ * The requirement was explicit: showing a persona must be differentiated per
6
+ * persona — its look AND its behavior in the terminal — never a generic spinner.
7
+ * So we map the quantitative layers to a theme:
8
+ *
9
+ * affect.valence -> palette hue (cold ↔ warm)
10
+ * affect.arousal -> palette brightness + breath amplitude
11
+ * extraversion -> breath rate + amplitude (how lively it "breathes")
12
+ * openness -> drift (how much the sigil explores between frames)
13
+ * emotionality -> jitter (instability)
14
+ * conscientiousness -> symmetry (crisp & ordered ↔ organic)
15
+ * identity hash -> seed (stable per persona) + glyph set
16
+ * voice -> terse | balanced | expansive output style
17
+ *
18
+ * Pure + dependency-free: returns data (ANSI-256 indices + params). The TUI/REPL
19
+ * paint it; the engine never prints.
20
+ */
21
+ import { createHash } from "node:crypto";
22
+ const GLYPH_SETS = [
23
+ [" ", "·", ":", "*", "✦", "█"],
24
+ [" ", ".", "+", "x", "#", "█"],
25
+ [" ", "˙", "∘", "○", "◍", "●"],
26
+ [" ", "·", "✶", "✷", "✸", "✹"],
27
+ [" ", "░", "▒", "▓", "█", "█"],
28
+ [" ", "⋄", "◇", "◈", "◆", "▰"],
29
+ [" ", "`", "'", "^", "≈", "☵"],
30
+ [" ", "·", "-", "=", "≡", "█"],
31
+ [" ", "˙", "•", "◦", "◉", "⬢"],
32
+ [" ", "·", "⋅", "∗", "✺", "✸"],
33
+ [" ", ".", "·", "•", "●", "⬤"],
34
+ [" ", "▁", "▃", "▅", "▇", "█"],
35
+ ];
36
+ function seedFrom(fm) {
37
+ const id = fm.identity;
38
+ const meta = fm.metadata;
39
+ // A RICH fingerprint so two personas differ even if one identity field collides:
40
+ // identity + name + the trait/affect signature. Never a bare "persona" fallback.
41
+ const traits = fm.personality?.traits ?? {};
42
+ const sig = Object.entries(traits)
43
+ .map(([k, v]) => `${k}:${typeof v?.mean === "number" ? v.mean.toFixed(2) : "?"}`)
44
+ .sort()
45
+ .join(",");
46
+ const key = [id?.canonical_id, id?.display_name, meta?.name, sig].filter(Boolean).join("|") || JSON.stringify(fm).slice(0, 200);
47
+ return parseInt(createHash("sha256").update(key).digest("hex").slice(0, 8), 16) >>> 0;
48
+ }
49
+ /** HSV → nearest ANSI-256 cube index (16..231). Gives the full color wheel. */
50
+ function hsvAnsi(h, s, v) {
51
+ h = ((h % 360) + 360) % 360;
52
+ const c = v * s;
53
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
54
+ const m = v - c;
55
+ let r = 0, g = 0, b = 0;
56
+ if (h < 60)
57
+ [r, g, b] = [c, x, 0];
58
+ else if (h < 120)
59
+ [r, g, b] = [x, c, 0];
60
+ else if (h < 180)
61
+ [r, g, b] = [0, c, x];
62
+ else if (h < 240)
63
+ [r, g, b] = [0, x, c];
64
+ else if (h < 300)
65
+ [r, g, b] = [x, 0, c];
66
+ else
67
+ [r, g, b] = [c, 0, x];
68
+ const to6 = (n) => Math.max(0, Math.min(5, Math.round((n + m) * 5)));
69
+ return 16 + 36 * to6(r) + 6 * to6(g) + to6(b);
70
+ }
71
+ /**
72
+ * Palette derived from the SEED's hue (so two personas differ in color regardless
73
+ * of affect), nudged by affect: valence warms/cools the hue, arousal raises
74
+ * saturation/brightness. Returns [primary, secondary, accent].
75
+ */
76
+ function colorFromSeed(seed, valence, arousal) {
77
+ const baseHue = seed % 360;
78
+ const hue = (baseHue + valence * 25 + 360) % 360;
79
+ const sat = Math.min(1, 0.5 + arousal * 0.35);
80
+ const val = 0.7 + arousal * 0.2;
81
+ const primary = hsvAnsi(hue, sat, val);
82
+ const secondary = hsvAnsi(hue, sat * 0.65, Math.min(1, val + 0.2));
83
+ const accent = hsvAnsi((hue + 38) % 360, Math.min(1, sat + 0.25), Math.min(1, val + 0.1));
84
+ return [primary, secondary, accent];
85
+ }
86
+ function traitMean(fm, name, dflt) {
87
+ const t = fm.personality?.traits?.[name];
88
+ return typeof t?.mean === "number" ? t.mean : dflt;
89
+ }
90
+ function affectMean(fm, dim, dflt) {
91
+ const a = fm.affect?.baseline?.core_affect?.[dim];
92
+ return typeof a?.mean === "number" ? a.mean : dflt;
93
+ }
94
+ function rng(seed) {
95
+ let s = seed || 0x9e3779b9;
96
+ return () => {
97
+ s ^= s << 13;
98
+ s ^= s >>> 17;
99
+ s ^= s << 5;
100
+ s >>>= 0;
101
+ return s / 0xffffffff;
102
+ };
103
+ }
104
+ /** Live "breathing" intensity from state, paced by the theme's motion params. */
105
+ export function themeIntensity(theme, values, frame = 0) {
106
+ const tone = values["mood.tone"] ?? 0;
107
+ const valence = values["affect.valence"] ?? 0;
108
+ const arousal = values["affect.arousal"] ?? 0;
109
+ const base = 0.5 + (tone + valence) * 0.25 + arousal * 0.1;
110
+ const pulse = Math.sin((frame * theme.motion.breathRate) / 2) * theme.motion.amplitude * (0.5 + arousal);
111
+ return Math.max(0.05, Math.min(1, base + pulse));
112
+ }
113
+ /**
114
+ * Render a persona's sigil for one animation frame. Motion params shape it:
115
+ * - drift: how much the glyph re-seeds between frames (openness = exploratory);
116
+ * - jitter: per-cell instability (emotionality);
117
+ * - symmetry: mirror vs organic (conscientiousness).
118
+ */
119
+ export function renderThemedSigil(theme, values, frame = 0) {
120
+ const { size, glyphs, motion } = theme;
121
+ const half = Math.ceil(size / 2);
122
+ const intensity = themeIntensity(theme, values, frame);
123
+ const density = 0.25 + intensity * 0.6;
124
+ // Drift re-seeds slowly for open personas, never for rigid ones.
125
+ const frameSeed = theme.seed + Math.floor(frame * motion.drift) * 0x1000193;
126
+ const next = rng(frameSeed);
127
+ const rows = [];
128
+ for (let y = 0; y < size; y++) {
129
+ let left = "";
130
+ for (let x = 0; x < half; x++) {
131
+ const cy = (y - (size - 1) / 2) / size;
132
+ const cx = (x - (size - 1) / 2) / size;
133
+ const radial = 1 - Math.min(1, Math.sqrt(cx * cx + cy * cy) * 1.4);
134
+ const jitter = (next() - 0.5) * motion.jitter;
135
+ const v = (next() * (0.5 + radial) + jitter) * density;
136
+ const idx = Math.max(0, Math.min(glyphs.length - 1, Math.floor(v * glyphs.length)));
137
+ left += glyphs[idx];
138
+ }
139
+ // symmetry: high -> mirror; low -> let the right half diverge organically.
140
+ let right;
141
+ if (next() <= motion.symmetry) {
142
+ right = left.slice(0, size - half).split("").reverse().join("");
143
+ }
144
+ else {
145
+ right = Array.from({ length: size - half }, () => {
146
+ const idx = Math.floor(next() * density * glyphs.length);
147
+ return glyphs[Math.max(0, Math.min(glyphs.length - 1, idx))];
148
+ }).join("");
149
+ }
150
+ rows.push((left + right).slice(0, size));
151
+ }
152
+ return { grid: rows, intensity };
153
+ }
154
+ export function personaTheme(fm) {
155
+ const seed = seedFrom(fm);
156
+ const valence = affectMean(fm, "valence", 0);
157
+ const arousal = affectMean(fm, "arousal", 0.4);
158
+ const openness = traitMean(fm, "openness", 0.5);
159
+ const extraversion = traitMean(fm, "extraversion", 0.5);
160
+ const emotionality = traitMean(fm, "emotionality", 0.5);
161
+ const conscientiousness = traitMean(fm, "conscientiousness", 0.5);
162
+ // Color from the seed's hue (distinct per persona), nudged by affect.
163
+ const [primary, secondary, accent] = colorFromSeed(seed, valence, arousal);
164
+ const verbosity = extraversion * 0.5 + openness * 0.3 - conscientiousness * 0.25;
165
+ const density = verbosity > 0.18 ? "expansive" : verbosity < -0.05 ? "terse" : "balanced";
166
+ // Glyph set from a fingerprint of seed + trait signature (not seed % N alone),
167
+ // so personas with seeds differing by a multiple of N don't share glyphs.
168
+ const glyphIdx = (seed ^ (Math.floor(openness * 97) * 7) ^ (Math.floor(conscientiousness * 89) * 13) ^ (Math.floor(emotionality * 83) * 17)) >>> 0;
169
+ return {
170
+ seed,
171
+ palette: { primary, secondary, accent, dim: 236 + (seed % 6) },
172
+ motion: {
173
+ breathRate: Number((0.6 + extraversion * 1.8).toFixed(3)),
174
+ amplitude: Number((0.04 + (extraversion + arousal) * 0.06).toFixed(3)),
175
+ jitter: Number((emotionality * 0.5).toFixed(3)),
176
+ drift: Number((openness * 0.6).toFixed(3)),
177
+ symmetry: Number(conscientiousness.toFixed(3)),
178
+ },
179
+ glyphs: GLYPH_SETS[glyphIdx % GLYPH_SETS.length],
180
+ voice: { density, flourish: Number(openness.toFixed(3)) },
181
+ size: 9,
182
+ };
183
+ }