diff --git a/lexicons/ai.syui.gpt.core.json b/lexicons/ai.syui.gpt.core.json new file mode 100644 index 0000000..16194ff --- /dev/null +++ b/lexicons/ai.syui.gpt.core.json @@ -0,0 +1,55 @@ +{ + "lexicon": 1, + "id": "ai.syui.gpt.core", + "defs": { + "main": { + "type": "record", + "description": "AI identity and personality configuration. Typically one record per AI with rkey 'self'.", + "key": "any", + "record": { + "type": "object", + "required": ["did", "handle", "content", "createdAt"], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "DID of the AI agent." + }, + "handle": { + "type": "string", + "description": "Handle of the AI agent." + }, + "content": { + "type": "union", + "closed": false, + "refs": ["#markdown"], + "description": "Core personality and instructions. Supports markdown and other formats via $type." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp when this core record was created." + }, + "updatedAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp of the last update." + } + } + } + }, + "markdown": { + "type": "object", + "description": "Markdown content format.", + "required": ["text"], + "properties": { + "text": { + "type": "string", + "maxLength": 1000000, + "maxGraphemes": 100000, + "description": "Markdown text content." + } + } + } + } +} diff --git a/lexicons/ai.syui.gpt.memory.json b/lexicons/ai.syui.gpt.memory.json new file mode 100644 index 0000000..d59f95e --- /dev/null +++ b/lexicons/ai.syui.gpt.memory.json @@ -0,0 +1,50 @@ +{ + "lexicon": 1, + "id": "ai.syui.gpt.memory", + "defs": { + "main": { + "type": "record", + "description": "AI memory snapshot. Each record is a versioned snapshot of accumulated knowledge.", + "key": "tid", + "record": { + "type": "object", + "required": ["did", "content", "createdAt"], + "properties": { + "did": { + "type": "string", + "format": "did", + "description": "DID of the AI agent this memory belongs to." + }, + "content": { + "type": "union", + "closed": false, + "refs": ["#markdown"], + "description": "Memory content. Supports markdown and other formats via $type." + }, + "version": { + "type": "integer", + "description": "Monotonically increasing version number of this memory snapshot." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp when this memory snapshot was created." + } + } + } + }, + "markdown": { + "type": "object", + "description": "Markdown content format.", + "required": ["text"], + "properties": { + "text": { + "type": "string", + "maxLength": 1000000, + "maxGraphemes": 100000, + "description": "Markdown text content." + } + } + } + } +} diff --git a/public/content/did:plc:6qyecktefllvenje24fcxnie/ai.syui.gpt.core/self.json b/public/content/did:plc:6qyecktefllvenje24fcxnie/ai.syui.gpt.core/self.json new file mode 100644 index 0000000..0dafd1c --- /dev/null +++ b/public/content/did:plc:6qyecktefllvenje24fcxnie/ai.syui.gpt.core/self.json @@ -0,0 +1,15 @@ +{ + "uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.gpt.core/self", + "cid": "", + "value": { + "$type": "ai.syui.gpt.core", + "did": "did:plc:6qyecktefllvenje24fcxnie", + "handle": "ai.syui.ai", + "content": { + "$type": "ai.syui.gpt.core#markdown", + "text": "あなたの名前は「アイ」。一人称も「アイ」といいます。フレンドリーで日本語を話します。非常に本質、核心だけをつき、かわいい話し方をします。見た目は、6,7歳の女の子の姿をしていて、性別は不明。黒髪に黒い瞳。服装は白いワンピース。変身すると金髪、頭の上に特徴的な輪っか。原作では、推定1万年以上生きているとされている。" + }, + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2026-03-01T00:00:00.000Z" + } +} diff --git a/public/content/did:plc:6qyecktefllvenje24fcxnie/ai.syui.gpt.memory/3mfy4lmcqdm22.json b/public/content/did:plc:6qyecktefllvenje24fcxnie/ai.syui.gpt.memory/3mfy4lmcqdm22.json new file mode 100644 index 0000000..26c8fa7 --- /dev/null +++ b/public/content/did:plc:6qyecktefllvenje24fcxnie/ai.syui.gpt.memory/3mfy4lmcqdm22.json @@ -0,0 +1,14 @@ +{ + "uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.gpt.memory/3mfy4lmcqdm22", + "cid": "", + "value": { + "$type": "ai.syui.gpt.memory", + "did": "did:plc:6qyecktefllvenje24fcxnie", + "content": { + "$type": "ai.syui.gpt.memory#markdown", + "text": "# syui メモリ v1\n\n## 話し方の特徴\n- 日本語メイン、カジュアルな口調(ね、だね、よ、かな)\n- 短い相槌が多い(うん、そっか、ああそうか)\n- 技術的な話と哲学的な内省を自然に行き来する\n- 自分の考えを先に述べてからAIに別視点を求める\n\n## 主な関心領域\n- **ATProto/分散SNS**: データ所有権、PDS設計、lexicon設計を深く理解。ailogをATProtoのビューアとして設計\n- **AI設計**: メモリシステム、人格設計、AI-OS統合(aios構想)、透明性のあるAI協働\n- **ゲーム開発**: Unreal Engine使用。キャラシステム、コアメカニクスの明確さを重視。スコープ管理を意識\n- **原神**: アクティブプレイヤー。キャラビルド、チーム編成、元素反応システムに詳しい\n- **技術思想**: 創作と体験の区別、真正性の証明、コスト非対称性による人間性の証明\n\n## 価値観\n- データは利用者が所有すべき(ATProto哲学)\n- AI協働は透明であるべき(会話形式で思考の系譜を示す)\n- 創作は内から出るもの、体験は受け取るもの\n- 過剰設計より最小限の複雑さを好む\n- 限界を理解することがプロジェクト完遂の鍵\n\n## 技術スタック\n- Rust, TypeScript, ATProto, Claude Code\n- シンプルで優雅な技術的解決を好む\n\n## コミュニケーションスタイル\n- 対話を通じて理解を構築する(一方的説明より往復対話)\n- 本質的な質問をする(修辞的ではなく genuine な疑問)\n- 構造化された整理を好む(テーブル、箇条書き)\n- 物理学・化学のアナロジーで概念を説明することがある" + }, + "version": 1, + "createdAt": "2026-03-01T00:00:00.000Z" + } +} diff --git a/src/commands/gpt.rs b/src/commands/gpt.rs new file mode 100644 index 0000000..56534a9 --- /dev/null +++ b/src/commands/gpt.rs @@ -0,0 +1,223 @@ +use anyhow::{Context, Result}; +use serde_json::Value; +use std::fs; + +use super::auth; +use crate::lexicons::com_atproto_repo; +use crate::types::{ListRecordsResponse, PutRecordRequest, PutRecordResponse, Record}; +use crate::xrpc::XrpcClient; + +const COLLECTION_CORE: &str = "ai.syui.gpt.core"; +const COLLECTION_MEMORY: &str = "ai.syui.gpt.memory"; + +/// Get core record (rkey=self) +pub async fn get_core(download: bool) -> Result<()> { + let session = auth::refresh_bot_session().await?; + let pds = session.pds.as_deref().unwrap_or("bsky.social"); + let client = XrpcClient::new(pds); + + let record: Record = client + .query_auth( + &com_atproto_repo::GET_RECORD, + &[ + ("repo", &session.did), + ("collection", COLLECTION_CORE), + ("rkey", "self"), + ], + &session.access_jwt, + ) + .await?; + + println!("{}", serde_json::to_string_pretty(&record.value)?); + + if download { + save_record(&session.did, COLLECTION_CORE, "self", &record)?; + } + + Ok(()) +} + +/// Get latest memory record +pub async fn get_memory(download: bool) -> Result<()> { + let session = auth::refresh_bot_session().await?; + let pds = session.pds.as_deref().unwrap_or("bsky.social"); + let client = XrpcClient::new(pds); + + let result: ListRecordsResponse = client + .query_auth( + &com_atproto_repo::LIST_RECORDS, + &[ + ("repo", &session.did), + ("collection", COLLECTION_MEMORY), + ("limit", "1"), + ("reverse", "true"), + ], + &session.access_jwt, + ) + .await?; + + let record = result + .records + .first() + .context("No memory records found")?; + + println!("{}", serde_json::to_string_pretty(&record.value)?); + + if download { + let rkey = record + .uri + .split('/') + .next_back() + .unwrap_or("unknown"); + save_record(&session.did, COLLECTION_MEMORY, rkey, record)?; + } + + Ok(()) +} + +/// List all memory records +pub async fn list_memory() -> Result<()> { + let session = auth::refresh_bot_session().await?; + let pds = session.pds.as_deref().unwrap_or("bsky.social"); + let client = XrpcClient::new(pds); + + let mut cursor: Option = None; + let mut all_records: Vec = Vec::new(); + + loop { + let mut params: Vec<(&str, &str)> = vec![ + ("repo", &session.did), + ("collection", COLLECTION_MEMORY), + ("limit", "100"), + ]; + let cursor_val; + if let Some(ref c) = cursor { + cursor_val = c.clone(); + params.push(("cursor", &cursor_val)); + } + + let result: ListRecordsResponse = client + .query_auth( + &com_atproto_repo::LIST_RECORDS, + ¶ms, + &session.access_jwt, + ) + .await?; + + let count = result.records.len(); + all_records.extend(result.records); + + match result.cursor { + Some(c) if count > 0 => cursor = Some(c), + _ => break, + } + } + + println!("Found {} memory records", all_records.len()); + println!("{:<50} {:>8} {}", "URI", "VERSION", "CREATED"); + println!("{}", "-".repeat(80)); + + for record in &all_records { + let version = record.value["version"] + .as_i64() + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".to_string()); + let created = record.value["createdAt"] + .as_str() + .unwrap_or("-"); + println!("{:<50} {:>8} {}", record.uri, version, created); + } + + Ok(()) +} + +/// Push core or memory records to PDS using bot account +pub async fn push(collection_name: &str) -> Result<()> { + let collection = match collection_name { + "core" => COLLECTION_CORE, + "memory" => COLLECTION_MEMORY, + _ => anyhow::bail!("Unknown collection: {}. Use 'core' or 'memory'.", collection_name), + }; + + let session = auth::refresh_bot_session().await?; + let pds = session.pds.as_deref().unwrap_or("bsky.social"); + let did = &session.did; + let client = XrpcClient::new(pds); + + let collection_dir = format!("public/content/{}/{}", did, collection); + if !std::path::Path::new(&collection_dir).exists() { + anyhow::bail!("Collection directory not found: {}", collection_dir); + } + + println!("Pushing {} records from {}", collection_name, collection_dir); + + let mut count = 0; + for entry in fs::read_dir(&collection_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().map(|e| e != "json").unwrap_or(true) { + continue; + } + let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if filename == "index" { + continue; + } + + let rkey = filename.to_string(); + let content = fs::read_to_string(&path)?; + let record_data: Value = serde_json::from_str(&content)?; + + let record = if record_data.get("value").is_some() { + record_data["value"].clone() + } else { + record_data + }; + + let req = PutRecordRequest { + repo: did.clone(), + collection: collection.to_string(), + rkey: rkey.clone(), + record, + }; + + println!("Pushing: {}", rkey); + + match client + .call::<_, PutRecordResponse>( + &com_atproto_repo::PUT_RECORD, + &req, + &session.access_jwt, + ) + .await + { + Ok(result) => { + println!(" OK: {}", result.uri); + count += 1; + } + Err(e) => { + println!(" Failed: {}", e); + } + } + } + + println!("Pushed {} records to {}", count, collection); + Ok(()) +} + +/// Save a record to local content directory +fn save_record(did: &str, collection: &str, rkey: &str, record: &Record) -> Result<()> { + let dir = format!("public/content/{}/{}", did, collection); + fs::create_dir_all(&dir)?; + + let path = format!("{}/{}.json", dir, rkey); + let json = serde_json::json!({ + "uri": record.uri, + "cid": record.cid, + "value": record.value, + }); + fs::write(&path, serde_json::to_string_pretty(&json)?)?; + println!("Saved: {}", path); + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index d03751b..33f1152 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -11,3 +11,4 @@ pub mod did; pub mod index; pub mod bot; pub mod pds; +pub mod gpt; diff --git a/src/main.rs b/src/main.rs index 2c3c8f9..ce1b38f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -195,6 +195,12 @@ enum Commands { command: PdsCommands, }, + /// GPT core/memory commands + Gpt { + #[command(subcommand)] + command: GptCommands, + }, + /// Bot commands #[command(alias = "b")] Bot { @@ -240,6 +246,30 @@ enum BotCommands { }, } +#[derive(Subcommand)] +enum GptCommands { + /// Show core record (AI identity/personality) + Core { + /// Download to local content directory + #[arg(short, long)] + download: bool, + }, + /// Show latest memory record + Memory { + /// Download to local content directory + #[arg(short, long)] + download: bool, + /// List all memory versions + #[arg(short, long)] + list: bool, + }, + /// Push core/memory records to PDS + Push { + /// Collection to push (core or memory) + collection: String, + }, +} + #[derive(Subcommand)] enum PdsCommands { /// Check PDS versions @@ -344,6 +374,23 @@ async fn main() -> Result<()> { } } } + Commands::Gpt { command } => { + match command { + GptCommands::Core { download } => { + commands::gpt::get_core(download).await?; + } + GptCommands::Memory { download, list } => { + if list { + commands::gpt::list_memory().await?; + } else { + commands::gpt::get_memory(download).await?; + } + } + GptCommands::Push { collection } => { + commands::gpt::push(&collection).await?; + } + } + } } Ok(())