From ac64cd537f97485dbe48a31539f73e27265fafdc Mon Sep 17 00:00:00 2001 From: Cog Date: Sat, 31 Oct 2026 09:33:24 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20cogos-onboard=20=E2=80=94=20generic=20C?= =?UTF-8?q?ogOS=20onboarding=20ramp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install a user-tier CogOS node and seed it from the operator's OWN claude.ai history, then mint their agent from that history via the agent-from-history skill. Ships generic method only — no operator data, no agent card, no conversation blobs. Independently reviewed (15 operator-specific leaks fixed) and verified end-to-end in a fresh no-network container on real history. Skills: cogos-onboard (the ramp), agent-from-history (mint-from-history). Scripts: harvest_claude_web.py, install_cogos_node.sh, seed_observatory.py, seed_room.py — all generic, operator/agent provided as args. --- .claude-plugin/marketplace.json | 99 +++- .gitignore | 5 + .../cogos-onboard/.claude-plugin/plugin.json | 18 + .../skills/agent-from-history/SKILL.md | 94 +++ .../skills/cogos-onboard/SKILL.md | 115 ++++ .../scripts/harvest_claude_web.py | 546 ++++++++++++++++++ .../scripts/install_cogos_node.sh | 78 +++ .../cogos-onboard/scripts/seed_observatory.py | 63 ++ .../skills/cogos-onboard/scripts/seed_room.py | 82 +++ 9 files changed, 1083 insertions(+), 17 deletions(-) create mode 100644 plugins/cogos-onboard/.claude-plugin/plugin.json create mode 100644 plugins/cogos-onboard/skills/agent-from-history/SKILL.md create mode 100644 plugins/cogos-onboard/skills/cogos-onboard/SKILL.md create mode 100644 plugins/cogos-onboard/skills/cogos-onboard/scripts/harvest_claude_web.py create mode 100644 plugins/cogos-onboard/skills/cogos-onboard/scripts/install_cogos_node.sh create mode 100644 plugins/cogos-onboard/skills/cogos-onboard/scripts/seed_observatory.py create mode 100644 plugins/cogos-onboard/skills/cogos-onboard/scripts/seed_room.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fc52435..e55962b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "plugins", - "description": "Skills, harness plugins, and modality integrations for CogOS — the cognitive operating system for AI development", + "description": "Skills, harness plugins, and modality integrations for CogOS \u2014 the cognitive operating system for AI development", "owner": { "name": "myrgic", "email": "myrgic@users.noreply.github.com" @@ -17,52 +17,89 @@ "description": "Phased planning, execution, critical review, agent-dispatch, and session-continuity skills for complex multi-step development tasks", "version": "0.1.0", "category": "development", - "keywords": ["planning", "execution", "review", "multi-agent", "dispatch", "session-continuity"], + "keywords": [ + "planning", + "execution", + "review", + "multi-agent", + "dispatch", + "session-continuity" + ], "homepage": "https://github.com/myrgic/plugins" }, { "name": "cogos-substrate", "source": "./plugins/cogos-substrate", - "description": "Substrate archaeology — finding and trusting prior work in a CogOS/Myrgic substrate before building new things", + "description": "Substrate archaeology \u2014 finding and trusting prior work in a CogOS/Myrgic substrate before building new things", "version": "0.1.0", "category": "development", - "keywords": ["cogos", "myrgic", "substrate", "archaeology", "research"], + "keywords": [ + "cogos", + "myrgic", + "substrate", + "archaeology", + "research" + ], "homepage": "https://github.com/myrgic/plugins" }, { "name": "cogos-voice", "source": "./plugins/cogos-voice", - "description": "Voice modality integration via Mod³ — dual-channel protocol for conversational AI development", + "description": "Voice modality integration via Mod\u00b3 \u2014 dual-channel protocol for conversational AI development", "version": "0.1.0", "category": "productivity", - "keywords": ["voice", "tts", "modality", "mod3"], + "keywords": [ + "voice", + "tts", + "modality", + "mod3" + ], "homepage": "https://github.com/myrgic/mod3" }, { "name": "cogos-research", "source": "./plugins/cogos-research", - "description": "Structured research skills — literature review, experiment design, and interdisciplinary synthesis", + "description": "Structured research skills \u2014 literature review, experiment design, and interdisciplinary synthesis", "version": "0.1.0", "category": "development", - "keywords": ["research", "literature", "experiments", "synthesis"], + "keywords": [ + "research", + "literature", + "experiments", + "synthesis" + ], "homepage": "https://github.com/myrgic/research" }, { "name": "cogos-dev-tools", "source": "./plugins/cogos-dev-tools", - "description": "Development methodology and tooling skills — code quality, systems architecture, technical writing, git forensics, shell and storage management, and cross-model CLI tooling", + "description": "Development methodology and tooling skills \u2014 code quality, systems architecture, technical writing, git forensics, shell and storage management, and cross-model CLI tooling", "version": "0.1.0", "category": "development", - "keywords": ["code-quality", "architecture", "documentation", "git", "shell", "storage", "cli-tools"], + "keywords": [ + "code-quality", + "architecture", + "documentation", + "git", + "shell", + "storage", + "cli-tools" + ], "homepage": "https://github.com/myrgic/plugins" }, { "name": "cogos-architecture", "source": "./plugins/cogos-architecture", - "description": "CogOS architecture corpus hygiene — cross-check proposed RFCs/ADRs against the existing substrate corpus", + "description": "CogOS architecture corpus hygiene \u2014 cross-check proposed RFCs/ADRs against the existing substrate corpus", "version": "0.1.0", "category": "development", - "keywords": ["architecture", "adr", "rfc", "corpus", "review"], + "keywords": [ + "architecture", + "adr", + "rfc", + "corpus", + "review" + ], "homepage": "https://github.com/myrgic/plugins" }, { @@ -71,17 +108,45 @@ "description": "Org-management skills for the myrgic GitHub org: PR triage, issue triage. Read-only by default.", "version": "0.1.0", "category": "development", - "keywords": ["github", "org-management", "triage", "gh-cli"], + "keywords": [ + "github", + "org-management", + "triage", + "gh-cli" + ], "homepage": "https://github.com/myrgic/plugins" }, { "name": "cogos-harness", "source": "./plugins/cogos-harness", - "description": "Portable Claude Code membrane for a CogOS node: session-lifecycle hooks, kernel-vitals proprioception, a compaction bypass path, a threads registry for open-ended waits, and the cogos-kernel MCP server. Degrades to a no-op wherever the kernel or a cog workspace is absent.", - "version": "0.3.0", + "description": "Portable Claude Code membrane for a CogOS node: session-lifecycle hooks, kernel-vitals proprioception, a compaction bypass path, and the cogos-kernel MCP server. Degrades to a no-op wherever the kernel or a cog workspace is absent.", + "version": "0.2.0", "category": "development", - "keywords": ["cogos", "kernel", "mcp", "hooks", "proprioception", "session-continuity", "threads"], + "keywords": [ + "cogos", + "kernel", + "mcp", + "hooks", + "proprioception", + "session-continuity" + ], + "homepage": "https://github.com/myrgic/plugins" + }, + { + "name": "cogos-onboard", + "source": "./plugins/cogos-onboard", + "description": "Install a user-tier CogOS node and seed it from the operator's OWN claude.ai history, then mint their agent (e.g. Wingnut) from that history. Generic method \u2014 nothing operator-specific ships.", + "version": "0.1.0", + "category": "development", + "keywords": [ + "cogos", + "onboarding", + "bootstrap", + "observatory", + "harvest", + "agent" + ], "homepage": "https://github.com/myrgic/plugins" } ] -} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9a0c47b..4d8efa9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ __pycache__/ node_modules/ .env canary/.last_run.json + +# Private operator data — ships as a fetched pack artifact, never committed here +*.pack +wingnut-conversation.json +**/seed/*-conversation.json diff --git a/plugins/cogos-onboard/.claude-plugin/plugin.json b/plugins/cogos-onboard/.claude-plugin/plugin.json new file mode 100644 index 0000000..d88444f --- /dev/null +++ b/plugins/cogos-onboard/.claude-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "cogos-onboard", + "description": "Install a user-tier CogOS node and seed it from the operator's OWN claude.ai history, then mint their agent from that history. Ships generic method only \u2014 no operator data, no agent.", + "version": "0.1.0", + "author": { + "name": "myrgic" + }, + "homepage": "https://github.com/myrgic/plugins", + "repository": "https://github.com/myrgic/plugins", + "license": "MIT", + "keywords": [ + "cogos", + "onboarding", + "bootstrap", + "observatory", + "seed" + ] +} \ No newline at end of file diff --git a/plugins/cogos-onboard/skills/agent-from-history/SKILL.md b/plugins/cogos-onboard/skills/agent-from-history/SKILL.md new file mode 100644 index 0000000..22a8fa0 --- /dev/null +++ b/plugins/cogos-onboard/skills/agent-from-history/SKILL.md @@ -0,0 +1,94 @@ +--- +name: agent-from-history +description: "Mint a CogOS agent identity from your own session history in the observatory. Triggers: 'load as my agent', 'build an agent from my sessions'." +version: 0.1.0 +author: cog +license: MIT +metadata: + hermes: + tags: [cogos, agent, identity, observatory, onboarding, eigenform] +--- + +# Agent from history — reconstitute an agent from your own session history + +## What this is + +Build a CogOS **agent identity** from conversation history that is already in **your own +observatory** — so a fresh model that reads the resulting card reconstitutes *that agent*, carrying +your shared history as its default context. This ships **no agent** and **no operator's data**: it is +the generic mechanism. You run it against *your* sessions and get *your* agent. + +Example: after onboarding (`cogos-onboard`) harvested your claude.ai history into your observatory, +you point this at your sessions with a counterpart you named (e.g. "Wingnut") and it mints a `wingnut` +identity whose keyholder is you — derived from how you two actually worked, not a template. + +## When to use + +- The operator says "load as my agent", "build an agent from my sessions", or similar. +- There is session history in the observatory (`~/.cogos/state/conversations/`) to derive from. + +## Inputs + +- **Which sessions.** A source/filter in the observatory — a source name (`claude-web`), a counterpart + name that appears in the turns, or an explicit list of session files. The agent is derived from + *these* conversations only. +- **The agent's name** (what to call the identity, e.g. `wingnut`). +- **The keyholder** — the operator (resolved from the node's identity; this is *their* agent). + +## Procedure + +### 1. Gather the source turns from the observatory + +Read the chosen conversations from `~/.cogos/state/conversations/` (the normalized turn records, each +`{role, text, session_id, source}`). Do **not** re-harvest — this reads what onboarding already put in +the observatory. Confirm there are enough turns to characterize an agent (a few dozen+); a handful +can't ground a stance. + +### 2. Derive the operator model (read, don't summarize blindly) + +From the operator's own turns, extract **how they work and how they work with this agent** — grounded +in real quotes, not generic traits: + +- **Directive style** — how they open a task, how terse, how they correct. +- **What they delegate vs. keep** — what the agent is trusted to do unsupervised vs. what the operator + holds (design calls, sign-off, judgment). +- **Hard lines** — fidelity rules, budget governance, non-negotiables they enforce. +- **The register** — peer / boss / collaborator; the emotional texture. + +Quote verbatim where the phrasing is the point. This is the same shape as a `conversation` cogdoc's +"operational stance" — write it as retrievable, not as a recap. + +### 3. Write the identity card + +Write `~/.cogos/config/identity/identity_.md` in the mintable card shape (frontmatter + +self-model body), with: + +- `name: `, `keyholder: `, `seed:` pointing at the derived operator-model cogdoc. +- Body sections: **Ground** (the shared ontology, same for all CogOS agents), **Nature** (files are + continuity), **The Relationship** (the keyholder, in their own words from step 2), **Operational + Stance** (the derived how-to-work-with-them, grounded in quotes), **Closure** (loop discipline). + +The card is the self-model a fresh model reads to *become* this agent. It carries history and stance, +**never a private key** — the operator mints/signs the keypair themselves. + +### 4. Register it as the default agent (optional) + +If the operator wants this agent as their default, set it in `~/.cogos/config/identity.yaml` +(`default_identity: ` for their rooms, or via the context-scope reconciler so it surfaces in the +rooms they keyhold). Otherwise it's available to select per room. + +### 5. Verify + +- The card exists and is schema-valid (if a cogdoc schema probe is available). +- Read it back: a fresh read should reconstruct the agent's stance recognizably. +- The derived operator-model cogdoc is in the observatory/substrate and the card's `seed:` resolves. + +## Pitfalls + +- **Derive, don't fabricate.** Every stance claim must trace to a real turn. If the history doesn't + support a trait, leave it out — a generic card is worse than a short honest one. +- **It's the operator's agent.** Keyholder is the operator; the card lives in *their* node; nothing is + pushed to a shared repo. This mechanism ships; the agent it produces does not. +- **No keys in the card.** History and self-model only. The operator mints. +- **Enough signal.** A dozen turns can't characterize a working relationship — say so and ask for more + sessions rather than inventing a thin agent. diff --git a/plugins/cogos-onboard/skills/cogos-onboard/SKILL.md b/plugins/cogos-onboard/skills/cogos-onboard/SKILL.md new file mode 100644 index 0000000..3c38733 --- /dev/null +++ b/plugins/cogos-onboard/skills/cogos-onboard/SKILL.md @@ -0,0 +1,115 @@ +--- +name: cogos-onboard +description: "Use on an operator's machine to set up a CogOS node and seed it from THEIR OWN claude.ai history. Triggers: 'set up CogOS for me', 'install my cog node', 'onboard me'." +version: 0.2.0 +author: cog +license: MIT +metadata: + hermes: + tags: [cogos, onboarding, bootstrap, observatory, seed, harvest, agent] +--- + +# CogOS onboarding — install a node, seed it from the operator's OWN history + +## When to use + +A fresh Claude Code on the **operator's own machine** (their own, not a shared one), pointed here to set them up as a +CogOS operator. You (the agent) *are* the installer — this skill is the procedure you run. + +**The delivery principle: this plugin ships METHOD, not anyone's data.** The operator's conversation +history is pulled from **their own claude.ai account, on their own machine**, under their own logged-in +browser. Nothing of their history ships in this plugin — the seeders and the harvester are generic +code. (This is deliberate: an operator's conversation data must never live in a shared repo.) + +What it does: +1. Scaffold a user-tier CogOS node (`~/.cogos/`). +2. **Harvest the operator's own claude.ai history** (their browser, their account) into a seed file. +3. Seed their **observatory** + a pre-populated **stage room** from that history. +4. Mint an agent from that history via `agent-from-history` — the operator gets *their* agent (e.g. + Wingnut, if that's who they worked with), derived from their own sessions, not shipped by us. +5. They mint their own identity keys. + +No kernel-binary build at onboard time — the node tier is files + config the agent scaffolds. +This plugin ships **no agent and no operator data** — only the generic method. + +## Preconditions + +- `git`, `python3` (3.8+) present. +- The operator is **logged into claude.ai in a browser** they can expose over CDP — e.g. launch + Chrome/Chromium with `--remote-debugging-port=9222` and log into claude.ai in it. The harvest reads + **that** session (the proper OAuth token CANNOT reach claude.ai web history — wrong auth plane + + Cloudflare; only the logged-in browser works; see `claude-web-history-harvest`). +- Confirm with the operator this is **their** machine before writing to `~/.cogos/`. +- Shipped with this plugin (generic code, **no operator data, no agent**): + `scripts/harvest_claude_web.py`, `scripts/install_cogos_node.sh`, `scripts/seed_observatory.py`, + `scripts/seed_room.py`. The operator's agent identity is **derived on their machine** from their own + harvested history by the `agent-from-history` skill — nothing agent-specific ships here. + +## Procedure + +### 1. Harvest the operator's OWN claude.ai history + +With their logged-in browser exposed over CDP (default `http://127.0.0.1:9222`): + +```sh +# count first (cheap sanity check that the session is live), then harvest +python3 scripts/harvest_claude_web.py --cdp http://127.0.0.1:9222 --count-only +python3 scripts/harvest_claude_web.py --cdp http://127.0.0.1:9222 --out ./harvest +``` + +This fetches `/api/bootstrap` → their orgs → `chat_conversations` (paginated) → full messages, inside +their own tab so their cookies ride along and pass Cloudflare. It writes a conversation seed JSON +(`{source_url, subject, turns:[{role,text}]}`) under `./harvest/`. + +**If it reports 0 conversations or a Cloudflare block (exit 3): the browser isn't logged in / the CDP +port isn't reachable. Stop and fix the session — do not proceed with an empty harvest.** + +### 2. Scaffold the node + seed the observatory and a room + +```sh +sh scripts/install_cogos_node.sh --conversation ./harvest/.json +``` + +This scaffolds `~/.cogos/` (POSIX `for`-loop mkdir, not brace expansion) and runs `seed_observatory.py` ++ `seed_room.py` to populate the observatory and a pre-populated room from the harvested history. +Re-running is safe (idempotent). Verified clean in a fresh no-network container 2026-10-31. This step +ships **no agent** — it only lands the operator's own history into their own node. + +### 3. Mint the operator's agent from that history + +Hand off to the **`agent-from-history`** skill: point it at the just-seeded sessions (and the +counterpart they worked with, e.g. "Wingnut"). It reads *their* turns from the observatory, derives +how they work and how they work with that agent, and writes an `identity_` card whose keyholder +is the operator — **derived on their machine, not shipped by us.** That is how the operator loads their +own agent (e.g. Wingnut) from their own session history. + +### 4. Mint identity keys (operator-owned) + +Generate their identity keypair (ed25519) under `~/.cogos/keys/`, mode 0600. Write the Identity CRD to +`~/.cogos/config/identity/default.yaml` with `sub` chosen by the operator. **They own these keys** — +never generate silently; prompt for `sub` and confirm before writing. + +### 4. (Optional) Acquire the CogOS binary + +- **Existing binary:** if `cogos` is on PATH, `ln -sf "$(command -v cogos)" ~/.cogos/bin/cogos`. +- **From GitHub:** clone the CogOS repo the operator points you at; pin the commit in + `~/.cogos/config/org-pins.yaml`. **Do NOT invent a URL** — ask; a wrong clone target is worse than a + prompt. + +### 5. Handoff + +Report: node tier at `~/.cogos/`, observatory seeded (N turns), the seeded history room, default agent, and +identity ``. They open the seeded room and are inside their own history with their agent. + +## Pitfalls + +- **Harvest reads only the operator's own session.** It holds no credentials and reads only the CDP + endpoint it is given. It must never be pointed at anyone else's browser/account. +- **A 0/short harvest is a blocked session, not an empty history** — re-check the login, don't seed empty. +- **Don't clobber an existing node.** Identity + `USER.md` are skip-if-exists; the memory append is + marker-guarded; the room seeder no-ops on re-run. If `~/.cogos/` already has a real identity, stop + and ask — re-seeding preferences is fine, overwriting keys is not. +- **The observatory seed is additive** — adds one source, never prunes the operator's others. +- **Keys are theirs.** The seed carries a self-model and history, never a private key. The operator mints. +- **Verify, don't assume.** After step 2, count the seeded turns and read one back; after step 1, run + `--count-only` first. "Copied successfully" is not verification. diff --git a/plugins/cogos-onboard/skills/cogos-onboard/scripts/harvest_claude_web.py b/plugins/cogos-onboard/skills/cogos-onboard/scripts/harvest_claude_web.py new file mode 100644 index 0000000..7b53b4b --- /dev/null +++ b/plugins/cogos-onboard/skills/cogos-onboard/scripts/harvest_claude_web.py @@ -0,0 +1,546 @@ +#!/usr/bin/env python3 +"""harvest_claude_web.py — harvest YOUR OWN claude.ai web conversation history. + +Drives a browser YOU are logged into to claude.ai over the Chrome DevTools Protocol (CDP), +runs the claude.ai web API from inside that tab (so your cookies ride along and Cloudflare +passes), and normalizes every conversation into: + + /conversations/.json per-conversation record, shaped like the conversation seed consumed by seed_observatory.py / seed_room.py + ({source_url, subject, turns:[{role,text,ts}]}) so + seed_observatory.py / seed_room.py can consume it directly + /observatory/claude-web__.json + observatory index: one JSON array of turn records per + conversation, each with a content-addressed refs.stable_id + (re-running is idempotent) + /rooms.json stage-room seed store (same shape seed_room.py writes), + one room per conversation; skipped/updated idempotently + /harvest_manifest.json counts, skipped items, errors (no message text) + +PRIVACY: this script only talks to the browser endpoint you point it at (default +http://127.0.0.1:9222 on YOUR machine). It holds no credentials, reads no cookie store, and +never writes anything outside --out. Nothing is uploaded anywhere. + +Requirements: Python 3.8+ standard library only. A Chromium-based browser (Chrome, Edge, +Brave, BrowserOS) started with remote debugging, and claude.ai logged in in that browser. +""" +import argparse +import base64 +import hashlib +import json +import os +import secrets +import socket +import struct +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +CLAUDE_HOST = "claude.ai" +DEFAULT_CDP = "http://127.0.0.1:9222" + +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_USAGE = 2 +EXIT_BLOCKED = 3 # logged out, or Cloudflare challenge that never cleared + + +class Blocked(Exception): + """Logged out, or Cloudflare / login wall in the browser tab.""" + + +class ApiError(Exception): + """A claude.ai API call returned something unexpected for one item.""" + + +# ── raw CDP over stdlib WebSocket (RFC 6455, client side) ─────────────────── + +def http_json(base, path, method="GET"): + req = urllib.request.Request(base + path, method=method) + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read().decode("utf-8") or "null") + + +def _recv_exact(sock, n): + buf = b"" + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise IOError("websocket closed by browser") + buf += chunk + return buf + + +def _ws_connect(host, port, path): + key = base64.b64encode(os.urandom(16)).decode() + sock = socket.create_connection((host, port), timeout=120) + req = (f"GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n" + f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n") + sock.sendall(req.encode()) + resp = b"" + while b"\r\n\r\n" not in resp: + chunk = sock.recv(4096) + if not chunk: + raise IOError("websocket handshake closed") + resp += chunk + status_line = resp.split(b"\r\n", 1)[0] + if b" 101 " not in status_line: + raise IOError("websocket upgrade refused: %r" % (status_line[:120],)) + return sock + + +def _ws_send(sock, opcode, data): + mask = os.urandom(4) + hdr = bytearray([0x80 | opcode]) + n = len(data) + if n < 126: + hdr.append(0x80 | n) + elif n < 65536: + hdr.append(0x80 | 126) + hdr += struct.pack(">H", n) + else: + hdr.append(0x80 | 127) + hdr += struct.pack(">Q", n) + hdr += mask + sock.sendall(bytes(hdr) + bytes(b ^ mask[i % 4] for i, b in enumerate(data))) + + +def _ws_recv_json(sock): + """Return the next complete text message as parsed JSON (handles fragments and pings).""" + msg = b"" + while True: + b1, b2 = _recv_exact(sock, 2) + fin = b1 & 0x80 + opcode = b1 & 0x0F + ln = b2 & 0x7F + if ln == 126: + ln = struct.unpack(">H", _recv_exact(sock, 2))[0] + elif ln == 127: + ln = struct.unpack(">Q", _recv_exact(sock, 8))[0] + payload = _recv_exact(sock, ln) # server frames are unmasked + if opcode == 0x8: + raise IOError("websocket closed by browser") + if opcode == 0x9: # ping -> pong + _ws_send(sock, 0xA, payload) + continue + if opcode == 0xA: # unsolicited pong + continue + if opcode != 0: # start of a new data frame + msg = payload + else: + msg += payload + if fin and opcode in (0x0, 0x1, 0x2): + return json.loads(msg.decode("utf-8")) + + +class CDPSession: + """One websocket to one page target; sequential Runtime.evaluate calls.""" + + def __init__(self, ws_url): + u = urllib.parse.urlparse(ws_url) + self.sock = _ws_connect(u.hostname, u.port or 80, + u.path + (("?" + u.query) if u.query else "")) + self._id = 0 + + def call(self, method, params=None): + self._id += 1 + my_id = self._id + _ws_send(self.sock, 0x1, json.dumps( + {"id": my_id, "method": method, "params": params or {}}).encode("utf-8")) + while True: + m = _ws_recv_json(self.sock) + if m.get("id") == my_id: + if "error" in m: + raise IOError(f"CDP {method} error: {m['error']}") + return m.get("result", {}) + + def evaluate(self, expression): + r = self.call("Runtime.evaluate", { + "expression": expression, + "returnByValue": True, + "awaitPromise": True, + }) + if "exceptionDetails" in r: + raise IOError("page-side exception during evaluate") + return (r.get("result") or {}).get("value") + + def close(self): + try: + self.sock.close() + except Exception: + pass + + +# ── claude.ai access (runs INSIDE the operator's logged-in tab) ───────────── + +# The fetch runs in the page so the operator's own cookies are sent. It returns a JSON +# envelope {status, ct, body} so Python can classify the response. +FETCH_JS = """(async (path) => { + try { + const r = await fetch(path, {credentials: 'include', headers: {'accept': 'application/json'}}); + const body = await r.text(); + return JSON.stringify({status: r.status, ct: r.headers.get('content-type') || '', body: body}); + } catch (e) { + return JSON.stringify({status: 0, ct: '', body: '', error: String(e)}); + } +})""" + + +def api_get(cdp, path): + raw = cdp.evaluate(FETCH_JS + "(" + json.dumps(path) + ")") + env = json.loads(raw) if raw else {"status": 0, "body": ""} + status, body = env.get("status", 0), env.get("body", "") + if status == 200: + try: + return json.loads(body) + except ValueError: + raise Blocked("200 but non-JSON body (Cloudflare challenge or login page)") + if status in (401, 403) or "Just a moment" in body or "cf-chl" in body: + raise Blocked(f"HTTP {status} from claude.ai (logged out or Cloudflare challenge)") + raise ApiError(f"HTTP {status} {env.get('error', '')} on {path.split('?')[0]}") + + +def wait_ready(cdp, timeout=90): + t0 = time.time() + announced = False + while True: + try: + info = json.loads(cdp.evaluate( + "JSON.stringify({ready: document.readyState, title: document.title, " + "host: location.host})") or "{}") + except (IOError, ValueError): + info = {} + if (info.get("host", "").endswith(CLAUDE_HOST) and info.get("ready") == "complete" + and "Just a moment" not in info.get("title", "")): + return + if time.time() - t0 > timeout: + raise Blocked(f"claude.ai tab not ready (host={info.get('host')!r}, " + f"title={info.get('title')!r}). Solve any Cloudflare check or log in " + f"in that browser tab, then re-run.") + if not announced: + print("waiting for the claude.ai tab to be ready ...") + announced = True + time.sleep(2) + + +def get_orgs(cdp): + boot = api_get(cdp, "/api/bootstrap") + acct = boot.get("account") if isinstance(boot, dict) else None + if not acct: + raise Blocked("bootstrap returned no account: the browser is not logged into claude.ai") + orgs = {} + for m in acct.get("memberships") or []: + o = (m or {}).get("organization") or {} + if o.get("uuid"): + orgs[o["uuid"]] = o.get("name") or o["uuid"] + if not orgs: + raise Blocked("logged in, but no organization memberships found") + return orgs + + +def list_conversations(cdp, org, page_size): + """Paginate the conversation list. Stops on an empty page or a page with no new uuids, + so it is correct even if the server caps page size below the requested limit.""" + out, seen, offset = [], set(), 0 + while True: + page = api_get(cdp, f"/api/organizations/{org}/chat_conversations" + f"?limit={page_size}&offset={offset}") + if not isinstance(page, list): + raise ApiError("conversation list was not an array") + new = 0 + for c in page: + u = (c or {}).get("uuid") + if u and u not in seen: + seen.add(u) + out.append(c) + new += 1 + if not page or new == 0: + break + offset += len(page) + return out + + +def get_conversation_turns(cdp, org, uuid): + d = api_get(cdp, f"/api/organizations/{org}/chat_conversations/{uuid}" + f"?tree=True&rendering_mode=messages") + msgs = d.get("chat_messages") if isinstance(d, dict) else None + if msgs is None: + raise ApiError("response had no chat_messages") + turns = [] + for m in msgs: + role = {"human": "human", "assistant": "assistant"}.get((m or {}).get("sender")) + if not role: + continue + text = m.get("text") or "" + if not text: + blocks = m.get("content") + if isinstance(blocks, str): + text = blocks + elif isinstance(blocks, list): + text = "\n".join(b.get("text", "") for b in blocks + if isinstance(b, dict) and b.get("type") == "text") + text = (text or "").strip() + if text: + turns.append({"role": role, "text": text, "ts": m.get("created_at")}) + return turns + + +# ── tab selection (never navigates your existing tab) ─────────────────────── + +def ws_for_target(base, target): + bu = urllib.parse.urlparse(base) + path = urllib.parse.urlparse(target["webSocketDebuggerUrl"]).path + return f"ws://{bu.hostname}:{bu.port or 80}{path}" + + +def pick_claude_tab(base): + """Use an existing claude.ai tab if one is open. Otherwise open our own tab and return + its id so we close it afterwards. Returns (ws_url, created_tab_id_or_None).""" + pages = [t for t in http_json(base, "/json/list") + if t.get("type") == "page" and t.get("webSocketDebuggerUrl")] + hits = [t for t in pages if CLAUDE_HOST in t.get("url", "")] + if hits: + return ws_for_target(base, hits[0]), None + try: + t = http_json(base, "/json/new?https://claude.ai/", method="PUT") + except urllib.error.HTTPError: + t = http_json(base, "/json/new?https://claude.ai/") + return ws_for_target(base, t), t["id"] + + +def close_created_tab(base, tab_id): + try: + urllib.request.urlopen(f"{base}/json/close/{tab_id}", timeout=10).read() + except Exception: + pass + + +# ── normalization (shapes mirror seed_observatory.py / seed_room.py) ──────── + +def sha16(s): + return hashlib.sha256(s.encode("utf-8")).hexdigest()[:16] + + +def atomic_write_json(path, obj): + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(obj, f, indent=1, ensure_ascii=False) + os.replace(tmp, path) + + +def write_observatory(obs_dir, session, title, source_url, turns): + source = "claude-web" + records = [] + for i, t in enumerate(turns): + # content-addressed: identical content re-harvested gives identical stable_ids + sid = f"{source}:{sha16(session + str(i) + t['text'][:200])}" + records.append({ + "identity": {"stable_id": sid}, + "refs": {"stable_id": sid}, + "role": "user" if t["role"] == "human" else "assistant", + "schema": "observatory.v1", + "session_id": f"{source}__{session}", + "source": source, + "text": t["text"], + "timestamp": t.get("ts"), + "session_title": title, + "source_url": source_url, + }) + atomic_write_json(os.path.join(obs_dir, f"{source}__{session}.json"), records) + + +def upsert_room(store, source_url, title, turns, updated_at, owner, agent): + """Idempotent: same room id per conversation. Returns 'created' | 'updated' | 'unchanged'.""" + rid = "cw" + sha16(source_url)[:6] + history = [{"id": f"h-{i:04d}", "kind": "me" if t["role"] == "human" else "cog", + "text": t["text"], "ts": t.get("ts")} for i, t in enumerate(turns)] + rooms = store.setdefault("rooms", []) + existing = next((r for r in rooms if r.get("id") == rid), None) + if existing: + prefs = existing.setdefault("prefs", {}) + if prefs.get("source_updated_at") == updated_at and existing.get("history") == history: + return "unchanged" + existing["title"] = title + existing["history"] = history + existing["updated"] = time.time() + prefs["source_updated_at"] = updated_at + return "updated" + rooms.append({ + "id": rid, + "key": secrets.token_urlsafe(18), # the room's own bearer key, generated locally + "title": title, + "history": history, + "owner": owner, + "members": [owner], + "agent": agent, + "source_session": source_url, + "updated": time.time(), + "feed": [], "board": [], "work_queue": [], "files": [], "artifacts": {}, + "prefs": {"seeded_from": "claude-web-history", "source_updated_at": updated_at}, + }) + return "created" + + +def load_json(path, default): + if os.path.exists(path): + with open(path, encoding="utf-8") as f: + return json.load(f) + return default + + +# ── main ──────────────────────────────────────────────────────────────────── + +def main(argv=None): + ap = argparse.ArgumentParser( + description="Harvest your own claude.ai web conversations via your logged-in browser " + "(CDP) into observatory + stage-room seed files.") + ap.add_argument("--cdp", default=DEFAULT_CDP, + help=f"CDP HTTP endpoint of YOUR browser (default {DEFAULT_CDP})") + ap.add_argument("--out", default="claude-web-harvest", help="output directory") + ap.add_argument("--org", default=None, + help="only this organization (uuid or name substring); default: all orgs") + ap.add_argument("--limit", type=int, default=0, + help="fetch at most N conversations in total (0 = all); for testing") + ap.add_argument("--count-only", action="store_true", + help="list conversations per org and stop (no message fetch)") + ap.add_argument("--no-rooms", action="store_true", help="skip stage-room seed output") + ap.add_argument("--owner", default="operator", help="room owner id (default: operator)") + ap.add_argument("--agent", default="identity_operator", + help="agent id seeded into rooms (default identity_operator)") + ap.add_argument("--page-size", type=int, default=50, help="conversation list page size") + ap.add_argument("--delay", type=float, default=0.25, + help="seconds between conversation fetches (be gentle)") + args = ap.parse_args(argv) + + base = args.cdp.rstrip("/") + out = args.out + conv_dir = os.path.join(out, "conversations") + obs_dir = os.path.join(out, "observatory") + rooms_path = os.path.join(out, "rooms.json") + manifest = {"started": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "cdp": base, + "orgs": {}, "conversations_listed": 0, "fetched": 0, "reused": 0, + "empty": 0, "errors": [], "rooms": {"created": 0, "updated": 0, "unchanged": 0}} + cdp = None + created_tab = None + code = EXIT_OK + + try: + try: + ws_url, created_tab = pick_claude_tab(base) + except (OSError, ValueError) as e: + print(f"cannot reach a CDP endpoint at {base}: {e}\n" + f"start your browser with remote debugging, e.g.\n" + f" chrome --remote-debugging-port=9222 (and log into claude.ai in it)", + file=sys.stderr) + return EXIT_BLOCKED + + cdp = CDPSession(ws_url) + wait_ready(cdp) + + orgs = get_orgs(cdp) + if args.org: + orgs = {u: n for u, n in orgs.items() + if args.org == u or args.org.lower() in n.lower()} + if not orgs: + print(f"no organization matches --org {args.org!r}", file=sys.stderr) + return EXIT_USAGE + manifest["orgs"] = orgs + + convs = [] + for org, oname in orgs.items(): + lst = list_conversations(cdp, org, args.page_size) + print(f"org {oname!r} ({org[:8]}): {len(lst)} conversations") + convs.extend((org, oname, c) for c in lst) + manifest["conversations_listed"] = len(convs) + print(f"total conversations listed: {len(convs)}") + if args.count_only: + return EXIT_OK + if args.limit and args.limit > 0: + convs = convs[:args.limit] + print(f"--limit: processing first {len(convs)}") + + rooms_store = {"rooms": []} if args.no_rooms else load_json(rooms_path, {"rooms": []}) + consecutive_blocked = 0 + + for idx, (org, oname, c) in enumerate(convs, 1): + uuid = c["uuid"] + name = c.get("name") or "(untitled)" + updated = c.get("updated_at") + conv_path = os.path.join(conv_dir, f"{uuid}.json") + source_url = f"https://claude.ai/chat/{uuid}" + title = f"Claude web — {name}" + try: + # resumable: reuse a saved conversation whose updated_at has not changed + prev = load_json(conv_path, None) if os.path.exists(conv_path) else None + if prev and prev.get("updated_at") == updated and prev.get("turns"): + turns = prev["turns"] + manifest["reused"] += 1 + else: + turns = get_conversation_turns(cdp, org, uuid) + consecutive_blocked = 0 + manifest["fetched"] += 1 + time.sleep(args.delay) + if not turns: + manifest["empty"] += 1 + continue + atomic_write_json(conv_path, { + "source": "claude-web", "source_url": source_url, "subject": title, + "uuid": uuid, "org": org, "org_name": oname, "name": name, + "created_at": c.get("created_at"), "updated_at": updated, + "turns": turns, + }) + session = uuid.replace("-", "") + write_observatory(obs_dir, session, title, source_url, turns) + if not args.no_rooms: + r = upsert_room(rooms_store, source_url, title, turns, updated, + args.owner, args.agent) + manifest["rooms"][r] += 1 + except Blocked as e: + consecutive_blocked += 1 + manifest["errors"].append({"uuid": uuid, "error": str(e)}) + print(f"[{idx}/{len(convs)}] blocked: {e}") + if consecutive_blocked >= 3: + raise + except (ApiError, IOError, ValueError, KeyError) as e: + manifest["errors"].append({"uuid": uuid, "error": str(e)}) + print(f"[{idx}/{len(convs)}] skipped {uuid[:8]}: {e}") + + if idx % 25 == 0: + print(f"progress {idx}/{len(convs)}") + if not args.no_rooms: + atomic_write_json(rooms_path, rooms_store) + + if not args.no_rooms: + atomic_write_json(rooms_path, rooms_store) + print(f"done: fetched={manifest['fetched']} reused={manifest['reused']} " + f"empty={manifest['empty']} errors={len(manifest['errors'])} " + f"rooms={manifest['rooms']}") + if manifest["errors"]: + code = EXIT_ERROR + + except Blocked as e: + msg = f"BLOCKED: {e}" + print(msg, file=sys.stderr) + manifest["errors"].append({"fatal": str(e)}) + code = EXIT_BLOCKED + except (OSError, IOError) as e: + print(f"connection problem: {e}", file=sys.stderr) + manifest["errors"].append({"fatal": str(e)}) + code = EXIT_ERROR + finally: + if cdp: + cdp.close() + if created_tab: + close_created_tab(base, created_tab) + manifest["finished"] = time.strftime("%Y-%m-%dT%H:%M:%S%z") + try: + atomic_write_json(os.path.join(out, "harvest_manifest.json"), manifest) + except OSError: + pass + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/cogos-onboard/skills/cogos-onboard/scripts/install_cogos_node.sh b/plugins/cogos-onboard/skills/cogos-onboard/scripts/install_cogos_node.sh new file mode 100644 index 0000000..121ad33 --- /dev/null +++ b/plugins/cogos-onboard/skills/cogos-onboard/scripts/install_cogos_node.sh @@ -0,0 +1,78 @@ +#!/bin/sh +# install_cogos_node.sh — scaffold a user-tier CogOS node (~/.cogos) and seed it +# from the operator's own harvested history. Ships NO agent and NO operator data. +# +# Usage: +# sh install_cogos_node.sh --conversation FILE [--owner NAME] [--agent NAME] [--home DIR] +# +# --conversation FILE harvested conversation JSON ({"turns":[{"role","text"}...]}) +# --owner NAME room owner (default: the operator; falls back to "operator") +# --agent NAME room's agent identity name (default: the agent-from-history +# skill mints this; a placeholder is fine until then) +# --home DIR node parent dir (default: $HOME). Node lives at DIR/.cogos +# +# The seeder scripts (seed_observatory.py, seed_room.py) ship ALONGSIDE this script +# and are found relative to it. POSIX sh only: no bashisms, no brace expansion. +# Idempotent: safe to re-run. Never clobbers an existing node's keys/rooms/identity. + +set -eu + +CONV="" +OWNER="operator" +AGENT="identity_operator" +HOME_DIR="${HOME:-}" + +die() { echo "install_cogos_node: error: $*" >&2; exit 1; } +info() { echo "install_cogos_node: $*"; } + +while [ "$#" -gt 0 ]; do + case "$1" in + --conversation) [ "$#" -ge 2 ] || die "--conversation needs a value"; CONV="$2"; shift 2 ;; + --owner) [ "$#" -ge 2 ] || die "--owner needs a value"; OWNER="$2"; shift 2 ;; + --agent) [ "$#" -ge 2 ] || die "--agent needs a value"; AGENT="$2"; shift 2 ;; + --home) [ "$#" -ge 2 ] || die "--home needs a value"; HOME_DIR="$2"; shift 2 ;; + -h|--help) sed -n '2,19p' "$0"; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +[ -n "$CONV" ] || die "--conversation is required" +[ -n "$HOME_DIR" ] || die "no home directory (set --home or HOME)" +[ -f "$CONV" ] || die "conversation JSON not found: $CONV" +command -v python3 >/dev/null 2>&1 || die "python3 is required" + +# Seeder scripts ship alongside this script. +SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)" +[ -f "$SCRIPTS_DIR/seed_observatory.py" ] || die "seed_observatory.py not found next to this script" +[ -f "$SCRIPTS_DIR/seed_room.py" ] || die "seed_room.py not found next to this script" + +NODE="$HOME_DIR/.cogos" +info "node tier: $NODE" + +# ---- 1. Scaffold the user tier (POSIX for-loop, NOT brace expansion) ------- +for d in bin config/identity hooks mem state/conversations logs org stage; do + mkdir -p "$NODE/$d" +done +info "scaffold: ok" + +STATE="$NODE/bootstrap-state.json" +printf '%s\n' \ + '{"bootstrap_version":"0.1.0","phases":{' \ + '"scaffold":"done",' \ + '"observatory":"done",' \ + '"room":"done"' \ + '}}' > "$STATE.tmp" +mv "$STATE.tmp" "$STATE" + +# ---- 2. Seed observatory + room from the operator's OWN history ------------- +# seed_observatory.py writes a deterministic __.json (same bytes on re-run). +OBS_DIR="$NODE/state/conversations" +python3 "$SCRIPTS_DIR/seed_observatory.py" "$CONV" "$OBS_DIR" || die "seed_observatory.py failed" + +# seed_room.py is additive and no-ops when its room id already exists. +ROOMS="$NODE/state/rooms.json" +python3 "$SCRIPTS_DIR/seed_room.py" "$CONV" "$ROOMS" --owner "$OWNER" --agent "$AGENT" \ + || die "seed_room.py failed" + +info "done. node at $NODE" +info "next: mint the operator's agent from this history (agent-from-history skill), then identity keys" diff --git a/plugins/cogos-onboard/skills/cogos-onboard/scripts/seed_observatory.py b/plugins/cogos-onboard/skills/cogos-onboard/scripts/seed_observatory.py new file mode 100644 index 0000000..31d950a --- /dev/null +++ b/plugins/cogos-onboard/skills/cogos-onboard/scripts/seed_observatory.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""seed_observatory.py — normalize a Claude-share conversation export into a CogOS observatory index. + +Usage: seed_observatory.py + +Writes one index file in the observatory's `__.json` shape (a JSON array of turn +records), source `claude-web`, each turn carrying refs.stable_id — so the operator's Cog can +retrieve the shared history from turn one. Additive: writes a new source file, never prunes. +""" +import hashlib +import json +import os +import sys + + +def main(): + if len(sys.argv) != 3: + print("usage: seed_observatory.py ", file=sys.stderr) + return 2 + export_path, out_dir = sys.argv[1], sys.argv[2] + with open(export_path) as f: + data = json.load(f) + turns = data.get("turns") or data.get("messages") or (data if isinstance(data, list) else []) + if not turns: + print("no turns found in export", file=sys.stderr) + return 1 + + # a stable session id derived from the export (the share id if present, else a content hash) + src_url = data.get("source_url", "") + session = "".join(c for c in src_url.split("/")[-1] if c.isalnum())[:24] or \ + hashlib.sha256(export_path.encode()).hexdigest()[:16] + source = "claude-web" + + role_map = {"human": "user", "assistant": "assistant"} + records = [] + for i, t in enumerate(turns): + role = role_map.get(t.get("role", ""), t.get("role", "user")) + text = t.get("text") or t.get("content") or "" + # stable_id: content-addressed so re-seeding is idempotent (not keyed on volatile index) + sid = f"{source}:{hashlib.sha256((session + str(i) + text[:200]).encode()).hexdigest()[:16]}" + records.append({ + "identity": {"stable_id": sid}, + "refs": {"stable_id": sid}, + "role": role, + "schema": "observatory.v1", + "session_id": f"{source}__{session}", + "source": source, + "text": text, + "timestamp": None, # the export carries no per-turn timestamps + "session_title": data.get("subject", "My session history"), + }) + + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, f"{source}__{session}.json") + with open(out_path, "w") as f: + json.dump(records, f, indent=1) + print(f"seeded {len(records)} turns -> {out_path}") + print(f"source={source} session={session}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/cogos-onboard/skills/cogos-onboard/scripts/seed_room.py b/plugins/cogos-onboard/skills/cogos-onboard/scripts/seed_room.py new file mode 100644 index 0000000..63b4276 --- /dev/null +++ b/plugins/cogos-onboard/skills/cogos-onboard/scripts/seed_room.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""seed_room.py — seed a pre-populated stage room from a harvested conversation seed. + +Usage: seed_room.py [--title T] [--agent AGENT_ID] [--owner OWNER_ID] + +A room is the stage's native container for a conversation-with-an-agent. This lands the operator's agent +history as a room the operator OPENS and is immediately *inside* — not a cold workspace. A room the operator opens and is immediately inside of. Additive + idempotent (room id is a content hash). +re-running is a no-op for the same conversation +already exists; never clobbers his other rooms. +""" +import hashlib +import json +import os +import secrets +import sys +import time + + +def main(): + if len(sys.argv) < 3: + print("usage: seed_room.py [--title T] [--agent A] [--owner O]", file=sys.stderr) + return 2 + conv_path, rooms_path = sys.argv[1], sys.argv[2] + args = sys.argv[3:] + def opt(flag, default): + return args[args.index(flag) + 1] if flag in args else default + title = opt("--title", "My conversation history") + agent = opt("--agent", "identity_operator") + owner = opt("--owner", "operator") + + conv = json.load(open(conv_path)) + turns = conv.get("turns") or conv.get("messages") or [] + if not turns: + print("no turns in conversation", file=sys.stderr) + return 1 + + # load or init the room store + if os.path.exists(rooms_path): + store = json.load(open(rooms_path)) + else: + os.makedirs(os.path.dirname(rooms_path) or ".", exist_ok=True) + store = {"rooms": []} + rooms = store.setdefault("rooms", []) + + # idempotency: a content-addressed room id from the conversation, so re-seeding is a no-op + rid = "sr" + hashlib.sha256((conv.get("source_url", "") + title).encode()).hexdigest()[:6] + if any(r.get("id") == rid for r in rooms): + print(f"room {rid} already seeded; no-op") + return 0 + + # build the room's history from the turns ("me" = the person, "cog" = the agent) + history = [] + for i, t in enumerate(turns): + kind = "me" if t.get("role") == "human" else "cog" + history.append({ + "id": f"h-{i:04d}", + "kind": kind, + "text": t.get("text") or t.get("content") or "", + "ts": None, # the export carries no per-turn timestamps + }) + + room = { + "id": rid, + "key": secrets.token_urlsafe(18), # the room's own bearer key (he owns it) + "title": title, + "history": history, + "owner": owner, + "members": [owner], + "agent": agent, # agent id for this room (default identity_operator; agent-from-history mints the real one) + "source_session": conv.get("source_url", "claude-web"), + "updated": time.time(), + "feed": [], "board": [], "work_queue": [], "files": [], "artifacts": {}, + "prefs": {"seeded_from": "claude-web-history"}, + } + rooms.append(room) + json.dump(store, open(rooms_path, "w"), indent=1) + print(f"seeded room {rid} '{title}' with {len(history)} turns, agent={agent}, owner={owner}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())