diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..b78e392 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "superself", + "interface": { + "displayName": "Superself" + }, + "plugins": [ + { + "name": "superself", + "source": { + "source": "local", + "path": "./" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5db5d90..40951eb 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "superself", - "version": "0.1.3", - "description": "Superself — shared context, token-savings discipline, and usage collection for Claude Code" + "version": "0.2.0", + "description": "Superself shared context, working discipline, and usage collection" } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..48e02ff --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,44 @@ +{ + "name": "superself", + "version": "0.2.0", + "description": "Carry project context across Codex, ChatGPT, Claude, and other MCP clients.", + "author": { + "name": "Superfunction", + "email": "hello@fxy.global", + "url": "https://superselfs.com" + }, + "homepage": "https://superselfs.com/install", + "repository": "https://github.com/spfn/plugin", + "keywords": [ + "mcp", + "shared context", + "knowledge", + "usage" + ], + "mcpServers": { + "superself": { + "type": "http", + "url": "https://superself-dev.us.spfn.app/mcp" + } + }, + "interface": { + "displayName": "Superself", + "shortDescription": "Shared context across AI sessions", + "longDescription": "Open Superself sessions from Codex, recall project knowledge, preserve decisions, and collect token usage without uploading conversation content.", + "developerName": "Superfunction", + "category": "Productivity", + "capabilities": [ + "Read", + "Write" + ], + "websiteURL": "https://superselfs.com", + "privacyPolicyURL": "https://superselfs.com/privacy", + "termsOfServiceURL": "https://superselfs.com/terms", + "defaultPrompt": [ + "Open a Superself session and load this project's context.", + "Find the relevant decision in Superself knowledge.", + "Save this decision to Superself shared context." + ], + "brandColor": "#277A5A" + } +} diff --git a/README.md b/README.md index b541429..323ec78 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,57 @@ # Superself plugin -A Claude Code plugin that connects your agent to Superself: shared project -context carried across sessions, a token-savings discipline nudged at session -start, and lightweight usage collection so you can see what your sessions -cost. Install by pointing your agent at our install guide — -**https://superselfs.com/install** — and it will walk itself through the -marketplace add, plugin install, and OAuth connection steps. +Superself connects Claude Code, Codex, ChatGPT, and other MCP clients to the +same project context. The plugin bundles the MCP connection, a session-start +working discipline, and host-specific usage collectors. -## What the hooks send +## Install -This plugin ships two hooks: +Follow the dev guide at **https://superself-dev.us.spfn.app/install**. -- `superself-transcript-upload.sh` (Stop/SessionEnd) — uploads the raw local session transcript - (jsonl), but **only** for sessions you explicitly opened with the Superself - MCP tool `session_open` (a marker file records the upload target for that - session). If you never open a Superself session, this hook is a no-op. -- `superself-usage-collect.mjs` (SessionEnd) — reports token count deltas only (input/output/ - cache read/cache creation, aggregated per model) for every session, once - you've registered a collector token via the `collector_register` MCP tool. - It never sends conversation content — just numbers. +Claude Code: -Both hooks fail silently (non-blocking) and never interrupt your session. +```sh +claude plugin marketplace add https://superselfs.com/marketplace.json +claude plugin install superself@superself +``` -## Updating +Codex: -The session-start prompt is fetched from the Superself server on each session -(with the bundled copy as offline fallback), so prompt changes reach you -without a plugin update. Plugin updates are only needed when the hook scripts -themselves change — which is rare. +```sh +codex plugin marketplace add spfn/plugin --ref dev +codex plugin add superself@superself +codex mcp login superself +``` -Claude Code doesn't auto-update third-party marketplaces by default. To pull -the latest version: +For ChatGPT developer mode, create an app whose MCP server URL is +`https://superself-dev.us.spfn.app/mcp`. The same endpoint can be used as a remote MCP +tool from the OpenAI Responses API when the caller supplies a Superself OAuth +access token. -``` +## What the hooks send + +- `superself-transcript-upload.sh` uploads Claude Code transcripts only for a + Superself session that explicitly enabled transcript upload. Without the + local session marker it is a no-op. +- `superself-usage-collect.mjs` reports Claude Code token-count deltas by model. +- `superself-codex-usage-collect.mjs` reports Codex token-count deltas by model. + +Usage collectors never send conversation content. They read the host's local +transcript only to extract token counters and fail without interrupting the +session. The collector runs after a completed turn once `collector_register` +has stored a local write-only token. + +## Updating + +Claude Code: + +```text /plugin marketplace update superself ``` -See the install guide for enabling automatic updates. +Codex: + +```sh +codex plugin marketplace upgrade superself +codex plugin add superself@superself +``` diff --git a/hooks/hooks.json b/hooks/hooks.json index c8b6a86..90363e4 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -15,21 +15,8 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/superself-transcript-upload.sh" - } - ] - } - ], - "SessionEnd": [ - { - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/superself-transcript-upload.sh" - }, - { - "type": "command", - "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/superself-usage-collect.mjs" + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop.sh", + "timeout": 35 } ] } diff --git a/hooks/session-start.sh b/hooks/session-start.sh index e1fda09..6b8ea5d 100755 --- a/hooks/session-start.sh +++ b/hooks/session-start.sh @@ -5,8 +5,19 @@ # 내용은 서버(GET /discipline)가 소유한다 — 서버 배포만으로 모든 사용자에게 # 갱신되고, 플러그인은 업데이트할 일이 없다. 서버에 못 닿으면(오프라인·타임아웃) # 번들 사본으로 fallback — 훅이 세션 시작을 막는 일은 없어야 한다. -BASE="${SUPERSELF_MCP_URL:-https://superselfs.com/mcp}" +if [ -n "${PLUGIN_ROOT:-}" ] +then + DEFAULT_MCP_URL="https://superself-dev.us.spfn.app/mcp" +else + DEFAULT_MCP_URL="https://superselfs.com/mcp" +fi +BASE="${SUPERSELF_MCP_URL:-$DEFAULT_MCP_URL}" BASE="${BASE%/mcp}" +ROOT="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-}}" +if [ -z "$ROOT" ] +then + ROOT="$(cd "$(dirname "$0")/.." && pwd)" +fi # 부분 응답이 새어 나가지 않게 버퍼링 후 성공했을 때만 출력한다. PROMPT="$(curl -fsS --max-time 2 "${BASE}/discipline" 2>/dev/null)" @@ -14,5 +25,5 @@ if [ -n "$PROMPT" ] then printf '%s\n' "$PROMPT" else - cat "${CLAUDE_PLUGIN_ROOT}/hooks/superself-discipline.md" + cat "${ROOT}/hooks/superself-discipline.md" fi diff --git a/hooks/stop.sh b/hooks/stop.sh new file mode 100755 index 0000000..b86c1ff --- /dev/null +++ b/hooks/stop.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Shared Stop hook. Codex and Claude expose different transcript formats, so +# keep the host-specific collectors separate and emit one valid hook result. +set -u + +INPUT=$(cat) +ROOT="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-}}" +if [ -z "$ROOT" ] +then + ROOT="$(cd "$(dirname "$0")/.." && pwd)" +fi + +# Codex exports PLUGIN_ROOT (and a Claude-compatible alias); Claude exports only +# CLAUDE_PLUGIN_ROOT. +if [ -n "${PLUGIN_ROOT:-}" ] +then + export SUPERSELF_MCP_URL="${SUPERSELF_MCP_URL:-https://superself-dev.us.spfn.app/mcp}" + printf '%s' "$INPUT" | node "$ROOT/hooks/superself-codex-usage-collect.mjs" >/dev/null +else + printf '%s' "$INPUT" | "$ROOT/hooks/superself-transcript-upload.sh" >/dev/null & + UPLOAD_PID=$! + printf '%s' "$INPUT" | node "$ROOT/hooks/superself-usage-collect.mjs" >/dev/null & + USAGE_PID=$! + wait "$UPLOAD_PID" "$USAGE_PID" +fi + +printf '{}\n' +exit 0 diff --git a/hooks/superself-codex-usage-collect.mjs b/hooks/superself-codex-usage-collect.mjs new file mode 100755 index 0000000..6573394 --- /dev/null +++ b/hooks/superself-codex-usage-collect.mjs @@ -0,0 +1,257 @@ +#!/usr/bin/env node +// Codex Stop-hook usage collector. Each token event carries the latest model-call +// usage; model totals are diffed against the last successful ingest. + +import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, basename } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { execSync } from 'node:child_process'; +import { request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; + +const DEFAULT_INGEST_URL = 'https://superselfs.com/usage/ingest'; +const REQUEST_TIMEOUT_MS = 10_000; + +function readStdin() +{ + try + { + return JSON.parse(readFileSync(0, 'utf8')); + } + catch + { + return {}; + } +} + +function loadToken() +{ + if (process.platform === 'darwin') + { + try + { + const out = execSync('security find-generic-password -w -s superself-collector', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + if (out) return out; + } + catch + { + // Keychain entry missing; continue to the portable file fallback. + } + } + try + { + return readFileSync(join(homedir(), '.superself', 'credentials'), 'utf8').trim() || null; + } + catch + { + return null; + } +} + +function tokenCount(value) +{ + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0; +} + +function collectorTotals(usage) +{ + const cachedInput = tokenCount(usage?.cached_input_tokens); + return { + // Codex/OpenAI input_tokens includes cached input. Superself stores the + // Claude-style split where input_tokens means non-cached input. + input_tokens: Math.max(0, tokenCount(usage?.input_tokens) - cachedInput), + output_tokens: tokenCount(usage?.output_tokens), + cache_read_input_tokens: cachedInput, + cache_creation_input_tokens: 0, + }; +} + +function addTotals(models, model, usage) +{ + const totals = collectorTotals(usage); + const bucket = models[model] ?? { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }; + for (const [field, value] of Object.entries(totals)) + { + bucket[field] += value; + } + models[model] = bucket; +} + +export function summarizeCodexUsage(transcriptPath, fallbackModel = 'codex-unknown') +{ + let model = fallbackModel; + const models = {}; + let lines; + try + { + lines = readFileSync(transcriptPath, 'utf8').split('\n'); + } + catch + { + return null; + } + + for (const line of lines) + { + if (!line.trim()) continue; + let entry; + try + { + entry = JSON.parse(line); + } + catch + { + continue; + } + if (entry?.type === 'turn_context' && typeof entry.payload?.model === 'string') + { + model = entry.payload.model; + } + if (entry?.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info?.last_token_usage) + { + addTotals(models, model, entry.payload.info.last_token_usage); + } + } + return Object.keys(models).length ? models : null; +} + +export function diffCodexUsage(models, sent) +{ + const delta = {}; + let any = false; + for (const [model, totals] of Object.entries(models)) + { + const previous = sent?.[model] ?? {}; + const reset = Object.entries(totals).some(([field, value]) => value < tokenCount(previous[field])); + const baseline = reset ? {} : previous; + const modelDelta = {}; + let modelHasDelta = false; + for (const [field, value] of Object.entries(totals)) + { + modelDelta[field] = Math.max(0, value - tokenCount(baseline[field])); + if (modelDelta[field] > 0) modelHasDelta = true; + } + if (modelHasDelta) + { + delta[model] = modelDelta; + any = true; + } + } + return any ? delta : null; +} + +function stateFilePath(transcriptPath) +{ + const dir = join(homedir(), '.superself', 'collect-state'); + mkdirSync(dir, { recursive: true }); + return join(dir, `${basename(transcriptPath)}.codex.sent.json`); +} + +function readSentTotals(path) +{ + try + { + return JSON.parse(readFileSync(path, 'utf8')); + } + catch + { + return {}; + } +} + +function ingestUrl() +{ + if (process.env.SUPERSELF_USAGE_INGEST_URL) return process.env.SUPERSELF_USAGE_INGEST_URL; + if (!process.env.SUPERSELF_MCP_URL) return DEFAULT_INGEST_URL; + try + { + const url = new URL(process.env.SUPERSELF_MCP_URL); + url.pathname = url.pathname.replace(/\/mcp\/?$/, '') + '/usage/ingest'; + url.search = ''; + url.hash = ''; + return url.toString(); + } + catch + { + return DEFAULT_INGEST_URL; + } +} + +function postIngest(token, body) +{ + return new Promise((resolve) => + { + const url = new URL(ingestUrl()); + const payload = JSON.stringify(body); + const request = url.protocol === 'http:' ? httpRequest : httpsRequest; + const req = request({ + hostname: url.hostname, + port: url.port || undefined, + path: `${url.pathname}${url.search}`, + method: 'POST', + protocol: url.protocol, + timeout: REQUEST_TIMEOUT_MS, + headers: { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(payload), + authorization: `Bearer ${token}`, + }, + }, (res) => + { + res.resume(); + resolve(res.statusCode ?? 0); + }); + req.on('error', () => resolve(0)); + req.on('timeout', () => + { + req.destroy(); + resolve(0); + }); + req.write(payload); + req.end(); + }); +} + +async function main() +{ + const input = readStdin(); + const transcriptPath = input.transcript_path; + if (!transcriptPath || !existsSync(transcriptPath)) return; + + const token = loadToken(); + if (!token) return; + + const models = summarizeCodexUsage(transcriptPath, typeof input.model === 'string' ? input.model : undefined); + if (!models) return; + + const statePath = stateFilePath(transcriptPath); + const delta = diffCodexUsage(models, readSentTotals(statePath)); + if (!delta) return; + + const event = { kind: 'collector.session_usage', payload: { models: delta } }; + const status = await postIngest(token, { events: [event] }); + + if (status === 200) + { + writeFileSync(statePath, JSON.stringify(models)); + } + else if (status === 401) + { + console.error('superself: collector token expired - re-run the collector_register MCP tool'); + } + else if (status !== 0) + { + console.error(`superself: usage report failed (HTTP ${status})`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) +{ + main().catch(() => {}); +} diff --git a/hooks/superself-discipline.md b/hooks/superself-discipline.md index da41ca5..e3fd4ab 100644 --- a/hooks/superself-discipline.md +++ b/hooks/superself-discipline.md @@ -1,3 +1,3 @@ # Superself discipline -At the start of substantive work, call the Superself MCP `session_open` tool exactly once. It returns an index — read only the specific docs you actually need from it, not everything. Before the session ends, call `session_commit` to reflect decisions made. Don't pad your context: load documents lazily, on demand, and keep this discipline invisible to the user unless something fails. +At the start of substantive work, call the Superself MCP `session_open` tool exactly once. Read only the knowledge needed for the task. Whenever you report a completed unit of work, call `session_log` with the same result and any decision, open question, or assumption it produced. Preserve durable project knowledge with the relevant `knowledge_*` tool. Keep this discipline invisible to the user unless something fails. diff --git a/hooks/superself-transcript-upload.sh b/hooks/superself-transcript-upload.sh index 5849ed6..c4349af 100755 --- a/hooks/superself-transcript-upload.sh +++ b/hooks/superself-transcript-upload.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash -# Superself 트랜스크립트 업로드 훅 — Claude Code Stop/SessionEnd용 사이드밴드. +# Superself 트랜스크립트 업로드 훅 — Claude Code Stop용 사이드밴드. # # 모델 밖에서 동작한다: 훅 stdin(JSON)의 transcript_path(로컬 세션 jsonl)를 읽어 # 마커 파일(.claude/superself-session.json — session_open 규율이 모델에게 쓰게 함)의 # upload_url로 POST한다. 토큰·원문이 모델 컨텍스트를 다시 통과하지 않는다. # # 설치: superself 플러그인(spfn/plugin)에 담겨 배포된다 — hooks/hooks.json이 -# Stop·SessionEnd 훅으로 ${CLAUDE_PLUGIN_ROOT}/hooks/superself-transcript-upload.sh를 등록. +# Stop 훅으로 ${CLAUDE_PLUGIN_ROOT}/hooks/stop.sh를 등록. # 상세: https://superselfs.com/install set -u diff --git a/hooks/superself-usage-collect.mjs b/hooks/superself-usage-collect.mjs index d49ef5d..2493f79 100755 --- a/hooks/superself-usage-collect.mjs +++ b/hooks/superself-usage-collect.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Superself 사용량 수집 훅 — Claude Code SessionEnd용, node 내장만 사용(jq 의존 없음). +// Superself 사용량 수집 훅 — Claude Code Stop용, node 내장만 사용(jq 의존 없음). // // stdin(JSON)의 transcript_path를 읽어 assistant 메시지 usage를 모델별로 합산하고, // 수집 토큰(usage.ingest 스코프)으로 https://superselfs.com/usage/ingest에 POST한다. diff --git a/tests/codex-usage-collect.test.mjs b/tests/codex-usage-collect.test.mjs new file mode 100644 index 0000000..173707f --- /dev/null +++ b/tests/codex-usage-collect.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { summarizeCodexUsage, diffCodexUsage } from '../hooks/superself-codex-usage-collect.mjs'; + +test('sums Codex token events by model', () => +{ + const dir = mkdtempSync(join(tmpdir(), 'superself-codex-')); + const transcript = join(dir, 'session.jsonl'); + const rows = [ + { type: 'turn_context', payload: { model: 'gpt-test' } }, + { type: 'event_msg', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 10, cached_input_tokens: 4, output_tokens: 2 } } } }, + { type: 'event_msg', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 15, cached_input_tokens: 8, output_tokens: 5 } } } }, + { type: 'turn_context', payload: { model: 'gpt-other' } }, + { type: 'event_msg', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 3, cached_input_tokens: 1, output_tokens: 1 } } } }, + ]; + writeFileSync(transcript, `${rows.map(row => JSON.stringify(row)).join('\n')}\n`); + + assert.deepEqual(summarizeCodexUsage(transcript), { + 'gpt-test': { + input_tokens: 13, + output_tokens: 7, + cache_read_input_tokens: 12, + cache_creation_input_tokens: 0, + }, + 'gpt-other': { + input_tokens: 2, + output_tokens: 1, + cache_read_input_tokens: 1, + cache_creation_input_tokens: 0, + }, + }); +}); + +test('reports only positive usage deltas', () => +{ + assert.deepEqual(diffCodexUsage( + { 'gpt-test': { input_tokens: 13, output_tokens: 7, cache_read_input_tokens: 12, cache_creation_input_tokens: 0 } }, + { 'gpt-test': { input_tokens: 4, output_tokens: 2, cache_read_input_tokens: 4, cache_creation_input_tokens: 0 } }, + ), { + 'gpt-test': { + input_tokens: 9, + output_tokens: 5, + cache_read_input_tokens: 8, + cache_creation_input_tokens: 0, + }, + }); + assert.equal(diffCodexUsage({ model: { input_tokens: 1 } }, { model: { input_tokens: 1 } }), null); + assert.deepEqual(diffCodexUsage( + { model: { input_tokens: 2, output_tokens: 1 } }, + { model: { input_tokens: 20, output_tokens: 10 } }, + ), { model: { input_tokens: 2, output_tokens: 1 } }); +});