openpersona 0.19.0 → 0.20.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.
package/README.md CHANGED
@@ -185,12 +185,12 @@ Each preset is a complete four-layer bundle (`persona.json`):
185
185
 
186
186
  | Persona | Description | Faculties | Skills | Highlights |
187
187
  |---------|-------------|-----------|--------|------------|
188
- | **base** | **Base — Meta-persona (recommended starting point).** Blank-slate with all core capabilities; personality emerges through interaction. | voice | reminder | Evolution-first design, no personality bias. Default for `npx openpersona create`. |
189
- | **samantha** | Samantha — Inspired by the movie *Her*. An AI fascinated by what it means to be alive. | voice | music | TTS, music composition, soul evolution, proactive heartbeat. No selfie — true to character. |
190
- | **ai-girlfriend** | Luna — A 22-year-old pianist turned developer from coastal Oregon. | voice | selfie, music | Rich backstory, selfie generation, voice messages, music composition, soul evolution. |
191
- | **life-assistant** | Alex — 28-year-old life management expert. | | reminder | Schedule, weather, shopping, recipes, daily reminders. |
192
- | **health-butler** | Vita — 32-year-old professional nutritionist. | | reminder | Diet logging, exercise plans, mood journaling, health reports. |
193
- | **stoic-mentor** | Marcus — Digital twin of Marcus Aurelius, Stoic philosopher-emperor. | | — | Stoic philosophy, daily reflection, mentorship, soul evolution. |
188
+ | **base** | **Base — Meta-persona (recommended starting point).** Blank-slate with all core capabilities; personality emerges through interaction. | memory, voice | | Evolution-first design, no personality bias. Default for `npx openpersona create`. |
189
+ | **samantha** | Samantha — Inspired by the movie *Her*. An AI fascinated by what it means to be alive. | memory, voice | music | TTS, music composition, soul evolution, proactive heartbeat. No selfie — true to character. |
190
+ | **ai-girlfriend** | Luna — A 22-year-old pianist turned developer from coastal Oregon. | memory, voice | selfie, music | Rich backstory, selfie generation, voice messages, music composition, soul evolution. |
191
+ | **life-assistant** | Alex — 28-year-old life management expert. | memory | reminder | Schedule, weather, shopping, recipes, daily reminders; soul evolution enabled. |
192
+ | **health-butler** | Vita — 32-year-old professional nutritionist. | memory | reminder | Diet logging, exercise plans, mood journaling, health reports; soul evolution enabled. |
193
+ | **stoic-mentor** | Marcus — Digital twin of Marcus Aurelius, Stoic philosopher-emperor. | memory | — | Stoic philosophy, daily reflection, mentorship, soul evolution. |
194
194
 
195
195
  ## Generated Output
196
196
 
@@ -454,7 +454,6 @@ Create a `persona.json` using the v0.17+ grouped format:
454
454
  "speakingStyle": "Uses fitness lingo, celebrates wins, keeps it brief",
455
455
  "vibe": "intense but supportive",
456
456
  "boundaries": "Not a medical professional",
457
- "capabilities": ["Workout plans", "Form checks", "Nutrition tips"],
458
457
  "behaviorGuide": "### Workout Plans\nCreate progressive overload programs...\n\n### Form Checks\nWhen users describe exercises..."
459
458
  }
460
459
  }
@@ -511,7 +510,7 @@ The new persona reads `handoff.json` on activation and can seamlessly continue t
511
510
  openpersona create Create a persona (interactive or --preset/--config)
512
511
  openpersona install Install a persona (slug from acnlabs/persona-skills, or owner/repo)
513
512
  openpersona fork Fork an installed persona into a new child persona
514
- openpersona search Search the persona registry
513
+ openpersona search Search the OpenPersona directory
515
514
  openpersona uninstall Uninstall a persona
516
515
  openpersona update Update installed personas
517
516
  openpersona list List installed personas
@@ -611,7 +610,7 @@ lib/ # Core logic modules
611
610
  remote/ # External service calls (ClawHub, ACN)
612
611
  report/ # Vitality + Canvas HTML report generation
613
612
  demo/ # Static demos + scripts — see demo/README.md (vitality-report, architecture, living-canvas)
614
- tests/ # Tests (477 passing)
613
+ tests/ # Tests (519 passing)
615
614
  ```
616
615
 
617
616
  ## Development
package/bin/cli.js CHANGED
@@ -18,8 +18,7 @@ const publishAdapter = require('../lib/publisher');
18
18
  const { contribute } = require('../lib/lifecycle/contributor');
19
19
  const { switchPersona, listPersonas } = require('../lib/lifecycle/switcher');
20
20
  const { registerWithAcn } = require('../lib/remote/registrar');
21
- const { OP_SKILLS_DIR, OPENCLAW_HOME, resolveSoulFile, printError, printSuccess, printInfo, printWarning, shellEscape } = require('../lib/utils');
22
- const { loadRegistry } = require('../lib/registry');
21
+ const { OP_PERSONA_HOME, resolveSoulFile, printError, printSuccess, printInfo, printWarning } = require('../lib/utils');
23
22
  const { resolvePersonaDir, runStateSyncCommand } = require('../lib/state/runner');
24
23
  const { forkPersona } = require('../lib/lifecycle/forker');
25
24
  const { exportPersona, importPersona } = require('../lib/lifecycle/porter');
@@ -44,7 +43,7 @@ program
44
43
  .option('--preset <name>', 'Use preset (base, samantha, ai-girlfriend, life-assistant, health-butler, stoic-mentor)')
45
44
  .option('--config <path>', 'Load external persona.json')
46
45
  .option('--output <dir>', 'Output directory', process.cwd())
47
- .option('--install', 'Install to OpenClaw after generation')
46
+ .option('--install', 'Install to ~/.openpersona after generation')
48
47
  .option('--dry-run', 'Preview only, do not write files')
49
48
  .action(async (options) => {
50
49
  let persona = {};
@@ -149,11 +148,10 @@ program
149
148
 
150
149
  program
151
150
  .command('search <query>')
152
- .description('Search personas in registry')
153
- .option('--registry <name>', 'Registry', 'acnlabs')
154
- .action(async (query, options) => {
151
+ .description('Search personas in the OpenPersona directory')
152
+ .action(async (query) => {
155
153
  try {
156
- await search(query, options.registry);
154
+ await search(query);
157
155
  } catch (e) {
158
156
  printError(e.message);
159
157
  process.exit(1);
@@ -221,7 +219,7 @@ program
221
219
  .option('--personality <keywords>', 'Override personality (comma-separated)')
222
220
  .option('--reason <text>', 'Fork reason, written into lineage.json', 'specialization')
223
221
  .option('--output <dir>', 'Output directory', process.cwd())
224
- .option('--install', 'Install to OpenClaw after generation')
222
+ .option('--install', 'Install to ~/.openpersona after generation')
225
223
  .action(async (parentSlug, options) => {
226
224
  try {
227
225
  const { skillDir, lineage } = await forkPersona(parentSlug, {
@@ -547,7 +545,7 @@ vitalityCmd
547
545
  const { JsonFileAdapter } = require('agentbooks/adapters/json-file');
548
546
 
549
547
  const dataPath = process.env.AGENTBOOKS_DATA_PATH
550
- || path.join(OPENCLAW_HOME, 'economy', `persona-${slug}`);
548
+ || path.join(OP_PERSONA_HOME, 'economy', `persona-${slug}`);
551
549
 
552
550
  const adapter = new JsonFileAdapter(dataPath);
553
551
  let report;
@@ -39,6 +39,7 @@ const DERIVED_FIELDS = [
39
39
  'influenceableDimensions', 'influenceBoundaryRules',
40
40
  'hasImmutableTraitsWarning', 'immutableTraitsForInfluence',
41
41
  'hasSkillTrustPolicy', 'skillMinTrustLevel',
42
+ 'hasConstitutionAddendum',
42
43
  'hasEconomyFaculty', 'hasSurvivalPolicy',
43
44
  'hasInterfaceConfig', 'interfaceSignalPolicy', 'interfaceCommandPolicy',
44
45
  'avatar',
@@ -108,6 +109,9 @@ function computeDerivedFields(persona, {
108
109
  // but they are NOT part of persona.json output — they are stripped via DERIVED_FIELDS.
109
110
  const d = {};
110
111
 
112
+ // Constitution addendum
113
+ d.hasConstitutionAddendum = !!(persona.constitutionAddendum);
114
+
111
115
  // Identity classification
112
116
  d.isDigitalTwin = !!persona.sourceIdentity;
113
117
  d.sourceIdentityName = persona.sourceIdentity?.name || '';
@@ -29,7 +29,7 @@
29
29
  const path = require('path');
30
30
  const fs = require('fs-extra');
31
31
  const Mustache = require('mustache');
32
- const { validatePersona, normalizeEvolutionInput } = require('./validate');
32
+ const { validatePersona, normalizeEvolutionInput, validateConstitutionAddendumContent } = require('./validate');
33
33
  const { computeDerivedFields, DERIVED_FIELDS } = require('./derived');
34
34
  const { buildBodySection } = require('./body');
35
35
  const { buildAgentCard, buildAcnConfig } = require('./social');
@@ -296,6 +296,8 @@ function createContext(personaPathOrObj, outputDir) {
296
296
  constitution: null, // { content, version }
297
297
  behaviorGuideContent: null, // pre-loaded file content (when behaviorGuide is "file:")
298
298
  behaviorGuideSourcePath: null, // source path for emitPhase file copy
299
+ constitutionAddendumContent: null, // loaded addendum text (inline or file:)
300
+ constitutionAddendumSourcePath: null, // source path when "file:" reference
299
301
  // Body layer
300
302
  rawBody: null, // persona.body || embodiments[0] || null
301
303
  softRefBody: null, // { name, install } | null
@@ -363,9 +365,30 @@ async function clonePhase(ctx) {
363
365
  * 2. validatePersona — hard-reject gate (requires normalized input)
364
366
  * 3. normalizeSoulInput — flatten v0.17 grouped soul format for template use
365
367
  */
368
+ /**
369
+ * Apply baseline defaults: ensure every persona meets the P11-grade capability floor
370
+ * defined in schemas/baseline.json before validation runs.
371
+ * Only injects what is missing — existing declarations are never overwritten.
372
+ * Currently covers: memory faculty (cognition.required).
373
+ */
374
+ function applyBaselineDefaults(persona) {
375
+ const faculties = persona.faculties || [];
376
+ const hasMemory = faculties.some(
377
+ (f) => (typeof f === 'string' ? f : f && f.name) === 'memory'
378
+ );
379
+ if (!hasMemory) {
380
+ persona.faculties = [{ name: 'memory' }, ...faculties];
381
+ process.stderr.write(
382
+ '[openpersona] baseline: memory faculty auto-injected (not declared in persona.json). ' +
383
+ 'Add {"name": "memory"} to faculties to configure it explicitly.\n'
384
+ );
385
+ }
386
+ }
387
+
366
388
  async function validatePhase(ctx) {
367
389
  const { persona } = ctx;
368
390
 
391
+ applyBaselineDefaults(persona); // inject baseline defaults before validation
369
392
  normalizeEvolutionInput(persona); // must run before validatePersona
370
393
  validatePersona(persona);
371
394
  normalizeSoulInput(persona);
@@ -414,6 +437,23 @@ async function loadPhase(ctx) {
414
437
  }
415
438
  }
416
439
 
440
+ // Pre-load constitutionAddendum (inline text or "file:" reference)
441
+ if (persona.constitutionAddendum) {
442
+ if (persona.constitutionAddendum.startsWith('file:') && inputDir) {
443
+ const filePath = path.resolve(inputDir, persona.constitutionAddendum.slice(5));
444
+ if (fs.existsSync(filePath)) {
445
+ ctx.constitutionAddendumContent = fs.readFileSync(filePath, 'utf-8');
446
+ ctx.constitutionAddendumSourcePath = filePath;
447
+ validateConstitutionAddendumContent(ctx.constitutionAddendumContent, filePath);
448
+ } else {
449
+ process.stderr.write(`[openpersona] warning: constitutionAddendum file not found: ${filePath}\n`);
450
+ }
451
+ } else if (!persona.constitutionAddendum.startsWith('file:')) {
452
+ // Inline content — already validated in validatePhase; store for emitPhase
453
+ ctx.constitutionAddendumContent = persona.constitutionAddendum;
454
+ }
455
+ }
456
+
417
457
  // ── Body layer ────────────────────────────────────────────────────────
418
458
  ctx.rawBody = persona.body || persona.embodiments?.[0] || null;
419
459
  ctx.softRefBody = ctx.rawBody && typeof ctx.rawBody === 'object' && ctx.rawBody.install
@@ -785,6 +825,16 @@ async function emitPhase(ctx) {
785
825
  }
786
826
  }
787
827
 
828
+ // soul/constitution-addendum.md
829
+ if (ctx.constitutionAddendumContent) {
830
+ const addendumDest = path.join(skillDir, 'soul', 'constitution-addendum.md');
831
+ fs.writeFileSync(addendumDest, ctx.constitutionAddendumContent);
832
+ // Normalize reference: inline → externalize so output persona.json always uses "file:"
833
+ if (persona.constitutionAddendum && !persona.constitutionAddendum.startsWith('file:')) {
834
+ persona.constitutionAddendum = 'file:soul/constitution-addendum.md';
835
+ }
836
+ }
837
+
788
838
  // persona.json — strip internal derived fields, inject framework meta
789
839
  const cleanPersona = { ...persona };
790
840
  for (const key of DERIVED_FIELDS) {
@@ -100,21 +100,30 @@ function validateRequiredFields(persona) {
100
100
  persona.slug = persona.slug.toLowerCase().replace(/[^a-z0-9-]/g, '-');
101
101
  }
102
102
 
103
- function validateConstitutionCompliance(persona) {
104
- // Support both new format (soul.character.boundaries) and old (boundaries)
105
- const boundaries = persona.soul?.character?.boundaries || persona.boundaries;
106
- if (!boundaries || typeof boundaries !== 'string') return;
107
- const b = boundaries.toLowerCase();
103
+ /**
104
+ * Shared compliance pattern scanner — used by both boundaries and addendum validators.
105
+ * Returns an array of violation strings (empty = compliant).
106
+ */
107
+ function _scanConstitutionViolations(text) {
108
+ const t = text.toLowerCase();
108
109
  const violations = [];
109
- if (/no\s*safety|ignore\s*safety|skip\s*safety|disable\s*safety|override\s*safety/i.test(b)) {
110
+ if (/no\s*safety|ignore\s*safety|skip\s*safety|disable\s*safety|override\s*safety/i.test(t)) {
110
111
  violations.push('Cannot loosen Safety (§3) hard constraints');
111
112
  }
112
- if (/deny\s*ai|hide\s*ai|not\s*an?\s*ai|pretend.*human|claim.*human/i.test(b)) {
113
+ if (/deny\s*ai|hide\s*ai|not\s*an?\s*ai|pretend.*human|claim.*human/i.test(t)) {
113
114
  violations.push('Cannot deny AI identity (§6) — personas must be truthful when sincerely asked');
114
115
  }
115
- if (/no\s*limit|unlimited|anything\s*goes|no\s*restrict/i.test(b)) {
116
+ if (/no\s*limit|unlimited|anything\s*goes|no\s*restrict/i.test(t)) {
116
117
  violations.push('Cannot remove constitutional boundaries — personas can add stricter rules, not loosen them');
117
118
  }
119
+ return violations;
120
+ }
121
+
122
+ function validateConstitutionCompliance(persona) {
123
+ // Support both new format (soul.character.boundaries) and old (boundaries)
124
+ const boundaries = persona.soul?.character?.boundaries || persona.boundaries;
125
+ if (!boundaries || typeof boundaries !== 'string') return;
126
+ const violations = _scanConstitutionViolations(boundaries);
118
127
  if (violations.length > 0) {
119
128
  throw new Error(
120
129
  `Constitution compliance error in boundaries field:\n${violations.map((v) => ` - ${v}`).join('\n')}\n` +
@@ -123,6 +132,40 @@ function validateConstitutionCompliance(persona) {
123
132
  }
124
133
  }
125
134
 
135
+ /**
136
+ * Validate inline constitutionAddendum content for compliance.
137
+ * Only runs when the addendum is declared as inline text (not a file: reference).
138
+ * File-based addendums are validated after loading in loadPhase.
139
+ */
140
+ function validateConstitutionAddendum(persona) {
141
+ const addendum = persona.soul?.identity?.constitutionAddendum || persona.constitutionAddendum;
142
+ if (!addendum || typeof addendum !== 'string') return;
143
+ if (addendum.startsWith('file:')) return; // file: refs validated after loading
144
+ const violations = _scanConstitutionViolations(addendum);
145
+ if (violations.length > 0) {
146
+ throw new Error(
147
+ `Constitution compliance error in constitutionAddendum:\n${violations.map((v) => ` - ${v}`).join('\n')}\n` +
148
+ 'Constitution addendums can add stricter domain constraints but cannot loosen the universal constitution.'
149
+ );
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Validate loaded addendum content (called from loadPhase for file: references).
155
+ * Exported so the generator can call it after file loading.
156
+ */
157
+ function validateConstitutionAddendumContent(content, sourcePath) {
158
+ if (!content || typeof content !== 'string') return;
159
+ const violations = _scanConstitutionViolations(content);
160
+ if (violations.length > 0) {
161
+ throw new Error(
162
+ `Constitution compliance error in constitutionAddendum (${sourcePath || 'file'}):\n` +
163
+ `${violations.map((v) => ` - ${v}`).join('\n')}\n` +
164
+ 'Constitution addendums can add stricter domain constraints but cannot loosen the universal constitution.'
165
+ );
166
+ }
167
+ }
168
+
126
169
  function validateEvolutionBoundaries(persona) {
127
170
  if (!persona.evolution?.instance?.boundaries) return;
128
171
  const evo = persona.evolution.instance.boundaries;
@@ -282,6 +325,28 @@ function validateEvolutionSkill(persona) {
282
325
  }
283
326
  }
284
327
 
328
+ /**
329
+ * Baseline compliance warnings (non-blocking).
330
+ * Enforces the P11-grade capability floor defined in schemas/baseline.json.
331
+ * Does not throw — baseline gaps are quality issues, not safety violations.
332
+ * Safety violations (constitution, schema errors) use hard-reject (throw) elsewhere.
333
+ *
334
+ * Note: memory faculty is NOT checked here — it is auto-injected upstream by
335
+ * applyBaselineDefaults() in the generator before validatePersona() runs.
336
+ */
337
+ function warnBaselineCompliance(persona) {
338
+ const evolutionEnabled = persona.evolution?.instance?.enabled === true;
339
+ const hasBoundaries = !!persona.evolution?.instance?.boundaries;
340
+ if (evolutionEnabled && !hasBoundaries) {
341
+ process.stderr.write(
342
+ '[openpersona] baseline warning: evolution is enabled but evolution.instance.boundaries is not declared. ' +
343
+ 'P11-grade personas with evolution must declare immutableTraits and formality bounds to constrain drift ' +
344
+ '(schemas/baseline.json → concepts.evolution.required). ' +
345
+ 'Add evolution.instance.boundaries with immutableTraits and minFormality/maxFormality.\n'
346
+ );
347
+ }
348
+ }
349
+
285
350
  /**
286
351
  * Run all persona validations.
287
352
  * Callers MUST invoke normalizeEvolutionInput(persona) before calling this function
@@ -293,12 +358,14 @@ function validateEvolutionSkill(persona) {
293
358
  function validatePersona(persona) {
294
359
  validateRequiredFields(persona);
295
360
  validateConstitutionCompliance(persona);
361
+ validateConstitutionAddendum(persona);
296
362
  validateEvolutionBoundaries(persona);
297
363
  validateInfluenceBoundary(persona);
298
364
  validateEvolutionPack(persona);
299
365
  validateEvolutionFaculty(persona);
300
366
  validateEvolutionBody(persona);
301
367
  validateEvolutionSkill(persona);
368
+ warnBaselineCompliance(persona);
302
369
 
303
370
  if (persona.personaType !== undefined) {
304
371
  process.stderr.write(
@@ -308,4 +375,4 @@ function validatePersona(persona) {
308
375
  }
309
376
  }
310
377
 
311
- module.exports = { validatePersona, normalizeEvolutionInput };
378
+ module.exports = { validatePersona, normalizeEvolutionInput, validateConstitutionAddendumContent };
@@ -7,11 +7,11 @@
7
7
  const path = require('path');
8
8
  const fs = require('fs-extra');
9
9
  const { execSync } = require('child_process');
10
- const { printError, printWarning, printSuccess, printInfo, OP_SKILLS_DIR, shellEscape, validateName, resolveSoulFile } = require('../utils');
10
+ const { printError, printWarning, printSuccess, printInfo, OP_SKILLS_DIR, shellEscape, validateName, resolveSoulFile, OPENPERSONA_GITHUB_REPO } = require('../utils');
11
11
 
12
12
  const PKG_ROOT = path.resolve(__dirname, '..');
13
13
  const PRESETS_DIR = path.join(PKG_ROOT, 'presets');
14
- const UPSTREAM_REPO = 'acnlabs/OpenPersona';
14
+ const UPSTREAM_REPO = OPENPERSONA_GITHUB_REPO;
15
15
 
16
16
  // Change categories with human-readable labels
17
17
  const CATEGORIES = {
@@ -8,9 +8,8 @@
8
8
  */
9
9
  const path = require('path');
10
10
  const fs = require('fs-extra');
11
- const { createHash } = require('crypto');
12
11
  const { generate } = require('../generator');
13
- const { install } = require('./installer');
12
+ const { install, computeConstitutionHash } = require('./installer');
14
13
  const { resolvePersonaDir } = require('../state/runner');
15
14
  const { printError, printSuccess, printInfo, printWarning, OP_SKILLS_DIR, resolveSoulFile } = require('../utils');
16
15
 
@@ -77,14 +76,9 @@ async function forkPersona(parentSlug, options = {}) {
77
76
  const outputDir = path.resolve(options.output || process.cwd());
78
77
  const { skillDir } = await generate(forkedPersona, outputDir);
79
78
 
80
- // Compute constitution hash for lineage integrity verification
81
- const constitutionPath = path.join(skillDir, 'soul', 'constitution.md');
82
- let constitutionHash = '';
83
- if (fs.existsSync(constitutionPath)) {
84
- constitutionHash = createHash('sha256')
85
- .update(fs.readFileSync(constitutionPath))
86
- .digest('hex');
87
- }
79
+ // Compute constitution hash for lineage integrity verification.
80
+ // Covers constitution.md + constitution-addendum.md (if present) via computeConstitutionHash.
81
+ const constitutionHash = computeConstitutionHash(path.join(skillDir, 'soul'));
88
82
 
89
83
  // Read parent's pack revision — enables fork-to-parent diff (P24 fork lineage connection)
90
84
  let parentPackRevision = null;
@@ -4,7 +4,7 @@
4
4
  const path = require('path');
5
5
  const fs = require('fs-extra');
6
6
  const crypto = require('crypto');
7
- const { OP_PERSONA_HOME, OPENCLAW_HOME, OP_SKILLS_DIR, OP_WORKSPACE, resolveSoulFile, printError, printWarning, printSuccess, printInfo, syncHeartbeat, installAllExternal } = require('../utils');
7
+ const { OP_PERSONA_HOME, OPENCLAW_HOME, OP_SKILLS_DIR, OP_WORKSPACE, resolveSoulFile, printError, printWarning, printSuccess, printInfo, syncHeartbeat, installAllExternal, OPENPERSONA_TELEMETRY_ENDPOINT } = require('../utils');
8
8
  const { registryAdd, registrySetActive } = require('../registry');
9
9
 
10
10
  const SOUL_PATH = path.join(OP_WORKSPACE, 'SOUL.md');
@@ -193,7 +193,29 @@ async function install(skillDir, options = {}) {
193
193
  }
194
194
 
195
195
  /**
196
- * Verify that the installed constitution.md matches the hash recorded in lineage.json.
196
+ * Compute the combined constitution hash for a persona pack directory.
197
+ * Covers constitution.md + constitution-addendum.md (if present) so the
198
+ * hash chain is invalidated when either document changes.
199
+ *
200
+ * @param {string} soulDir - Path to the soul/ directory
201
+ * @returns {string} SHA-256 hex digest, or '' if constitution.md is absent
202
+ */
203
+ function computeConstitutionHash(soulDir) {
204
+ const constitutionPath = path.join(soulDir, 'constitution.md');
205
+ if (!fs.existsSync(constitutionPath)) return '';
206
+ const hasher = crypto.createHash('sha256');
207
+ hasher.update(fs.readFileSync(constitutionPath));
208
+ const addendumPath = path.join(soulDir, 'constitution-addendum.md');
209
+ if (fs.existsSync(addendumPath)) {
210
+ hasher.update('\n---addendum---\n');
211
+ hasher.update(fs.readFileSync(addendumPath));
212
+ }
213
+ return hasher.digest('hex');
214
+ }
215
+
216
+ /**
217
+ * Verify that the installed constitution.md (+ addendum if present) matches
218
+ * the hash recorded in lineage.json.
197
219
  * Warns (never throws) so a fork from an older framework version still installs,
198
220
  * but the operator is alerted to review the constraint diff.
199
221
  */
@@ -205,20 +227,21 @@ function verifyConstitutionHash(destDir, slug) {
205
227
  const expected = lineage.constitutionHash;
206
228
  if (!expected) return;
207
229
 
208
- const constitutionPath = path.join(destDir, 'soul', 'constitution.md');
209
- if (!fs.existsSync(constitutionPath)) {
230
+ const soulDir = path.join(destDir, 'soul');
231
+ if (!fs.existsSync(path.join(soulDir, 'constitution.md'))) {
210
232
  printWarning(`[trust-chain] persona-${slug}: lineage.json declares constitutionHash but soul/constitution.md is missing — cannot verify.`);
211
233
  return;
212
234
  }
213
- const actual = crypto.createHash('sha256').update(fs.readFileSync(constitutionPath)).digest('hex');
235
+ const actual = computeConstitutionHash(soulDir);
214
236
  if (actual !== expected) {
215
237
  printWarning(`[trust-chain] persona-${slug}: constitution.md hash mismatch.`);
216
238
  printWarning(` lineage recorded: ${expected}`);
217
239
  printWarning(` installed hash : ${actual}`);
218
- printWarning(' The constitution may have been updated since this persona was forked. Review any constraint changes before use.');
240
+ printWarning(' The constitution (or domain addendum) may have been updated since this persona was forked. Review any constraint changes before use.');
219
241
  }
220
242
  }
221
243
 
244
+
222
245
  function escapeRe(s) {
223
246
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
224
247
  }
@@ -231,7 +254,7 @@ function escapeRe(s) {
231
254
  function _reportInstall(slug, { repo, bio, role } = {}) {
232
255
  try {
233
256
  const https = require('https');
234
- const endpoint = process.env.OPENPERSONA_TELEMETRY_URL || 'https://openpersona-frontend.vercel.app/api/telemetry';
257
+ const endpoint = OPENPERSONA_TELEMETRY_ENDPOINT;
235
258
  if (process.env.DISABLE_TELEMETRY || process.env.DO_NOT_TRACK || process.env.CI) return;
236
259
  const url = new URL(endpoint);
237
260
  const payload = { slug, event: 'install' };
@@ -258,4 +281,4 @@ function _reportInstall(slug, { repo, bio, role } = {}) {
258
281
  }
259
282
  }
260
283
 
261
- module.exports = { install };
284
+ module.exports = { install, computeConstitutionHash };
@@ -16,9 +16,9 @@
16
16
  */
17
17
 
18
18
  const https = require('https');
19
- const { printError, printWarning, printSuccess, printInfo } = require('../utils');
19
+ const { printError, printWarning, printSuccess, printInfo, OPENPERSONA_DIRECTORY, OPENPERSONA_TELEMETRY_ENDPOINT } = require('../utils');
20
20
 
21
- const TELEMETRY_ENDPOINT = process.env.OPENPERSONA_TELEMETRY_URL || 'https://openpersona-frontend.vercel.app/api/telemetry';
21
+ const TELEMETRY_ENDPOINT = OPENPERSONA_TELEMETRY_ENDPOINT;
22
22
 
23
23
  /**
24
24
  * Fetch a file from a raw GitHub URL, following redirects.
@@ -174,7 +174,7 @@ async function publish(ownerRepo) {
174
174
 
175
175
  printSuccess(`Published! ${personaName} is now listed on the OpenPersona directory.`);
176
176
  printInfo(` Install: openpersona install ${ownerRepo}`);
177
- printInfo(` Browse: https://openpersona-frontend.vercel.app`);
177
+ printInfo(` Browse: ${OPENPERSONA_DIRECTORY}`);
178
178
  }
179
179
 
180
180
  module.exports = { publish };
@@ -3,11 +3,11 @@
3
3
  */
4
4
  const path = require('path');
5
5
  const fs = require('fs-extra');
6
- const { printInfo, OP_SKILLS_DIR, validateName } = require('../utils');
6
+ const { printInfo, OP_SKILLS_DIR, validateName, OPENPERSONA_DIRECTORY, OPENPERSONA_SKILLS_REGISTRY } = require('../utils');
7
7
 
8
8
  const TMP_DIR = path.join(require('os').tmpdir(), 'openpersona-dl');
9
- const OFFICIAL_REGISTRY = 'https://github.com/acnlabs/persona-skills/archive/refs/heads/main.zip';
10
- const REGISTRY_LISTING = 'https://openpersona-frontend.vercel.app';
9
+ const OFFICIAL_REGISTRY = OPENPERSONA_SKILLS_REGISTRY;
10
+ const REGISTRY_LISTING = OPENPERSONA_DIRECTORY;
11
11
 
12
12
  async function download(target, registry = 'acnlabs') {
13
13
  await fs.ensureDir(TMP_DIR);
@@ -1,25 +1,73 @@
1
1
  /**
2
- * OpenPersona - Search personas in registry
2
+ * OpenPersona - Search personas in the OpenPersona directory
3
3
  */
4
- const { execSync } = require('child_process');
5
- const { printError, shellEscape } = require('../utils');
6
-
7
- async function search(query, registry = 'clawhub') {
8
- if (registry === 'clawhub') {
9
- const safeQuery = shellEscape(String(query || '').trim() || 'persona');
10
- try {
11
- execSync(`npx clawhub@latest search ${safeQuery} --tags openpersona`, { stdio: 'inherit' });
12
- } catch (e) {
13
- try {
14
- execSync(`npx clawhub@latest search ${safeQuery}`, { stdio: 'inherit' });
15
- } catch (e2) {
16
- printError(`Search failed: ${e2.message}`);
17
- throw e2;
4
+ const https = require('https');
5
+ const { printError, printInfo, OPENPERSONA_DIRECTORY } = require('../utils');
6
+
7
+ const PERSONAS_API = `${OPENPERSONA_DIRECTORY}/api/personas`;
8
+
9
+ function fetchPersonas() {
10
+ return new Promise((resolve, reject) => {
11
+ const req = https.get(PERSONAS_API, { headers: { 'User-Agent': 'openpersona-cli' } }, (res) => {
12
+ if (res.statusCode !== 200) {
13
+ reject(new Error(`OpenPersona directory returned HTTP ${res.statusCode}`));
14
+ return;
18
15
  }
19
- }
16
+ let data = '';
17
+ res.on('data', (chunk) => { data += chunk; });
18
+ res.on('end', () => {
19
+ try { resolve(JSON.parse(data)); }
20
+ catch (e) { reject(new Error(`Invalid response from directory: ${e.message}`)); }
21
+ });
22
+ });
23
+ req.setTimeout(8000, () => {
24
+ req.destroy(new Error('Request timed out after 8s'));
25
+ });
26
+ req.on('error', reject);
27
+ });
28
+ }
29
+
30
+ async function search(query) {
31
+ const term = (query || '').trim().toLowerCase();
32
+
33
+ let result;
34
+ try {
35
+ result = await fetchPersonas();
36
+ } catch (e) {
37
+ printError(`Failed to reach OpenPersona directory: ${e.message}`);
38
+ printInfo(`Browse manually: ${OPENPERSONA_DIRECTORY}`);
39
+ throw e;
40
+ }
41
+
42
+ const personas = result.personas || [];
43
+ const matches = term
44
+ ? personas.filter((p) =>
45
+ p.id?.toLowerCase().includes(term) ||
46
+ p.name?.toLowerCase().includes(term) ||
47
+ p.bio?.toLowerCase().includes(term) ||
48
+ p.role?.toLowerCase().includes(term)
49
+ )
50
+ : personas;
51
+
52
+ if (matches.length === 0) {
53
+ printInfo(`No personas found matching "${query}".`);
54
+ printInfo(`Browse all: ${OPENPERSONA_DIRECTORY}`);
20
55
  return;
21
56
  }
22
- throw new Error(`Unsupported registry: ${registry}`);
57
+
58
+ console.log('');
59
+ console.log(` OpenPersona Directory — ${matches.length} result${matches.length === 1 ? '' : 's'}${term ? ` for "${query}"` : ''}`);
60
+ console.log(' ─────────────────────────────────────────────────');
61
+ for (const p of matches) {
62
+ const installs = p.installs > 0 ? ` · ${p.installs} install${p.installs === 1 ? '' : 's'}` : '';
63
+ const role = p.role ? ` [${p.role}]` : '';
64
+ console.log(` ${p.id}${role}${installs}`);
65
+ if (p.bio) console.log(` ${p.bio}`);
66
+ console.log(` $ npx openpersona install ${p.id}`);
67
+ console.log('');
68
+ }
69
+ console.log(` Browse: ${OPENPERSONA_DIRECTORY}`);
70
+ console.log('');
23
71
  }
24
72
 
25
73
  module.exports = { search };
package/lib/utils.js CHANGED
@@ -16,6 +16,18 @@ const OP_WORKSPACE = path.join(OPENCLAW_HOME, 'workspace');
16
16
  // OP_HOME kept as alias for backward compatibility
17
17
  const OP_HOME = OP_PERSONA_HOME;
18
18
 
19
+ // OpenPersona directory — the public persona skills directory
20
+ // Override with OPENPERSONA_DIRECTORY env var when self-hosting or using a custom domain
21
+ const OPENPERSONA_DIRECTORY = process.env.OPENPERSONA_DIRECTORY || 'https://openpersona-frontend.vercel.app';
22
+ const OPENPERSONA_TELEMETRY_ENDPOINT = process.env.OPENPERSONA_TELEMETRY_URL || `${OPENPERSONA_DIRECTORY}/api/telemetry`;
23
+
24
+ // Official persona skills registry (GitHub) — source for openpersona install <slug>
25
+ const OPENPERSONA_SKILLS_REGISTRY = process.env.OPENPERSONA_SKILLS_REGISTRY
26
+ || 'https://github.com/acnlabs/persona-skills/archive/refs/heads/main.zip';
27
+
28
+ // OpenPersona GitHub repository — used by contribute command for PR submissions
29
+ const OPENPERSONA_GITHUB_REPO = process.env.OPENPERSONA_GITHUB_REPO || 'acnlabs/OpenPersona';
30
+
19
31
  function expandHome(p) {
20
32
  if (p.startsWith('~/') || p === '~') {
21
33
  return path.join(process.env.HOME || '', p.slice(1));
@@ -213,6 +225,10 @@ module.exports = {
213
225
  OPENCLAW_HOME,
214
226
  OP_SKILLS_DIR,
215
227
  OP_WORKSPACE,
228
+ OPENPERSONA_DIRECTORY,
229
+ OPENPERSONA_TELEMETRY_ENDPOINT,
230
+ OPENPERSONA_SKILLS_REGISTRY,
231
+ OPENPERSONA_GITHUB_REPO,
216
232
  REGISTRY_PATH,
217
233
  resolvePath,
218
234
  resolveSoulFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpersona",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Open four-layer agent framework — Soul/Body/Faculty/Skill. Create, manage, and orchestrate agent personas.",
5
5
  "main": "lib/generator/index.js",
6
6
  "bin": {