Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Reproduction — soul forking with lineage

- **`Soul.fork(child_name, *, drift, inherit, charter, rng)`** — a soul can now produce a child soul. The child gets a fresh DID, `identity.parent_did` pointing at the parent, and `identity.generation = parent.generation + 1`. Its DNA is deep-copied and the five OCEAN traits each drift by an independent uniform delta in `[-drift, +drift]`, clamped to the schema's 0..1. Archetype, core values and the evolution *rules* carry over; the parent's mutation log does not. Pass `rng=random.Random(seed)` to make the drift reproducible (same parent, same seed, same child traits; the DID is still fresh each call).
- **`Identity.parent_did` and `Identity.generation`** — new fields, both defaulted, so souls written before this release load unchanged. Lineage (parent → child) is a separate axis from reincarnation (`incarnation` / `previous_lives`, the same soul living again); a soul can carry both.
- **Memory inheritance is opt-in per tier** — `inherit` defaults to `("core", "procedural")`, because how-tos passing down is how culture spreads. `semantic` and `social` pass only when named. **Episodic memory is always empty on the child**, even when the caller asks for it: a child does not remember its parent's life. Bonds, `role`, the trust chain, the self-model and the knowledge graph stay with the parent. `role` in particular is deliberately dropped so a `root` governance soul cannot accidentally hand its undeletable status to a child.
- **`EvolutionConfig.mutation_rate` finally has a reader.** The field has been serialized in every soul since the evolution system shipped and nothing consumed it; it is now the default drift width for `fork()`.
- **Known consequence:** `EvolutionConfig.immutable_traits` defaults to `["personality", "core_values"]`, and `fork()` honours it, so a default-configured parent produces an **OCEAN clone** no matter what `drift` is set to. `soul fork` prints a warning and reports the frozen traits in `--json`; `soul_protocol.runtime.soul.frozen_ocean_traits()` exposes the same check. Drop `"personality"` from the parent's `immutable_traits` to let children diverge.
- **`Soul.reincarnate()` now carries lineage through rebirth.** It previously built the new `Identity` without `parent_did` / `generation`, so reborn souls came back orphaned at generation 1. Rebirth is a new life for the *same* soul — it is still its parent's child at the same depth in the family tree — so only the rebirth axis advances. Souls reincarnated before this release keep whatever their file already says; nothing is rewritten on load.
- **New CLI: `soul fork <parent> --child <name>`** with `--drift`, `--inherit`, `--charter`, `--seed`, `--output/-o` and `--json`.
- **Lineage read path:** `Soul.public_profile()` now carries `parent_did` and `generation` (unlike `previous_lives`, which stays private — a parent DID is a link others walk). `soul inspect` and `soul status` show `Parent` / `Generation` lines for forked souls only, so output for unforked souls is unchanged.

### Architecture debt — extract health, rename list (#288)

- **Refactor:** Extracted duplicated health-audit and cleanup logic from `cli/main.py` and `mcp/server.py` into a new shared module `runtime/health.py`. Both entry points now delegate to `audit_health()`, `plan_cleanup()`, and `execute_cleanup()`.
Expand Down
48 changes: 48 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,48 @@ soul = await Soul.awaken("./kavi.soul")
soul = await Soul.awaken(Path("config.yaml"), engine=my_engine)
```

#### `Soul.fork()`

```python
async def fork(
self,
child_name: str,
*,
drift: float | None = None,
inherit: Sequence[str] = ("core", "procedural"),
charter: str | None = None,
rng: random.Random | None = None,
) -> Soul
```

Fork a **child** soul from this one — reproduction with lineage. The child gets a fresh DID, `identity.parent_did` pointing at this soul, and `identity.generation = parent.generation + 1`.

This is a different axis from `Soul.reincarnate()`. Reincarnation is the *same* soul living again (`incarnation` / `previous_lives`); a fork is a *new* soul descended from another. A soul can carry both.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `child_name` | `str` | required | Name for the child |
| `drift` | `float \| None` | `None` | Half-width of the per-trait OCEAN mutation. Defaults to the parent's `EvolutionConfig.mutation_rate`. `0` copies OCEAN exactly. |
| `inherit` | `Sequence[str]` | `("core", "procedural")` | Memory tiers to carry forward: `core`, `semantic`, `procedural`, `social` |
| `charter` | `str \| None` | `None` | Charter written by the parent; stored in the child's core memory with parent attribution |
| `rng` | `random.Random \| None` | `None` | Generator used for every OCEAN drift draw. Pass a seeded `random.Random` for a reproducible child; the DID is still fresh each call. |

**Returns:** `Soul` — ready to `save_local()` / `export()`.

**Raises:** `ValueError` if `drift` is negative or `inherit` names an unknown tier.

What crosses the gap: DNA (deep-copied, then each of the five OCEAN traits drifts by an independent uniform delta in `[-drift, +drift]`, clamped to 0..1), the archetype, the core values, the evolution *rules*, and the requested memory tiers.

What does not: **episodic memory is always empty on the child**, even when `inherit` names it — a child does not remember its parent's life. Bonds, role, trust chain, mutation history, self-model and knowledge graph also stay with the parent.

```python
child = await parent.fork("Vale", drift=0.08, charter="Keep the well open.")
assert child.identity.parent_did == parent.did
assert child.identity.generation == parent.identity.generation + 1
```

> **Drift is gated by `immutable_traits`.** The default `EvolutionConfig.immutable_traits` contains `"personality"`, which freezes all five OCEAN traits — a default-configured parent produces an OCEAN clone regardless of `drift`. Drop `"personality"` from the parent's `immutable_traits` to let children diverge. `soul_protocol.runtime.soul.frozen_ocean_traits(immutable_traits)` reports which traits are frozen.

#### `Soul.from_markdown()`

```python
Expand Down Expand Up @@ -1036,6 +1078,12 @@ A soul's unique identity with cryptographic DID.
| `origin_story` | `str` | `""` | Persona / origin text |
| `prime_directive` | `str` | `""` | Top-level directive |
| `core_values` | `list[str]` | `[]` | Values for significance scoring |
| `incarnation` | `int` | `1` | Rebirth counter — bumped by `reincarnate()` |
| `previous_lives` | `list[str]` | `[]` | DIDs of this soul's earlier incarnations |
| `parent_did` | `str \| None` | `None` | DID of the soul this one was forked from |
| `generation` | `int` | `1` | Depth in the fork lineage — bumped by `fork()` |

Lineage (`parent_did` / `generation`) and rebirth (`incarnation` / `previous_lives`) are separate axes: the first tracks a new soul descended from another, the second tracks the same soul living again.

### DNA / Personality

Expand Down
38 changes: 38 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1250,6 +1250,44 @@ soul evolve .soul/ --reject abc123

---

### `soul fork`

Fork a child Soul from a parent — reproduction with lineage. The child gets a new DID, `parent_did` pointing at the parent, and `generation = parent + 1`. Its OCEAN traits drift from the parent's by an independent random delta per trait.

```bash
soul fork aria.soul --child Vale
soul fork aria.soul --child Vale --drift 0.08 --inherit core,procedural
soul fork .soul/ --child Vale --charter "Keep the well open" --json
```

**Arguments:**

| Argument | Required | Description |
|----------|----------|-------------|
| `PATH` | Yes | Path to the parent soul file or `.soul/` directory. |

**Options:**

| Option | Description |
|--------|-------------|
| `--child TEXT` | **Required.** Name for the child soul. |
| `--drift FLOAT` | OCEAN mutation half-width per trait. Defaults to the parent's `evolution.mutation_rate`. `0` copies OCEAN exactly. |
| `--inherit TEXT` | Comma-separated memory tiers to inherit: `core`, `semantic`, `procedural`, `social`. Default `core,procedural`. |
| `--charter TEXT` | Charter written by the parent; stored in the child's core memory with parent attribution. |
| `--seed INT` | Seed the OCEAN drift. The same parent and seed always produce the same child traits (the DID is still fresh). |
| `--output, -o PATH` | Output path (default `./<child>.soul`). An existing directory is saved into. |
| `--json` | Emit machine-readable JSON instead of the rich panel. |

Episodic memory is **never** inherited — a child does not remember its parent's life, even when `--inherit` names `episodic`. Bonds, role, trust chain and mutation history stay with the parent too.

`--json` emits `child`, `did`, `parent_did`, `generation`, `drift`, `inherited`, `frozen_traits`, `ocean` and `output`.

> **Drift is gated by `immutable_traits`.** The default evolution config marks `personality` immutable, which freezes all five OCEAN traits — so a default parent forks an OCEAN clone whatever `--drift` says. The command prints a warning (and lists them in `frozen_traits`) when that happens. Drop `personality` from the parent's `evolution.immutable_traits` to let children diverge.

Lineage shows up in `soul inspect` and `soul status` as `Parent` / `Generation` lines, and in `Soul.public_profile()` as `parent_did` / `generation`.

---

### `soul evaluate`

Evaluate an interaction against a rubric. Scores the interaction, stores learning as procedural memory, and adjusts skill XP based on the score.
Expand Down
17 changes: 17 additions & 0 deletions schemas/Identity.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,23 @@
},
"title": "Previous Lives",
"type": "array"
},
"parent_did": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Parent Did"
},
"generation": {
"default": 1,
"title": "Generation",
"type": "integer"
}
},
"required": [
Expand Down
17 changes: 17 additions & 0 deletions schemas/SoulConfig.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,23 @@
},
"title": "Previous Lives",
"type": "array"
},
"parent_did": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Parent Did"
},
"generation": {
"default": 1,
"title": "Generation",
"type": "integer"
}
},
"required": [
Expand Down
17 changes: 17 additions & 0 deletions schemas/soul-protocol.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,23 @@
},
"title": "Previous Lives",
"type": "array"
},
"parent_did": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Parent Did"
},
"generation": {
"default": 1,
"title": "Generation",
"type": "integer"
}
},
"required": [
Expand Down
143 changes: 143 additions & 0 deletions src/soul_protocol/cli/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
# cli/main.py — Click CLI for the Soul Protocol (org + user groups + runtime commands)
# Updated: 2026-09-11 (lineage) — `soul fork --seed N` seeds the OCEAN drift
# so two runs from the same parent produce the same child traits.
# Updated: 2026-09-07 (lineage) — New `soul fork <parent> --child <name>`
# command (reproduction with lineage): --drift, --inherit, --charter,
# --output, --json. `soul inspect` and `soul status` now show Parent /
# Generation for forked souls.
# Updated: 2026-08-04 (#294) — Wires `--password` flag into `soul export`,
# `soul inspect`, and `soul unpack` for AES-256-GCM encryption at rest.
# Password is prompt-only (is_flag=True) so it never leaks into shell history.
Expand Down Expand Up @@ -128,6 +134,7 @@
import asyncio
import builtins
import json
import random
import sys
import warnings
import zipfile
Expand Down Expand Up @@ -542,6 +549,11 @@ async def _inspect():
]
if soul.identity.core_values:
identity_lines.append(f"Values {', '.join(soul.identity.core_values)}")
# Lineage — only shown for forked souls, so unforked output is unchanged.
if soul.identity.parent_did:
identity_lines.append(f"Parent [dim]{soul.identity.parent_did}[/dim]")
if soul.identity.generation > 1:
identity_lines.append(f"Generation {soul.identity.generation}")

identity_panel = Panel(
"\n".join(identity_lines),
Expand Down Expand Up @@ -683,6 +695,13 @@ async def _status():
f" Memories {soul.memory_count}",
]

# Lineage — only for forked souls, so unforked output is unchanged.
if soul.identity.parent_did:
lines.append(
f" Lineage gen {soul.identity.generation}, "
f"child of [dim]{soul.identity.parent_did}[/dim]"
)

# Multi-user bond display when more than one user has a bond.
bonded_users = soul.bonded_users
if bonded_users:
Expand Down Expand Up @@ -3137,6 +3156,130 @@ async def _evolve():
asyncio.run(_evolve())


@cli.command("fork")
@click.argument("path", type=click.Path(exists=True))
@click.option("--child", "child_name", required=True, help="Name for the child soul")
@click.option(
"--drift",
type=float,
default=None,
help="OCEAN mutation half-width per trait (default: parent's evolution.mutation_rate)",
)
@click.option(
"--inherit",
default="core,procedural",
show_default=True,
help="Comma-separated memory tiers to inherit: core, semantic, procedural, social. "
"Episodic is never inherited.",
)
@click.option("--charter", type=str, default=None, help="Charter written by the parent")
@click.option(
"--seed",
type=int,
default=None,
help="Seed the OCEAN drift so the same parent + seed always yields the same child",
)
@click.option(
"--output",
"-o",
type=click.Path(),
default=None,
help="Output path (default: ./<child>.soul). An existing directory is saved into.",
)
@click.option("--json", "as_json", is_flag=True, default=False, help="Emit JSON")
def fork_cmd(path, child_name, drift, inherit, charter, seed, output, as_json):
"""Fork a child Soul from a parent — reproduction with lineage.

The child gets a new DID, ``parent_did`` pointing at the parent, and
``generation = parent + 1``. Its OCEAN traits drift from the parent's by
an independent random delta per trait. Episodic memory always starts
empty — a child does not remember its parent's life.

\b
Examples:
soul fork aria.soul --child Vale
soul fork aria.soul --child Vale --drift 0.08 --inherit core,procedural
soul fork .soul/ --child Vale --charter "Keep the well open" --json
"""
if drift is not None and drift < 0:
raise click.BadParameter("--drift must be >= 0", param_hint="--drift")

tiers = [t.strip() for t in inherit.split(",") if t.strip()]

async def _fork():
from soul_protocol.runtime.soul import frozen_ocean_traits

parent = await _awaken_or_fail(path)
try:
child = await parent.fork(
child_name,
drift=drift,
inherit=tiers,
charter=charter,
rng=None if seed is None else random.Random(seed),
)
except ValueError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise SystemExit(1) from exc

immutable = parent.serialize().evolution.immutable_traits
frozen = frozen_ocean_traits(immutable)
effective_drift = parent.serialize().evolution.mutation_rate if drift is None else drift

out = output or f"./{_safe_name(child.name)}.soul"
if Path(out).is_dir():
await child.save_local(out)
else:
await child.export(out, include_keys=True)

p = child.dna.personality
if as_json:
click.echo(
json.dumps(
{
"child": child.name,
"did": child.did,
"parent_did": child.identity.parent_did,
"generation": child.identity.generation,
"drift": effective_drift,
"inherited": sorted(t for t in tiers if t != "episodic"),
"frozen_traits": frozen,
"ocean": {
"openness": p.openness,
"conscientiousness": p.conscientiousness,
"extraversion": p.extraversion,
"agreeableness": p.agreeableness,
"neuroticism": p.neuroticism,
},
"output": str(out),
},
indent=2,
)
)
return

console.print(
f"[green]Forked[/green] [bold]{child.name}[/bold] ({child.did})\n"
f" Parent {child.identity.parent_did}\n"
f" Generation {child.identity.generation}\n"
f" Drift {effective_drift}\n"
f" Inherited {', '.join(sorted(t for t in tiers if t != 'episodic')) or 'nothing'}"
)
console.print(
f"[dim]OCEAN: O={p.openness:.2f} C={p.conscientiousness:.2f} "
f"E={p.extraversion:.2f} A={p.agreeableness:.2f} N={p.neuroticism:.2f}[/dim]"
)
if frozen:
console.print(
f"[yellow]No OCEAN drift:[/yellow] {', '.join(frozen)} frozen by "
f"evolution.immutable_traits ({', '.join(immutable)}). "
"Drop 'personality' from the parent's immutable_traits to let children diverge."
)
console.print(f"[dim]Saved to {out}[/dim]")

asyncio.run(_fork())


@cli.command("evaluate")
@click.argument("path", type=click.Path(exists=True))
@click.option("--user-input", "user_input", required=True, help="User's message")
Expand Down
Loading
Loading