diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d6f4a9..bea887f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,68 @@ `cccc-sdk` tracks the `cccc` daemon version. Each release targets a specific CCCC line and exposes the IPC surface available on that line. +## [0.4.33] — Aligned with Rust CCCC 0.4.33 + +### Added + +- Group preamble get/set/reset helpers. +- Cursor-based terminal history/since helpers and Rust `terminal_resize` + dispatch compatibility. +- Current Voice Secretary prompt refinement, document, transcript, and request + argument mappings. +- `insight` and `require_peer_insight` on send, reply, cross-group, and tracked + message helpers. + +### Fixed + +- `assertCompatible` / `assert_compatible` now probe `events_stream` instead of + trusting the advertised capability alone. +- TypeScript `sendAndWaitForReply` verifies the streaming upgrade before it + creates a message side effect. +- Removed `assistant_voice_transcribe` IPC usage now fails locally with a clear + HTTP Voice Secretary migration message. + +### Specs + +- Resynced daemon IPC, Context Ops, and CCCS snapshots from Rust CCCC 0.4.33. + +## [0.4.30] — Aligned with CCCC 0.4.30 + +Focused compatibility release for the CCCC 0.4.30 daemon line. + +### Specs + +- Resynced `spec/CCCC_DAEMON_IPC_V1.md`, `spec/CCCC_CONTEXT_OPS_V1.md`, and + `spec/CCCS_V1.md` from the CCCC 0.4.30 standards snapshots. + +### Added + +- **Message suggestions** — `send` / `reply` accept + `suggested_user_message` (Python) / `suggestedUserMessage` (TypeScript). +- **Actor/session control** — `actor_new_session`. +- **Group Copy file export** — `group_copy_export_file`. +- **Terminal history and resize** — `terminal_history`, `term_resize`. +- **Blueprint generation** — `blueprint_generate`. +- **Voice Secretary JSON helpers** — document/transcript/request helpers plus + `assistant_voice_model_install`. +- **Memory ReMe admin helpers** — layout/search/get/write/index/context + check/compact/daily flush. +- **IM bridge management** — `im_bind_chat`, `im_list_authorized`, + `im_list_pending`, `im_reject_pending`, `im_revoke_chat`. +- **Remote Access** — `remote_access_state`, `remote_access_configure`, + `remote_access_start`, `remote_access_stop`. + +### Deferred + +- Streaming socket-special ops (`term_attach`, browser/VNC attach variants) + remain intentionally deferred until the SDK has a shared duplex attach + transport. `term_resize` is regular request/response and is wrapped here. + +### Tests + +- Added Python and TypeScript contract parity coverage for the 0.4.30 JSON op + alignment. + ## [0.4.18] — Aligned with CCCC 0.4.18 Focused compatibility release for the CCCC 0.4.18 daemon line. diff --git a/README.ja.md b/README.ja.md index 5a74702..1ea9eec 100644 --- a/README.ja.md +++ b/README.ja.md @@ -3,7 +3,7 @@ [English](README.md) | [中文](README.zh-CN.md) | **日本語** > ステータス:**現在の CCCC Daemon IPC v1 向けの contract-first SDK**。パッケージ -> バージョンは `cccc` 本体(現在 0.4.18)に追従し、daemon contract の進化に合わせて新しい workflow API を追加します。 +> バージョンは `cccc` 本体(現在 0.4.33)に追従し、daemon contract の進化に合わせて新しい workflow API を追加します。 CCCC SDK は CCCC プラットフォーム向けの **クライアント SDK** です。 @@ -63,7 +63,7 @@ from cccc_sdk import CCCCClient c = CCCCClient() c.assert_compatible( require_ipc_v=1, - require_capabilities={"events_stream": True}, + require_ops=["groups"], require_ops=["groups", "send", "reply", "inbox_list", "context_get", "context_sync"], ) print("OK: daemon is compatible") diff --git a/README.md b/README.md index 5314f33..90e2156 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **English** | [中文](README.zh-CN.md) | [日本語](README.ja.md) > Status: **contract-first SDK for current CCCC daemon IPC v1**. Package -> versions track `cccc` core (currently 0.4.18). See `CHANGELOG.md` for the +> versions track `cccc` core (currently 0.4.33). See `CHANGELOG.md` for the > per-version op surface, and `spec/ADAPTATION_PLAN.md` for remaining work. CCCC SDK provides **client SDKs** for building applications on top of the CCCC platform. @@ -64,7 +64,7 @@ from cccc_sdk import CCCCClient c = CCCCClient() c.assert_compatible( require_ipc_v=1, - require_capabilities={"events_stream": True}, + require_ops=["groups"], require_ops=["groups", "send", "reply", "inbox_list", "context_get", "context_sync"], ) print("OK: daemon is compatible") diff --git a/README.zh-CN.md b/README.zh-CN.md index b45e4f2..c992bf0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -3,7 +3,7 @@ [English](README.md) | **中文** | [日本語](README.ja.md) > 状态:**面向当前 CCCC Daemon IPC v1 的契约优先 SDK**。包版本跟随 `cccc` -> 本体(当前 0.4.18),同时随 daemon 合约演进补充新版工作流 API。 +> 本体(当前 0.4.33),同时随 daemon 合约演进补充新版工作流 API。 CCCC SDK 是一套用于 CCCC 平台的**客户端 SDK**。 @@ -63,7 +63,7 @@ from cccc_sdk import CCCCClient c = CCCCClient() c.assert_compatible( require_ipc_v=1, - require_capabilities={"events_stream": True}, + require_ops=["groups"], require_ops=["groups", "send", "reply", "inbox_list", "context_get", "context_sync"], ) print("OK: daemon is compatible") diff --git a/RELEASING.md b/RELEASING.md index ceab8c0..ebf305d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -6,8 +6,8 @@ This repo is a monorepo with two deliverables: ## Versioning policy -- SDK major/minor tracks CCCC: `0.4.18`. -- RC sequence is SDK-owned (`0.4.18rcN` for Python, `0.4.18-rc.N` for npm). +- SDK major/minor tracks CCCC: `0.4.33`. +- RC sequence is SDK-owned (`0.4.33rcN` for Python, `0.4.33-rc.N` for npm). - Compatibility is enforced by contracts/capabilities/op-probing, not by matching RC numbers. ## 0) Sync specs (recommended) @@ -38,8 +38,8 @@ Edit `python/pyproject.toml` (`project.version`). ### Publish RC to TestPyPI ```bash -git tag v0.4.18rcN -git push origin v0.4.18rcN +git tag v0.4.33rcN +git push origin v0.4.33rcN ``` This triggers `.github/workflows/python-publish-testpypi.yml`. @@ -49,14 +49,14 @@ Install check: ```bash python -m pip install --index-url https://pypi.org/simple \ --extra-index-url https://test.pypi.org/simple \ - cccc-sdk==0.4.18rcN + cccc-sdk==0.4.33rcN ``` ### Publish stable to PyPI ```bash -git tag v0.4.18 -git push origin v0.4.18 +git tag v0.4.33 +git push origin v0.4.33 ``` This triggers `.github/workflows/python-publish.yml`. @@ -68,8 +68,8 @@ This triggers `.github/workflows/python-publish.yml`. Edit `ts/package.json` (`version`). Examples: -- RC: `0.4.18-rc.N` -- Stable: `0.4.18` +- RC: `0.4.33-rc.N` +- Stable: `0.4.33` ### Local checks diff --git a/python/README.md b/python/README.md index a2b0461..f4f39d2 100644 --- a/python/README.md +++ b/python/README.md @@ -52,7 +52,7 @@ python - <<'PY' from cccc_sdk import CCCCClient c = CCCCClient() -c.assert_compatible(require_ipc_v=1, require_capabilities={"events_stream": True}) +c.assert_compatible(require_ipc_v=1, require_ops=["groups"]) groups = c.groups() print(groups) @@ -171,7 +171,7 @@ c.context_sync( If you need an op that does not have a dedicated helper yet, use `call()` / `call_raw()`. -## CCCC 0.4.18 surface — Hermes runtime and Voice Secretary lease +## CCCC 0.4.33 JSON alignment ```python # Hermes runtime setup diagnostics and MCP preparation @@ -186,6 +186,24 @@ lease = c.assistant_voice_recording_lease( owner_id="browser-tab-1", ttl_seconds=30, ) + +# Current request/response JSON helpers +c.send( + group_id="g_xxx", + text="Next step", + suggested_user_message="Run the checks", + insight="The compatibility gate is the release-critical part.", +) +c.actor_new_session(group_id="g_xxx", actor_id="codex-1", clear_saved_session=True) +c.group_copy_export_file(group_id="g_xxx", include_blobs=True) +c.group_preamble_set(group_id="g_xxx", content="Project-specific startup guidance") +c.terminal_history(group_id="g_xxx", actor_id="codex-1", limit_bytes=64_000) +c.terminal_since(group_id="g_xxx", actor_id="codex-1", after=0) +c.term_resize(group_id="g_xxx", actor_id="codex-1", cols=120, rows=40) +c.blueprint_generate(group_id="g_xxx", task_id="task-1") +c.memory_reme_search(group_id="g_xxx", query="release notes") +c.im_list_authorized(platform="dingtalk") +c.remote_access_state(group_id="g_xxx") ``` ## CCCC 0.4.17 surface — new op families @@ -250,4 +268,19 @@ c.ledger_snapshot(group_id="g_xxx", reason="manual") c.branding_update(patch={"product_name": "My CCCC"}) ``` -Not yet wrapped (use `call()` for now): remaining Voice Secretary document/transcribe/prompt ops, remaining Memory ReMe write/index/compaction ops, ChatGPT Web Model runtime, IM bridge management, Remote Access, and the streaming socket-special browser/PTY attach ops. See `spec/ADAPTATION_PLAN.md` for the roadmap. +The current request/response JSON ops are wrapped in the Python and TypeScript +clients. Still deferred: streaming socket-special attach ops such as PTY, +browser, VNC, and web-model attach flows; those need a shared duplex transport +instead of a plain `call()` wrapper. See `spec/ADAPTATION_PLAN.md` for the +roadmap. + +Before consuming the optional event stream, probe the real operation rather +than only checking the daemon capability map: + +```python +c.assert_compatible(require_ops=["events_stream"]) +``` + +Voice transcription is no longer a daemon JSON operation in Rust CCCC. Use the +HTTP Voice Secretary transcription endpoint; the deprecated +`assistant_voice_transcribe()` helper now fails locally with a migration message. diff --git a/python/examples/compat_check.py b/python/examples/compat_check.py index 4a92036..b26847e 100644 --- a/python/examples/compat_check.py +++ b/python/examples/compat_check.py @@ -10,6 +10,7 @@ def main() -> int: info = c.assert_compatible( require_ipc_v=1, require_capabilities={"events_stream": True}, + require_ops=["events_stream"], require_ops=[ "groups", "group_show", @@ -31,4 +32,3 @@ def main() -> int: if __name__ == "__main__": raise SystemExit(main()) - diff --git a/python/pyproject.toml b/python/pyproject.toml index 0ff9b98..99340c3 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cccc-sdk" -version = "0.4.18" +version = "0.4.33" description = "Client SDK for the CCCC daemon (IPC v1)" readme = "README.md" requires-python = ">=3.9" diff --git a/python/src/cccc_sdk/client.py b/python/src/cccc_sdk/client.py index dc9e6d4..8b0c12f 100644 --- a/python/src/cccc_sdk/client.py +++ b/python/src/cccc_sdk/client.py @@ -3,6 +3,10 @@ from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Set +from .client_0430_ops import CCCC0430OpsMixin +from .client_chat_ops import ChatOpsMixin +from .client_group_space_ops import GroupSpaceOpsMixin +from .client_group_space_provider_ops import GroupSpaceProviderOpsMixin from .errors import DaemonAPIError, IncompatibleDaemonError from .transport import DaemonEndpoint, call_daemon, discover_endpoint, open_events_stream @@ -11,7 +15,7 @@ def _compact(args: Dict[str, Any]) -> Dict[str, Any]: return {k: v for k, v in args.items() if v is not None} -class CCCCClient: +class CCCCClient(CCCC0430OpsMixin, ChatOpsMixin, GroupSpaceOpsMixin, GroupSpaceProviderOpsMixin): """A minimal client for the CCCC daemon IPC v1.""" def __init__( @@ -82,9 +86,7 @@ def assert_compatible( _UNPROBABLE_OPS = { "ping", "shutdown", - "events_stream", "term_attach", - "term_resize", "presentation_browser_attach", "presentation_browser_vnc_attach", "web_model_browser_attach", @@ -775,387 +777,6 @@ def memory_profile_get( ), ) - def group_space_status(self, *, group_id: str, provider: str = "notebooklm") -> Dict[str, Any]: - return self.call("group_space_status", {"group_id": str(group_id), "provider": str(provider)}) - - def group_space_spaces(self, *, group_id: str, provider: str = "notebooklm") -> Dict[str, Any]: - return self.call("group_space_spaces", {"group_id": str(group_id), "provider": str(provider)}) - - def group_space_capabilities(self, *, group_id: str, provider: str = "notebooklm") -> Dict[str, Any]: - return self.call("group_space_capabilities", {"group_id": str(group_id), "provider": str(provider)}) - - def group_space_bind( - self, - *, - group_id: str, - lane: str, - action: str = "bind", - remote_space_id: str = "", - provider: str = "notebooklm", - by: str = "user", - ) -> Dict[str, Any]: - args: Dict[str, Any] = { - "group_id": str(group_id), - "provider": str(provider), - "lane": str(lane), - "action": str(action), - "by": str(by), - } - if remote_space_id: - args["remote_space_id"] = str(remote_space_id) - return self.call("group_space_bind", args) - - def group_space_ingest( - self, - *, - group_id: str, - lane: str, - payload: Optional[Dict[str, Any]] = None, - kind: str = "context_sync", - idempotency_key: str = "", - provider: str = "notebooklm", - by: str = "user", - ) -> Dict[str, Any]: - args: Dict[str, Any] = { - "group_id": str(group_id), - "provider": str(provider), - "lane": str(lane), - "kind": str(kind), - "by": str(by), - } - if payload is not None: - args["payload"] = dict(payload) - if idempotency_key: - args["idempotency_key"] = str(idempotency_key) - return self.call("group_space_ingest", args) - - def group_space_query( - self, - *, - group_id: str, - lane: str, - query: str, - options: Optional[Dict[str, Any]] = None, - provider: str = "notebooklm", - ) -> Dict[str, Any]: - args: Dict[str, Any] = { - "group_id": str(group_id), - "provider": str(provider), - "lane": str(lane), - "query": str(query), - } - if options is not None: - args["options"] = dict(options) - return self.call("group_space_query", args) - - def group_space_sources( - self, - *, - group_id: str, - lane: str, - action: str = "list", - source_id: str = "", - new_title: str = "", - provider: str = "notebooklm", - by: str = "user", - ) -> Dict[str, Any]: - args: Dict[str, Any] = { - "group_id": str(group_id), - "provider": str(provider), - "lane": str(lane), - "action": str(action), - "by": str(by), - } - if source_id: - args["source_id"] = str(source_id) - if new_title: - args["new_title"] = str(new_title) - return self.call("group_space_sources", args) - - def group_space_artifact( - self, - *, - group_id: str, - lane: str, - action: str = "list", - kind: str = "", - options: Optional[Dict[str, Any]] = None, - wait: Optional[bool] = None, - save_to_space: Optional[bool] = None, - output_path: str = "", - output_format: str = "", - artifact_id: str = "", - timeout_seconds: Optional[int] = None, - initial_interval: Optional[int] = None, - max_interval: Optional[int] = None, - provider: str = "notebooklm", - by: str = "user", - ) -> Dict[str, Any]: - args: Dict[str, Any] = { - "group_id": str(group_id), - "provider": str(provider), - "lane": str(lane), - "action": str(action), - "by": str(by), - } - if kind: - args["kind"] = str(kind) - if options is not None: - args["options"] = dict(options) - if wait is not None: - args["wait"] = bool(wait) - if save_to_space is not None: - args["save_to_space"] = bool(save_to_space) - if output_path: - args["output_path"] = str(output_path) - if output_format: - args["output_format"] = str(output_format) - if artifact_id: - args["artifact_id"] = str(artifact_id) - if timeout_seconds is not None: - args["timeout_seconds"] = int(timeout_seconds) - if initial_interval is not None: - args["initial_interval"] = int(initial_interval) - if max_interval is not None: - args["max_interval"] = int(max_interval) - return self.call("group_space_artifact", args) - - def group_space_jobs( - self, - *, - group_id: str, - lane: str, - action: str = "list", - job_id: str = "", - state: str = "", - limit: Optional[int] = None, - provider: str = "notebooklm", - by: str = "user", - ) -> Dict[str, Any]: - args: Dict[str, Any] = { - "group_id": str(group_id), - "provider": str(provider), - "lane": str(lane), - "action": str(action), - "by": str(by), - } - if job_id: - args["job_id"] = str(job_id) - if state: - args["state"] = str(state) - if limit is not None: - args["limit"] = int(limit) - return self.call("group_space_jobs", args) - - def group_space_sync( - self, - *, - group_id: str, - lane: str, - action: str = "status", - force: bool = False, - provider: str = "notebooklm", - by: str = "user", - ) -> Dict[str, Any]: - return self.call( - "group_space_sync", - { - "group_id": str(group_id), - "provider": str(provider), - "lane": str(lane), - "action": str(action), - "force": bool(force), - "by": str(by), - }, - ) - - def group_space_provider_credential_status(self, *, provider: str = "notebooklm", by: str = "user") -> Dict[str, Any]: - return self.call("group_space_provider_credential_status", {"provider": str(provider), "by": str(by)}) - - def group_space_provider_credential_update( - self, - *, - provider: str = "notebooklm", - by: str = "user", - auth_json: str = "", - clear: bool = False, - ) -> Dict[str, Any]: - args: Dict[str, Any] = {"provider": str(provider), "by": str(by), "clear": bool(clear)} - if auth_json: - args["auth_json"] = str(auth_json) - return self.call("group_space_provider_credential_update", args) - - def group_space_provider_health_check(self, *, provider: str = "notebooklm", by: str = "user") -> Dict[str, Any]: - return self.call("group_space_provider_health_check", {"provider": str(provider), "by": str(by)}) - - def group_space_provider_auth( - self, - *, - provider: str = "notebooklm", - action: str = "status", - timeout_seconds: Optional[int] = None, - by: str = "user", - ) -> Dict[str, Any]: - args: Dict[str, Any] = {"provider": str(provider), "action": str(action), "by": str(by)} - if timeout_seconds is not None: - args["timeout_seconds"] = int(timeout_seconds) - return self.call("group_space_provider_auth", args) - - def send_cross_group( - self, - *, - group_id: str, - dst_group_id: str, - text: str, - by: str = "user", - to: Optional[List[str]] = None, - priority: str = "normal", - reply_required: bool = False, - refs: Optional[List[Dict[str, Any]]] = None, - attachments: Optional[List[Dict[str, Any]]] = None, - ) -> Dict[str, Any]: - args: Dict[str, Any] = { - "group_id": str(group_id), - "dst_group_id": str(dst_group_id), - "text": str(text), - "by": str(by), - "priority": str(priority), - "reply_required": bool(reply_required), - } - if to is not None: - args["to"] = [str(x) for x in to] - if refs is not None: - args["refs"] = [dict(r) for r in refs] - if attachments is not None: - args["attachments"] = [dict(a) for a in attachments] - return self.call("send_cross_group", args) - - def send( - self, - *, - group_id: str, - text: str, - by: str = "user", - to: Optional[List[str]] = None, - priority: str = "normal", - reply_required: bool = False, - path: str = "", - refs: Optional[List[Dict[str, Any]]] = None, - attachments: Optional[List[Dict[str, Any]]] = None, - client_id: str = "", - ) -> Dict[str, Any]: - args: Dict[str, Any] = { - "group_id": str(group_id), - "text": str(text), - "by": str(by), - "priority": str(priority), - "reply_required": bool(reply_required), - } - if to is not None: - args["to"] = [str(x) for x in to] - if path: - args["path"] = str(path) - if refs is not None: - args["refs"] = [dict(r) for r in refs] - if attachments is not None: - args["attachments"] = [dict(a) for a in attachments] - if client_id: - args["client_id"] = str(client_id) - return self.call("send", args) - - def reply( - self, - *, - group_id: str, - reply_to: str, - text: str, - by: str = "user", - to: Optional[List[str]] = None, - priority: str = "normal", - reply_required: bool = False, - refs: Optional[List[Dict[str, Any]]] = None, - attachments: Optional[List[Dict[str, Any]]] = None, - client_id: str = "", - ) -> Dict[str, Any]: - args: Dict[str, Any] = { - "group_id": str(group_id), - "reply_to": str(reply_to), - "text": str(text), - "by": str(by), - "priority": str(priority), - "reply_required": bool(reply_required), - } - if to is not None: - args["to"] = [str(x) for x in to] - if refs is not None: - args["refs"] = [dict(r) for r in refs] - if attachments is not None: - args["attachments"] = [dict(a) for a in attachments] - if client_id: - args["client_id"] = str(client_id) - return self.call("reply", args) - - def chat_ack(self, *, group_id: str, actor_id: str, event_id: str, by: Optional[str] = None) -> Dict[str, Any]: - """ACK an attention message (self-only in CCCC: by must equal actor_id).""" - aid = str(actor_id) - return self.call( - "chat_ack", - { - "group_id": str(group_id), - "actor_id": aid, - "event_id": str(event_id), - "by": str(by) if by is not None else aid, - }, - ) - - def inbox_list( - self, - *, - group_id: str, - actor_id: str, - by: str = "user", - limit: int = 50, - kind_filter: str = "all", - ) -> Dict[str, Any]: - return self.call( - "inbox_list", - { - "group_id": str(group_id), - "actor_id": str(actor_id), - "by": str(by), - "limit": int(limit), - "kind_filter": str(kind_filter), - }, - ) - - def inbox_mark_read(self, *, group_id: str, actor_id: str, event_id: str, by: str = "user") -> Dict[str, Any]: - return self.call( - "inbox_mark_read", - {"group_id": str(group_id), "actor_id": str(actor_id), "event_id": str(event_id), "by": str(by)}, - ) - - def inbox_mark_all_read( - self, *, group_id: str, actor_id: str, by: str = "user", kind_filter: str = "all" - ) -> Dict[str, Any]: - return self.call( - "inbox_mark_all_read", - {"group_id": str(group_id), "actor_id": str(actor_id), "by": str(by), "kind_filter": str(kind_filter)}, - ) - - def notify_ack( - self, *, group_id: str, actor_id: str, notify_event_id: str, by: Optional[str] = None - ) -> Dict[str, Any]: - aid = str(actor_id) - return self.call( - "notify_ack", - { - "group_id": str(group_id), - "actor_id": aid, - "notify_event_id": str(notify_event_id), - "by": str(by) if by is not None else aid, - }, - ) - def context_get(self, *, group_id: str) -> Dict[str, Any]: return self.call("context_get", {"group_id": str(group_id)}) @@ -1382,6 +1003,8 @@ def tracked_send( handoff_to: str = "", assignee: str = "", refs: Optional[List[Dict[str, Any]]] = None, + insight: str = "", + require_peer_insight: Optional[bool] = None, ) -> Dict[str, Any]: """Atomically create a tracked task and send the linked chat message.""" args: Dict[str, Any] = { @@ -1424,6 +1047,10 @@ def tracked_send( args["assignee"] = str(assignee) if refs is not None: args["refs"] = [dict(r) for r in refs] + if insight: + args["insight"] = str(insight) + if require_peer_insight is not None: + args["require_peer_insight"] = bool(require_peer_insight) return self.call("tracked_send", args) def task_list(self, *, group_id: str, task_id: str = "") -> Dict[str, Any]: diff --git a/python/src/cccc_sdk/client_0430_admin_ops.py b/python/src/cccc_sdk/client_0430_admin_ops.py new file mode 100644 index 0000000..0bf438b --- /dev/null +++ b/python/src/cccc_sdk/client_0430_admin_ops.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +from .client_0430_shared import _compact + + +class CCCC0430AdminOpsMixin: + def actor_new_session( + self, + *, + group_id: str, + actor_id: str, + clear_saved_session: bool = False, + by: str = "user", + ) -> Dict[str, Any]: + return self.call( + "actor_new_session", + { + "group_id": str(group_id), + "actor_id": str(actor_id), + "by": str(by), + "clear_saved_session": bool(clear_saved_session), + }, + ) + + def group_copy_export_file(self, *, group_id: str, include_blobs: Optional[bool] = None) -> Dict[str, Any]: + return self.call("group_copy_export_file", _compact({"group_id": str(group_id), "include_blobs": include_blobs})) + + def group_preamble_get(self, *, group_id: str) -> Dict[str, Any]: + return self.call("group_preamble_get", {"group_id": str(group_id)}) + + def group_preamble_set(self, *, group_id: str, content: str, by: str = "user") -> Dict[str, Any]: + return self.call( + "group_preamble_set", + {"group_id": str(group_id), "content": str(content), "by": str(by)}, + ) + + def group_preamble_reset(self, *, group_id: str, by: str = "user") -> Dict[str, Any]: + return self.call( + "group_preamble_reset", + {"group_id": str(group_id), "confirm": "preamble", "by": str(by)}, + ) + + def terminal_history( + self, + *, + group_id: str, + actor_id: str, + before: Optional[int] = None, + limit_bytes: Optional[int] = None, + strip_ansi: Optional[bool] = None, + compact: Optional[bool] = None, + limit: Optional[int] = None, + cursor: str = "", + by: str = "user", + ) -> Dict[str, Any]: + cursor_before = int(cursor) if cursor.isdigit() else None + return self.call( + "terminal_history", + _compact( + { + "group_id": str(group_id), + "actor_id": str(actor_id), + "before": int(before) if before is not None else cursor_before, + "limit_bytes": int(limit_bytes) if limit_bytes is not None else (int(limit) if limit is not None else None), + "strip_ansi": strip_ansi, + "compact": compact, + "by": str(by), + } + ), + ) + + def terminal_since( + self, + *, + group_id: str, + actor_id: str, + after: int, + limit_bytes: Optional[int] = None, + by: str = "user", + ) -> Dict[str, Any]: + return self.call( + "terminal_since", + _compact( + { + "group_id": str(group_id), + "actor_id": str(actor_id), + "after": int(after), + "limit_bytes": int(limit_bytes) if limit_bytes is not None else None, + "by": str(by), + } + ), + ) + + def term_resize(self, *, group_id: str, actor_id: str, cols: int, rows: int) -> Dict[str, Any]: + return self.call( + "terminal_resize", + {"group_id": str(group_id), "actor_id": str(actor_id), "cols": int(cols), "rows": int(rows)}, + ) + + def im_bind_chat( + self, + *, + group_id: str, + platform: str, + chat_id: str, + thread_id: Optional[int] = None, + by: str = "user", + ) -> Dict[str, Any]: + return self.call( + "im_bind_chat", + _compact( + { + "group_id": str(group_id), + "platform": str(platform), + "chat_id": str(chat_id), + "thread_id": int(thread_id) if thread_id is not None else None, + "by": str(by), + } + ), + ) + + def im_list_authorized(self, *, platform: str = "") -> Dict[str, Any]: + return self.call("im_list_authorized", _compact({"platform": platform or None})) + + def im_list_pending(self, *, platform: str = "") -> Dict[str, Any]: + return self.call("im_list_pending", _compact({"platform": platform or None})) + + def im_reject_pending(self, *, key: str, platform: str = "", by: str = "user") -> Dict[str, Any]: + return self.call("im_reject_pending", _compact({"platform": platform or None, "key": str(key), "by": str(by)})) + + def im_revoke_chat( + self, + *, + chat_id: str, + platform: str = "", + thread_id: Optional[int] = None, + by: str = "user", + ) -> Dict[str, Any]: + return self.call( + "im_revoke_chat", + _compact( + { + "platform": platform or None, + "chat_id": str(chat_id), + "thread_id": int(thread_id) if thread_id is not None else None, + "by": str(by), + } + ), + ) + + def remote_access_state(self, *, group_id: str = "", by: str = "") -> Dict[str, Any]: + return self.call("remote_access_state", _compact({"group_id": group_id or None, "by": by or None})) + + def remote_access_configure( + self, *, config: Dict[str, Any], group_id: str = "", by: str = "user" + ) -> Dict[str, Any]: + return self.call( + "remote_access_configure", + _compact({"group_id": group_id or None, "config": dict(config), "by": str(by)}), + ) + + def remote_access_start(self, *, group_id: str = "", by: str = "user") -> Dict[str, Any]: + return self.call("remote_access_start", _compact({"group_id": group_id or None, "by": str(by)})) + + def remote_access_stop(self, *, group_id: str = "", by: str = "user") -> Dict[str, Any]: + return self.call("remote_access_stop", _compact({"group_id": group_id or None, "by": str(by)})) + + def blueprint_generate(self, *, group_id: str, task_id: str, variant: Optional[int] = None) -> Dict[str, Any]: + return self.call( + "blueprint_generate", + _compact( + { + "group_id": str(group_id), + "task_id": str(task_id), + "variant": int(variant) if variant is not None else None, + } + ), + ) diff --git a/python/src/cccc_sdk/client_0430_assistant_ops.py b/python/src/cccc_sdk/client_0430_assistant_ops.py new file mode 100644 index 0000000..386217a --- /dev/null +++ b/python/src/cccc_sdk/client_0430_assistant_ops.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +from .client_0430_shared import _compact +from .errors import IncompatibleDaemonError + + +class CCCC0430AssistantOpsMixin: + def assistant_voice_model_install( + self, + *, + group_id: str, + model_id: str = "", + force: Optional[bool] = None, + by: str = "user", + ) -> Dict[str, Any]: + return self.call( + "assistant_voice_model_install", + _compact({"group_id": str(group_id), "model_id": model_id or None, "force": force, "by": str(by)}), + ) + + def assistant_voice_transcribe( + self, + *, + group_id: str, + audio_base64: str = "", + path: str = "", + mime_type: str = "", + by: str = "user", + ) -> Dict[str, Any]: + del group_id, audio_base64, path, mime_type, by + raise IncompatibleDaemonError( + "assistant_voice_transcribe was removed from Rust daemon IPC; " + "use the HTTP Voice Secretary transcription endpoint" + ) + + def assistant_voice_transcript_append( + self, + *, + group_id: str, + session_id: str, + segment_id: str = "", + text: str = "", + language: str = "", + document_path: str = "", + is_final: Optional[bool] = None, + flush: Optional[bool] = None, + trigger: Optional[Dict[str, Any]] = None, + by: str = "user", + ) -> Dict[str, Any]: + return self.call( + "assistant_voice_transcript_append", + _compact( + { + "group_id": str(group_id), + "session_id": str(session_id), + "segment_id": segment_id or None, + "text": text or None, + "language": language or None, + "document_path": document_path or None, + "is_final": is_final, + "flush": flush, + "trigger": dict(trigger) if trigger is not None else None, + "by": str(by), + } + ), + ) + + def assistant_voice_document_list(self, *, group_id: str, include_archived: Optional[bool] = None) -> Dict[str, Any]: + return self.call( + "assistant_voice_document_list", + _compact({"group_id": str(group_id), "include_archived": include_archived}), + ) + + def assistant_voice_document_input_read( + self, *, group_id: str, by: str = "" + ) -> Dict[str, Any]: + return self.call( + "assistant_voice_document_input_read", + _compact({"group_id": str(group_id), "by": by or None}), + ) + + def assistant_voice_document_save( + self, + *, + group_id: str, + document_path: str = "", + workspace_path: str = "", + title: str = "", + content: str = "", + status: str = "", + create_new: Optional[bool] = None, + by: str = "user", + ) -> Dict[str, Any]: + return self.call( + "assistant_voice_document_save", + _compact( + { + "group_id": str(group_id), + "document_path": document_path or None, + "workspace_path": workspace_path or None, + "title": title or None, + "content": content or None, + "status": status or None, + "create_new": create_new, + "by": str(by), + } + ), + ) + + def assistant_voice_document_instruction( + self, + *, + group_id: str, + document_path: str, + instruction: str = "", + source_text: str = "", + trigger: Optional[Dict[str, Any]] = None, + by: str = "user", + ) -> Dict[str, Any]: + return self.call( + "assistant_voice_document_instruction", + _compact( + { + "group_id": str(group_id), + "document_path": str(document_path), + "instruction": instruction or None, + "source_text": source_text or None, + "trigger": dict(trigger) if trigger is not None else None, + "by": str(by), + } + ), + ) + + def assistant_voice_document_archive( + self, + *, + group_id: str, + document_path: str, + by: str = "user", + ) -> Dict[str, Any]: + return self.call( + "assistant_voice_document_archive", + _compact( + { + "group_id": str(group_id), + "document_path": str(document_path), + "by": str(by), + } + ), + ) + + def assistant_voice_input_append( + self, + *, + group_id: str, + request_id: str = "", + voice_transcript: str = "", + composer_text: str = "", + operation: str = "", + composer_context: Optional[Dict[str, Any]] = None, + composer_snapshot_hash: str = "", + by: str = "user", + ) -> Dict[str, Any]: + return self.call( + "assistant_voice_input_append", + _compact( + { + "group_id": str(group_id), + "kind": "prompt_refine", + "request_id": request_id or None, + "voice_transcript": voice_transcript or None, + "composer_text": composer_text or None, + "operation": operation or None, + "composer_context": dict(composer_context) if composer_context is not None else None, + "composer_snapshot_hash": composer_snapshot_hash or None, + "by": str(by), + } + ), + ) + + def assistant_voice_prompt_draft_submit( + self, + *, + group_id: str, + request_id: str, + draft_text: str = "", + no_op: Optional[bool] = None, + summary: str = "", + operation: str = "", + composer_snapshot_hash: str = "", + by: str = "voice-secretary", + ) -> Dict[str, Any]: + return self.call( + "assistant_voice_prompt_draft_submit", + _compact( + { + "group_id": str(group_id), + "request_id": str(request_id), + "draft_text": draft_text or None, + "no_op": no_op, + "summary": summary or None, + "operation": operation or None, + "composer_snapshot_hash": composer_snapshot_hash or None, + "by": str(by), + } + ), + ) + + def assistant_voice_prompt_draft_ack( + self, *, group_id: str, request_id: str, status: str + ) -> Dict[str, Any]: + return self.call( + "assistant_voice_prompt_draft_ack", + {"group_id": str(group_id), "request_id": str(request_id), "status": str(status)}, + ) + + def assistant_voice_request( + self, + *, + group_id: str, + request_text: str, + target: str = "", + summary: str = "", + document_path: str = "", + artifact_paths: Optional[list[str]] = None, + source_event_id: str = "", + priority: str = "", + requires_ack: Optional[bool] = None, + by: str = "voice-secretary", + ) -> Dict[str, Any]: + return self.call( + "assistant_voice_request", + _compact( + { + "group_id": str(group_id), + "request_text": str(request_text), + "target": target or None, + "summary": summary or None, + "document_path": document_path or None, + "artifact_paths": [str(path) for path in artifact_paths] if artifact_paths is not None else None, + "source_event_id": source_event_id or None, + "priority": priority or None, + "requires_ack": requires_ack, + "by": str(by), + } + ), + ) diff --git a/python/src/cccc_sdk/client_0430_memory_ops.py b/python/src/cccc_sdk/client_0430_memory_ops.py new file mode 100644 index 0000000..afc89d1 --- /dev/null +++ b/python/src/cccc_sdk/client_0430_memory_ops.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from .client_0430_shared import _compact + + +class CCCC0430MemoryOpsMixin: + def memory_reme_layout_get(self, *, group_id: Optional[str] = None, by: Optional[str] = None) -> Dict[str, Any]: + return self.call("memory_reme_layout_get", _compact({"group_id": group_id, "by": by})) + + def memory_reme_search( + self, + *, + query: str, + group_id: Optional[str] = None, + actor_id: Optional[str] = None, + limit: Optional[int] = None, + max_results: Optional[int] = None, + tags: Optional[List[str]] = None, + target: Optional[str] = None, + vector_weight: Optional[float] = None, + ) -> Dict[str, Any]: + return self.call( + "memory_reme_search", + _compact( + { + "group_id": group_id, + "actor_id": actor_id, + "query": str(query), + "limit": int(limit) if limit is not None else None, + "max_results": int(max_results) if max_results is not None else None, + "tags": [str(x) for x in tags] if tags is not None else None, + "target": str(target) if target is not None else None, + "vector_weight": float(vector_weight) if vector_weight is not None else None, + } + ), + ) + + def memory_reme_get( + self, + *, + group_id: Optional[str] = None, + path: Optional[str] = None, + actor_id: Optional[str] = None, + target: Optional[str] = None, + date: Optional[str] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, + ) -> Dict[str, Any]: + return self.call( + "memory_reme_get", + _compact( + { + "group_id": group_id, + "actor_id": actor_id, + "path": path, + "target": str(target) if target is not None else None, + "date": date, + "offset": int(offset) if offset is not None else None, + "limit": int(limit) if limit is not None else None, + } + ), + ) + + def memory_reme_write( + self, + *, + target: str, + content: str, + group_id: Optional[str] = None, + actor_id: Optional[str] = None, + tags: Optional[List[str]] = None, + source_refs: Optional[List[str]] = None, + idempotency_key: Optional[str] = None, + dedup_intent: Optional[str] = None, + dedup_query: Optional[str] = None, + date: Optional[str] = None, + ) -> Dict[str, Any]: + return self.call( + "memory_reme_write", + _compact( + { + "group_id": group_id, + "actor_id": actor_id, + "target": str(target), + "content": str(content), + "tags": [str(x) for x in tags] if tags is not None else None, + "source_refs": [str(x) for x in source_refs] if source_refs is not None else None, + "idempotency_key": idempotency_key, + "dedup_intent": dedup_intent, + "dedup_query": dedup_query, + "date": date, + } + ), + ) + + def memory_reme_index_sync( + self, *, group_id: Optional[str] = None, force: Optional[bool] = None, by: Optional[str] = None + ) -> Dict[str, Any]: + return self.call("memory_reme_index_sync", _compact({"group_id": group_id, "force": force, "by": by})) + + def memory_reme_context_check( + self, *, messages: List[Dict[str, Any]], group_id: Optional[str] = None, by: Optional[str] = None + ) -> Dict[str, Any]: + return self.call("memory_reme_context_check", _compact({"group_id": group_id, "messages": [dict(m) for m in messages], "by": by})) + + def memory_reme_compact( + self, + *, + messages: List[Dict[str, Any]], + group_id: Optional[str] = None, + return_prompt: Optional[bool] = None, + by: Optional[str] = None, + ) -> Dict[str, Any]: + return self.call( + "memory_reme_compact", + _compact({"group_id": group_id, "messages": [dict(m) for m in messages], "return_prompt": return_prompt, "by": by}), + ) + + def memory_reme_daily_flush( + self, *, group_id: Optional[str] = None, date: Optional[str] = None, by: Optional[str] = None + ) -> Dict[str, Any]: + return self.call("memory_reme_daily_flush", _compact({"group_id": group_id, "date": date, "by": by})) diff --git a/python/src/cccc_sdk/client_0430_ops.py b/python/src/cccc_sdk/client_0430_ops.py new file mode 100644 index 0000000..7b69ae1 --- /dev/null +++ b/python/src/cccc_sdk/client_0430_ops.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from .client_0430_admin_ops import CCCC0430AdminOpsMixin +from .client_0430_assistant_ops import CCCC0430AssistantOpsMixin +from .client_0430_memory_ops import CCCC0430MemoryOpsMixin + + +class CCCC0430OpsMixin( + CCCC0430AdminOpsMixin, + CCCC0430AssistantOpsMixin, + CCCC0430MemoryOpsMixin, +): + pass diff --git a/python/src/cccc_sdk/client_0430_shared.py b/python/src/cccc_sdk/client_0430_shared.py new file mode 100644 index 0000000..209c65e --- /dev/null +++ b/python/src/cccc_sdk/client_0430_shared.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from typing import Any, Dict + + +def _compact(args: Dict[str, Any]) -> Dict[str, Any]: + return {k: v for k, v in args.items() if v is not None} diff --git a/python/src/cccc_sdk/client_chat_ops.py b/python/src/cccc_sdk/client_chat_ops.py new file mode 100644 index 0000000..a6ce696 --- /dev/null +++ b/python/src/cccc_sdk/client_chat_ops.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional + + +class ChatOpsMixin: + def send_cross_group( + self, + *, + group_id: str, + dst_group_id: str, + text: str, + by: str = "user", + to: Optional[List[str]] = None, + priority: str = "normal", + reply_required: bool = False, + refs: Optional[List[Dict[str, Any]]] = None, + attachments: Optional[List[Dict[str, Any]]] = None, + insight: str = "", + require_peer_insight: Optional[bool] = None, + ) -> Dict[str, Any]: + args: Dict[str, Any] = { + "group_id": str(group_id), + "dst_group_id": str(dst_group_id), + "text": str(text), + "by": str(by), + "priority": str(priority), + "reply_required": bool(reply_required), + } + if to is not None: + args["to"] = [str(x) for x in to] + if refs is not None: + args["refs"] = [dict(r) for r in refs] + if attachments is not None: + args["attachments"] = [dict(a) for a in attachments] + if insight: + args["insight"] = str(insight) + if require_peer_insight is not None: + args["require_peer_insight"] = bool(require_peer_insight) + return self.call("send_cross_group", args) + + def send( + self, + *, + group_id: str, + text: str, + by: str = "user", + to: Optional[List[str]] = None, + priority: str = "normal", + reply_required: bool = False, + path: str = "", + refs: Optional[List[Dict[str, Any]]] = None, + attachments: Optional[List[Dict[str, Any]]] = None, + client_id: str = "", + suggested_user_message: str = "", + insight: str = "", + require_peer_insight: Optional[bool] = None, + ) -> Dict[str, Any]: + args: Dict[str, Any] = { + "group_id": str(group_id), + "text": str(text), + "by": str(by), + "priority": str(priority), + "reply_required": bool(reply_required), + } + if to is not None: + args["to"] = [str(x) for x in to] + if path: + args["path"] = str(path) + if refs is not None: + args["refs"] = [dict(r) for r in refs] + if attachments is not None: + args["attachments"] = [dict(a) for a in attachments] + if client_id: + args["client_id"] = str(client_id) + if suggested_user_message: + args["suggested_user_message"] = str(suggested_user_message) + if insight: + args["insight"] = str(insight) + if require_peer_insight is not None: + args["require_peer_insight"] = bool(require_peer_insight) + return self.call("send", args) + + def reply( + self, + *, + group_id: str, + reply_to: str, + text: str, + by: str = "user", + to: Optional[List[str]] = None, + priority: str = "normal", + reply_required: bool = False, + refs: Optional[List[Dict[str, Any]]] = None, + attachments: Optional[List[Dict[str, Any]]] = None, + client_id: str = "", + suggested_user_message: str = "", + insight: str = "", + require_peer_insight: Optional[bool] = None, + ) -> Dict[str, Any]: + args: Dict[str, Any] = { + "group_id": str(group_id), + "reply_to": str(reply_to), + "text": str(text), + "by": str(by), + "priority": str(priority), + "reply_required": bool(reply_required), + } + if to is not None: + args["to"] = [str(x) for x in to] + if refs is not None: + args["refs"] = [dict(r) for r in refs] + if attachments is not None: + args["attachments"] = [dict(a) for a in attachments] + if client_id: + args["client_id"] = str(client_id) + if suggested_user_message: + args["suggested_user_message"] = str(suggested_user_message) + if insight: + args["insight"] = str(insight) + if require_peer_insight is not None: + args["require_peer_insight"] = bool(require_peer_insight) + return self.call("reply", args) + + def chat_ack(self, *, group_id: str, actor_id: str, event_id: str, by: Optional[str] = None) -> Dict[str, Any]: + """ACK an attention message (self-only in CCCC: by must equal actor_id).""" + aid = str(actor_id) + return self.call( + "chat_ack", + { + "group_id": str(group_id), + "actor_id": aid, + "event_id": str(event_id), + "by": str(by) if by is not None else aid, + }, + ) + + def inbox_list( + self, + *, + group_id: str, + actor_id: str, + by: str = "user", + limit: int = 50, + kind_filter: str = "all", + ) -> Dict[str, Any]: + return self.call( + "inbox_list", + { + "group_id": str(group_id), + "actor_id": str(actor_id), + "by": str(by), + "limit": int(limit), + "kind_filter": str(kind_filter), + }, + ) + + def inbox_mark_read(self, *, group_id: str, actor_id: str, event_id: str, by: str = "user") -> Dict[str, Any]: + return self.call( + "inbox_mark_read", + {"group_id": str(group_id), "actor_id": str(actor_id), "event_id": str(event_id), "by": str(by)}, + ) + + def inbox_mark_all_read( + self, *, group_id: str, actor_id: str, by: str = "user", kind_filter: str = "all" + ) -> Dict[str, Any]: + return self.call( + "inbox_mark_all_read", + {"group_id": str(group_id), "actor_id": str(actor_id), "by": str(by), "kind_filter": str(kind_filter)}, + ) + + def notify_ack( + self, *, group_id: str, actor_id: str, notify_event_id: str, by: Optional[str] = None + ) -> Dict[str, Any]: + aid = str(actor_id) + return self.call( + "notify_ack", + { + "group_id": str(group_id), + "actor_id": aid, + "notify_event_id": str(notify_event_id), + "by": str(by) if by is not None else aid, + }, + ) diff --git a/python/src/cccc_sdk/client_group_space_ops.py b/python/src/cccc_sdk/client_group_space_ops.py new file mode 100644 index 0000000..8cc60f0 --- /dev/null +++ b/python/src/cccc_sdk/client_group_space_ops.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + + +class GroupSpaceOpsMixin: + def group_space_status(self, *, group_id: str, provider: str = "notebooklm") -> Dict[str, Any]: + return self.call("group_space_status", {"group_id": str(group_id), "provider": str(provider)}) + + def group_space_spaces(self, *, group_id: str, provider: str = "notebooklm") -> Dict[str, Any]: + return self.call("group_space_spaces", {"group_id": str(group_id), "provider": str(provider)}) + + def group_space_capabilities(self, *, group_id: str, provider: str = "notebooklm") -> Dict[str, Any]: + return self.call("group_space_capabilities", {"group_id": str(group_id), "provider": str(provider)}) + + def group_space_bind( + self, + *, + group_id: str, + lane: str, + action: str = "bind", + remote_space_id: str = "", + provider: str = "notebooklm", + by: str = "user", + ) -> Dict[str, Any]: + args: Dict[str, Any] = { + "group_id": str(group_id), + "provider": str(provider), + "lane": str(lane), + "action": str(action), + "by": str(by), + } + if remote_space_id: + args["remote_space_id"] = str(remote_space_id) + return self.call("group_space_bind", args) + + def group_space_ingest( + self, + *, + group_id: str, + lane: str, + kind: str = "context_sync", + payload: Optional[Dict[str, Any]] = None, + idempotency_key: str = "", + provider: str = "notebooklm", + by: str = "user", + ) -> Dict[str, Any]: + args: Dict[str, Any] = { + "group_id": str(group_id), + "provider": str(provider), + "lane": str(lane), + "kind": str(kind), + "by": str(by), + } + if payload is not None: + args["payload"] = dict(payload) + if idempotency_key: + args["idempotency_key"] = str(idempotency_key) + return self.call("group_space_ingest", args) + + def group_space_query( + self, + *, + group_id: str, + lane: str, + query: str, + options: Optional[Dict[str, Any]] = None, + provider: str = "notebooklm", + ) -> Dict[str, Any]: + args: Dict[str, Any] = { + "group_id": str(group_id), + "provider": str(provider), + "lane": str(lane), + "query": str(query), + } + if options is not None: + args["options"] = dict(options) + return self.call("group_space_query", args) + + def group_space_sources( + self, + *, + group_id: str, + lane: str, + action: str = "list", + source_id: str = "", + new_title: str = "", + provider: str = "notebooklm", + by: str = "user", + ) -> Dict[str, Any]: + args: Dict[str, Any] = { + "group_id": str(group_id), + "provider": str(provider), + "lane": str(lane), + "action": str(action), + "by": str(by), + } + if source_id: + args["source_id"] = str(source_id) + if new_title: + args["new_title"] = str(new_title) + return self.call("group_space_sources", args) + + def group_space_artifact( + self, + *, + group_id: str, + lane: str, + action: str = "list", + kind: str = "", + options: Optional[Dict[str, Any]] = None, + wait: Optional[bool] = None, + save_to_space: Optional[bool] = None, + output_path: str = "", + output_format: str = "", + artifact_id: str = "", + timeout_seconds: Optional[int] = None, + initial_interval: Optional[int] = None, + max_interval: Optional[int] = None, + provider: str = "notebooklm", + by: str = "user", + ) -> Dict[str, Any]: + args: Dict[str, Any] = { + "group_id": str(group_id), + "provider": str(provider), + "lane": str(lane), + "action": str(action), + "by": str(by), + } + if kind: + args["kind"] = str(kind) + if options is not None: + args["options"] = dict(options) + if wait is not None: + args["wait"] = bool(wait) + if save_to_space is not None: + args["save_to_space"] = bool(save_to_space) + if output_path: + args["output_path"] = str(output_path) + if output_format: + args["output_format"] = str(output_format) + if artifact_id: + args["artifact_id"] = str(artifact_id) + if timeout_seconds is not None: + args["timeout_seconds"] = int(timeout_seconds) + if initial_interval is not None: + args["initial_interval"] = int(initial_interval) + if max_interval is not None: + args["max_interval"] = int(max_interval) + return self.call("group_space_artifact", args) + + def group_space_jobs( + self, + *, + group_id: str, + lane: str, + action: str = "list", + job_id: str = "", + state: str = "", + limit: Optional[int] = None, + provider: str = "notebooklm", + by: str = "user", + ) -> Dict[str, Any]: + args: Dict[str, Any] = { + "group_id": str(group_id), + "provider": str(provider), + "lane": str(lane), + "action": str(action), + "by": str(by), + } + if job_id: + args["job_id"] = str(job_id) + if state: + args["state"] = str(state) + if limit is not None: + args["limit"] = int(limit) + return self.call("group_space_jobs", args) + + def group_space_sync( + self, + *, + group_id: str, + lane: str, + action: str = "status", + force: bool = False, + provider: str = "notebooklm", + by: str = "user", + ) -> Dict[str, Any]: + return self.call( + "group_space_sync", + { + "group_id": str(group_id), + "provider": str(provider), + "lane": str(lane), + "action": str(action), + "force": bool(force), + "by": str(by), + }, + ) diff --git a/python/src/cccc_sdk/client_group_space_provider_ops.py b/python/src/cccc_sdk/client_group_space_provider_ops.py new file mode 100644 index 0000000..2e42649 --- /dev/null +++ b/python/src/cccc_sdk/client_group_space_provider_ops.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + + +class GroupSpaceProviderOpsMixin: + def group_space_provider_credential_status(self, *, provider: str = "notebooklm", by: str = "user") -> Dict[str, Any]: + return self.call("group_space_provider_credential_status", {"provider": str(provider), "by": str(by)}) + + def group_space_provider_credential_update( + self, + *, + provider: str = "notebooklm", + by: str = "user", + auth_json: str = "", + clear: bool = False, + ) -> Dict[str, Any]: + args: Dict[str, Any] = {"provider": str(provider), "by": str(by), "clear": bool(clear)} + if auth_json: + args["auth_json"] = str(auth_json) + return self.call("group_space_provider_credential_update", args) + + def group_space_provider_health_check(self, *, provider: str = "notebooklm", by: str = "user") -> Dict[str, Any]: + return self.call("group_space_provider_health_check", {"provider": str(provider), "by": str(by)}) + + def group_space_provider_auth( + self, + *, + provider: str = "notebooklm", + action: str = "status", + timeout_seconds: Optional[int] = None, + by: str = "user", + ) -> Dict[str, Any]: + args: Dict[str, Any] = {"provider": str(provider), "action": str(action), "by": str(by)} + if timeout_seconds is not None: + args["timeout_seconds"] = int(timeout_seconds) + return self.call("group_space_provider_auth", args) diff --git a/python/tests/test_client_0430_contract.py b/python/tests/test_client_0430_contract.py new file mode 100644 index 0000000..9c0b52b --- /dev/null +++ b/python/tests/test_client_0430_contract.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from cccc_sdk.client import CCCCClient +from cccc_sdk.errors import IncompatibleDaemonError +from cccc_sdk.transport import DaemonEndpoint + + +class TestClient0433Contract(unittest.TestCase): + def _client(self) -> CCCCClient: + return CCCCClient(endpoint=DaemonEndpoint(transport="tcp", host="127.0.0.1", port=9000)) + + def test_current_message_preamble_and_terminal_ops(self) -> None: + captured: list[dict] = [] + + def fake_call_daemon(*, endpoint, request, timeout_s): # type: ignore[no-untyped-def] + captured.append(request) + return {"ok": True, "result": {}} + + with patch("cccc_sdk.client.call_daemon", side_effect=fake_call_daemon): + client = self._client() + client.send( + group_id="g_1", + text="next?", + insight="Compatibility is the release gate.", + require_peer_insight=True, + ) + client.reply(group_id="g_1", reply_to="e_1", text="done", insight="The probe now matches reality.") + client.group_preamble_get(group_id="g_1") + client.group_preamble_set(group_id="g_1", content="Project guidance") + client.group_preamble_reset(group_id="g_1") + client.terminal_history( + group_id="g_1", + actor_id="codex-1", + before=100, + limit_bytes=2048, + strip_ansi=True, + compact=True, + ) + client.terminal_since(group_id="g_1", actor_id="codex-1", after=100, limit_bytes=4096) + client.term_resize(group_id="g_1", actor_id="codex-1", cols=120, rows=40) + + self.assertEqual( + [request["op"] for request in captured], + [ + "send", + "reply", + "group_preamble_get", + "group_preamble_set", + "group_preamble_reset", + "terminal_history", + "terminal_since", + "terminal_resize", + ], + ) + self.assertEqual(captured[0]["args"]["insight"], "Compatibility is the release gate.") + self.assertIs(captured[0]["args"]["require_peer_insight"], True) + self.assertEqual(captured[4]["args"]["confirm"], "preamble") + self.assertEqual(captured[5]["args"]["before"], 100) + self.assertEqual(captured[6]["args"]["after"], 100) + + def test_current_voice_secretary_ops(self) -> None: + captured: list[dict] = [] + + def fake_call_daemon(*, endpoint, request, timeout_s): # type: ignore[no-untyped-def] + captured.append(request) + return {"ok": True, "result": {}} + + with patch("cccc_sdk.client.call_daemon", side_effect=fake_call_daemon): + client = self._client() + client.assistant_voice_transcript_append( + group_id="g_1", + session_id="s_1", + segment_id="seg_1", + text="hello", + document_path="notes/meeting.md", + is_final=True, + ) + client.assistant_voice_document_list(group_id="g_1", include_archived=True) + client.assistant_voice_document_save( + group_id="g_1", document_path="notes/meeting.md", content="# Summary", create_new=True + ) + client.assistant_voice_document_instruction( + group_id="g_1", document_path="notes/meeting.md", instruction="Tighten the summary" + ) + client.assistant_voice_input_append( + group_id="g_1", request_id="r_1", composer_text="draft", operation="replace_with_refined_prompt" + ) + client.assistant_voice_prompt_draft_submit(group_id="g_1", request_id="r_1", draft_text="refined") + client.assistant_voice_prompt_draft_ack(group_id="g_1", request_id="r_1", status="applied") + client.assistant_voice_request( + group_id="g_1", + request_text="Review the release", + target="@foreman", + artifact_paths=["notes/meeting.md"], + requires_ack=True, + ) + client.assistant_voice_document_archive(group_id="g_1", document_path="notes/meeting.md") + + self.assertEqual(captured[0]["args"]["session_id"], "s_1") + self.assertEqual(captured[2]["args"]["document_path"], "notes/meeting.md") + self.assertEqual(captured[4]["args"]["kind"], "prompt_refine") + self.assertEqual(captured[7]["args"]["request_text"], "Review the release") + + def test_removed_ipc_transcription_fails_clearly(self) -> None: + with self.assertRaises(IncompatibleDaemonError): + self._client().assistant_voice_transcribe(group_id="g_1", audio_base64="abc") + + +if __name__ == "__main__": + unittest.main() diff --git a/python/tests/test_client_contract_parity.py b/python/tests/test_client_contract_parity.py index fbb2632..c20674c 100644 --- a/python/tests/test_client_contract_parity.py +++ b/python/tests/test_client_contract_parity.py @@ -4,6 +4,7 @@ from unittest.mock import patch from cccc_sdk.client import CCCCClient +from cccc_sdk.errors import DaemonAPIError, IncompatibleDaemonError from cccc_sdk.transport import DaemonEndpoint @@ -104,6 +105,8 @@ def fake_call_daemon(*, endpoint, request, timeout_s): # type: ignore[no-untype priority="attention", reply_required=True, waiting_on="actor", + insight="This task closes the release gap.", + require_peer_insight=True, ) req = captured[0] @@ -123,9 +126,22 @@ def fake_call_daemon(*, endpoint, request, timeout_s): # type: ignore[no-untype "priority": "attention", "reply_required": True, "waiting_on": "actor", + "insight": "This task closes the release gap.", + "require_peer_insight": True, }, ) + def test_assert_compatible_probes_events_stream(self) -> None: + client = self._client() + def fake_call_raw(op: str, args: dict) -> dict: + if op == "ping": + return {"ok": True, "result": {"ipc_v": 1, "capabilities": {"events_stream": True}}} + raise DaemonAPIError(code="unknown_op", message=f"unknown operation: {op}", details={}) + + with patch.object(client, "call_raw", side_effect=fake_call_raw): + with self.assertRaises(IncompatibleDaemonError): + client.assert_compatible(require_ops=["events_stream"]) + def test_tracked_send_defaults_reply_required_to_true(self) -> None: captured: list[dict] = [] diff --git a/spec/ADAPTATION_PLAN.md b/spec/ADAPTATION_PLAN.md index bd1ff5e..7bedbe2 100644 --- a/spec/ADAPTATION_PLAN.md +++ b/spec/ADAPTATION_PLAN.md @@ -3,99 +3,34 @@ This document tracks remaining work to keep `cccc-sdk` aligned with the current CCCC daemon IPC surface. -The completed portion for the latest release lives in `CHANGELOG.md`. +The completed portion for each release lives in `CHANGELOG.md`. -## Coverage snapshot (vs. cccc 0.4.18 daemon) +## Coverage snapshot (vs. Rust cccc 0.4.33 daemon) -The SDK now includes the 0.4.18 Hermes runtime setup helpers and the -daemon-owned Voice Secretary recording lease, in addition to the 0.4.17 -contract-alignment wrappers. The remaining gaps are concentrated in the -families below. +The SDK mirrors the current CCCC standards snapshots and wraps the current +request/response JSON op listed in `spec/CCCC_DAEMON_IPC_V1.md`, except for +streaming socket-special attach operations that require a duplex transport. -## Remaining op families +Newly aligned in the 0.4.33 SDK line: -### 1. Voice Secretary / Assistant Voice remaining ops +- `insight` / `require_peer_insight` across message helpers +- `group_preamble_get`, `group_preamble_set`, `group_preamble_reset` +- cursor-based `terminal_history`, `terminal_since`, and Rust + `terminal_resize` dispatch compatibility +- current Voice Secretary transcript/document/request and prompt-refinement ops +- removal-safe migration error for the former `assistant_voice_transcribe` IPC +- real `events_stream` op probing, including pre-send protection in + `sendAndWaitForReply` -Daemon ops (under `daemon/assistants/assistant_ops.py`): +## Remaining op family -``` -assistant_voice_input_append -assistant_voice_transcribe -assistant_voice_model_install -assistant_voice_model_remove -assistant_voice_runtime_install -assistant_voice_runtime_remove -assistant_voice_transcript_append -assistant_voice_document_list -assistant_voice_document_select -assistant_voice_document_input_read -assistant_voice_document_save -assistant_voice_document_instruction -assistant_voice_document_archive -assistant_voice_prompt_draft_submit -assistant_voice_prompt_draft_ack -assistant_voice_instruction_feedback -assistant_voice_ask_requests_clear -assistant_voice_request -``` - -Mostly dict-in/dict-out; the contracts in -`contracts/v1/assistant.py` are stable. Suitable for one -self-contained PR. The transcript/document state should -be reflected as data classes on the TS side. - -### 2. Memory ReMe remaining ops - -``` -memory_reme_layout_get -memory_reme_write -memory_reme_index_sync -memory_reme_context_check -memory_reme_compact -memory_reme_daily_flush -``` - -`memory_reme_search` and `memory_reme_get` have thin wrappers already. -The remaining ops cover layout/indexing, write, context-check, and -compaction/flush. The shape is documented inline in -`daemon/memory/memory_ops.py`. Pure dict-in/dict-out; -about half a day's work. +### Streaming socket-special ops -### 3. ChatGPT Web Model runtime (7 ops) - -``` -web_model_runtime_wait_next_turn -web_model_runtime_complete_turn -web_model_browser_open -web_model_browser_info -web_model_browser_close -web_model_browser_attach # streaming -web_model_browser_vnc_attach # streaming -``` - -The two `runtime_*` ops are the only ones an external -automation client typically needs. Browser ops are -operator-facing. Streaming attach variants share the -need for a generalized bidirectional socket helper (see -§5). - -### 4. IM bridge management + Remote Access (9 ops) - -``` -im_bind_chat / im_list_authorized / im_list_pending / -im_reject_pending / im_revoke_chat -remote_access_state / remote_access_configure / -remote_access_start / remote_access_stop -``` - -Admin/operator-side. Trivial dict-in/dict-out. One -short PR. - -### 5. Streaming socket-special ops (8 ops) +These operations intentionally remain outside the thin request/response helper +set because they upgrade the daemon connection after the initial handshake: ``` term_attach -term_resize presentation_browser_attach presentation_browser_vnc_attach web_model_browser_attach @@ -104,51 +39,29 @@ space_provider_auth_browser_attach space_provider_auth_browser_vnc_attach ``` -These do not fit the request/response transport. They -return a streaming socket after the initial handshake. Implementation work: -- Generalize `open_events_stream` (Py) / `openEventsStream` - (TS) into a generic `open_attach_stream(op, args)` - helper that returns a duplex stream after handshake - validation. -- Wire up the eight ops above on top of that helper. -- `*_vnc_attach` returns raw RFB bytes; the SDK can - expose the duplex socket directly and let callers - forward to a VNC client. -- `term_resize` is regular request/response and can ship either as a small - standalone wrapper or together with `term_attach`. - -This is the only category that needs new transport -design; everything else above is mechanical. - -## Suggested ordering - -1. Streaming attach helper + the 8 streaming ops - (unlocks a whole UX category). -2. Voice Secretary remaining ops (biggest single block; full - contract is in `contracts/v1/assistant.py`). -3. Web Model runtime ops (depends on streaming helper - for browser ops; the two `runtime_*` ops can ship - standalone). -4. Memory ReMe remaining ops. -5. IM bridge + Remote Access 9 ops. - -## Contract details that may grow - -Watch the daemon for these (non-blocking now, but worth -keeping in sync): - -- `Reference.kind` literal in `contracts/v1/message.py`. - Currently the typed union is `file | url | commit | - text`, but the runtime also emits `task_ref` and - `presentation_ref`. If the daemon ever narrows the - literal, the SDK's `MessageRef` union should follow. -- `AgentRuntime` literal in `contracts/v1/actor.py`. -- `EventKind` literal in `contracts/v1/event.py` (new - `assistant.*` and `presentation.*` kinds have already - shipped). -- Async-result envelope shape in - `contracts/v1/async_result.py` — currently optional - fields; if any become required, update - `AsyncResultEnvelope` in `ts/src/types.ts`. +- Generalize `open_events_stream` (Python) / `openEventsStream` (TypeScript) + into a shared `open_attach_stream(op, args)` helper that validates the + handshake and returns a duplex stream/socket. +- Add attach helpers on top of that transport. +- Keep `*_vnc_attach` as raw byte streams so callers can bridge to an RFB/VNC + client without the SDK owning UI protocol semantics. + +Terminal resize is not streaming and is already wrapped. The public helper +retains the historical `termResize` / `term_resize` name while targeting the +Rust daemon's `terminal_resize` operation. + +## Watch list + +The CCCC source tree contains some source-only operations that are not currently +listed in the standards snapshot. Do not treat them as SDK contract +requirements until they enter `docs/standards/CCCC_DAEMON_IPC_V1.md` or are +explicitly promoted by CCCC core. + +Watch especially: + +- Web Model runtime/browser request-response ops. +- Group Bridge internal relay/status ops. +- `Reference.kind`, `AgentRuntime`, `EventKind`, and `AsyncResultEnvelope` + literal/schema growth in `contracts/v1`. diff --git a/spec/CCCC_CONTEXT_OPS_V1.md b/spec/CCCC_CONTEXT_OPS_V1.md index 46b4cad..ec0d757 100644 --- a/spec/CCCC_CONTEXT_OPS_V1.md +++ b/spec/CCCC_CONTEXT_OPS_V1.md @@ -213,7 +213,6 @@ Agent states are keyed by `actor_id`. environment_summary?: string user_model?: string persona_notes?: string - resume_hint?: string } ``` @@ -222,7 +221,10 @@ Permission: self / user. Notes: - Partial patch. - Daemon stores data into `hot` (`active_task_id`, `focus`, `blockers`, `next_action`) and `warm` (the rest). +- `open_loops` is the current memo for unfinished work, unresolved facts, risks, assumptions, and follow-ups. +- `commitments` records promises made to users or other actors. - `agent_state` is actor-owned working state. Foreman should guide peers via tasks, coordination, or help/role-notes, not by directly rewriting a peer's `agent_state`. +- `resume_hint` is not part of this contract; clients must use `open_loops` for current memo entries. - Legacy aliases `agent_id`, `environment`, `user_profile`, and `notes` are tolerated by daemon but SHOULD NOT be used by new clients. #### `agent_state.clear` diff --git a/spec/CCCC_DAEMON_IPC_V1.md b/spec/CCCC_DAEMON_IPC_V1.md index f70b98f..1825d6d 100644 --- a/spec/CCCC_DAEMON_IPC_V1.md +++ b/spec/CCCC_DAEMON_IPC_V1.md @@ -1,6 +1,6 @@ # CCCC Daemon API/IPC Contract v1 -Status: Draft (for CCCC v0.4.x ecosystem) +Status: Draft (for CCCC v0.5.x ecosystem) This document defines the **daemon-facing client contract** for CCCC: how a client (CLI/Web/MCP bridge/SDK) discovers the daemon endpoint, frames requests, and calls daemon operations. @@ -106,15 +106,11 @@ For all non-streaming operations, requests and responses are framed as: - **One JSON object per line**, delimited by a single `\n` (newline). - Encoding MUST be UTF‑8. -Baseline behavior (implemented by CCCC v0.4.x): -- Each connection processes exactly **one** request line and produces exactly **one** response line. -- The daemon then closes the connection. - -Clients MUST assume the daemon may close the connection after any successful response and MUST NOT rely on persistent connections. - -Forward-compatible extension (not required for v1): -- A daemon MAY accept multiple request lines over a single connection (strictly serial, no pipelining). +Baseline behavior (implemented by CCCC v0.5.x): +- A connection accepts multiple request lines and produces one response line for each request. +- Requests on one connection are processed strictly serially. - Clients MUST NOT pipeline requests (there is no request id / multiplexing in v1). +- Clients SHOULD reuse successful connections, but MUST tolerate the daemon closing a connection after any response and reconnect through endpoint discovery. ### 4.3 Size Limits @@ -122,7 +118,10 @@ Implementations MUST respect practical line limits to avoid truncation: - **Request line limit (daemon receive):** the daemon MAY stop reading after ~2,000,000 bytes without a newline; clients MUST keep request lines comfortably below this bound. - **Response line limit (typical clients):** the reference client reader MAY cap a response line at ~4,000,000 bytes; daemons SHOULD keep single-response payloads below this bound. -Clients SHOULD treat truncated/invalid JSON as a transport failure. +Clients SHOULD treat truncated/invalid JSON as a transport failure. Once any request bytes +have been written, clients MUST NOT automatically replay the request after a send, read, or +decode failure unless the operation carries a daemon-enforced idempotency key. Retrying a +failure that occurred while establishing the connection is safe because no request was sent. ### 4.4 Streaming Upgrade: `term_attach` @@ -334,12 +333,15 @@ Args: none Result: ```ts -{ version: string; pid: number; ts: string; ipc_v?: 1; capabilities?: Record } +{ version: string; pid: number; ts: string; ipc_v: 1; capabilities: Record } ``` Notes: -- `ipc_v` is RECOMMENDED for SDK compatibility checks. -- `capabilities` is RECOMMENDED as a best-effort feature map (e.g., `{ "events_stream": true }`). +- SDK-compatible daemons MUST return `ipc_v: 1`; omitting it is interpreted as IPC version `0`. +- SDK-compatible daemons MUST return a `capabilities` feature map. Python and Rust daemons advertise supported `events_stream` and `remote_access` features here. +- Clients SHOULD probe operation support independently; a recognized operation may reject empty probe arguments, but MUST NOT return `unknown_op`. +- Clients MUST use protocol, compatibility, and capability fields instead of exact product-version equality. +- Ordinary business commands MUST NOT stop, signal, or replace a reachable daemon. Implementation replacement is restricted to explicit daemon lifecycle commands. #### `shutdown` @@ -1274,6 +1276,59 @@ Result: { group: Record } // group.yaml content, redacted ``` +#### `group_preamble_get` + +Read the effective group startup preamble. A non-empty group override replaces +the built-in preamble body on the next preamble delivery; the fixed CCCC +identity and protocol frame remains in place. + +Args: +```ts +{ group_id: string } +``` + +Result: +```ts +{ + group_id: string + source: "builtin" | "home" + filename: "CCCC_PREAMBLE.md" + overridden: boolean + content: string +} +``` + +#### `group_preamble_set` + +Create or replace the non-empty group preamble override. The UTF-8 encoded +content must not exceed 512 KiB. Existing sessions that have already received +their preamble are not reinjected; start a fresh session when the new guidance +must apply immediately. `group_reset` creates a new group id and does not carry +this override forward, so provisioners must set the desired preamble on the +replacement group before starting its actors. This operation manages prompt +content only; consumers requiring a distinct standby turn must observe the +actor return to `waiting` or `idle` before sending the authoritative mission. + +Args: +```ts +{ group_id: string; content: string; by?: string } +``` + +Result: the `group_preamble_get` result plus `changed: boolean`. When `changed` +is false, the stored override is not rewritten. + +#### `group_preamble_reset` + +Delete the group override and restore the built-in preamble body. The explicit +confirmation avoids accidental removal. + +Args: +```ts +{ group_id: string; confirm: "preamble"; by?: string } +``` + +Result: the `group_preamble_get` result plus `changed: boolean`. + #### `group_create` Args: @@ -1375,12 +1430,13 @@ Result: #### `assistant_state` Read the group-scoped state for first-party built-in assistants. Voice -Secretary service-local ASR runs in a daemon-managed first-party service -process; heavy ASR runtimes remain behind an explicit local command adapter. +Secretary service-local ASR runs in-process through the Rust `sherpa-onnx` +binding. The native runtime is linked into the CCCC binary; model weights remain +explicit, checksummed downloads under `CCCC_HOME/cache/voice-models`. Args: ```ts -{ group_id: string; assistant_id?: "pet" | "voice_secretary" } +{ group_id: string; assistant_id?: "voice_secretary" } ``` Result: @@ -1400,13 +1456,26 @@ Result: active_document_id?: string // daemon sidecar/internal compatibility only capture_target_document_id?: string // daemon sidecar/internal compatibility only new_input_available?: boolean + service_runtime?: Record + service_runtimes_by_id?: Record + service_models?: Array> + service_models_by_id?: Record } ``` +Voice service runtime records may include `primary_package`, `package_versions`, +`installed_version`, `latest_version`, `latest_checked_at`, +`latest_check_error`, and `update_available` so local ASR settings can show the +linked sherpa-onnx version. Rust reports the stable runtime ID +`sherpa_onnx_streaming` for Web/API compatibility and `implementation="rust"`; +runtime install/remove calls are idempotent compatibility operations because the +linked runtime cannot be removed independently. Voice model records may include `installed_manifest_sha256`, +`update_available`, `last_update_error`, and artifact source fields (`url`, +`sha256`, `archive`) so model updates remain explicit and inspectable. + #### `assistant_settings_update` -Update group-scoped built-in assistant settings. `pet` is read-only in M0 and -mirrors `group_settings_update.patch.desktop_pet_enabled`. +Update group-scoped built-in assistant settings. When `voice_secretary.enabled=true`, the daemon also materializes a hidden internal actor with `internal_kind="voice_secretary"` and `actor_id="voice-secretary"`. @@ -1446,8 +1515,8 @@ Args: `browser_asr` means browser-managed speech recognition and does not guarantee browser-device-local model execution. `assistant_service_local_asr` means ASR -runs on the daemon host through the first-party Voice Secretary service and uses -an installed local ASR model. The returned assistant health may include `health.service` with +runs on the daemon host through native Rust and uses an installed local ASR +model. The returned assistant health may include `health.service` with `status`, `alive`, `asr_command_configured`, `asr_mock_configured`, `selected_model_id`, `managed_model`, and `last_error` so Web can show whether service-local ASR is actually usable. `service_model_id` is optional and @@ -1492,7 +1561,8 @@ Download and verify a daemon-managed local Voice Secretary ASR model into CCCC-owned cache storage. Built-in releases include a default model manifest; tests and local development may add a local overlay at `CCCC_HOME/config/voice-models.json`. Each artifact entry must include a fixed -URL and `sha256`. +URL and `sha256`. Reinstalling/updating a model downloads into staging storage +and replaces the active model only after all artifacts verify successfully. Args: ```ts @@ -1515,36 +1585,40 @@ Result: installed_at?: string updated_at?: string error?: Record + update_available?: boolean + installed_manifest_sha256?: string } } ``` -#### `assistant_voice_transcribe` +#### HTTP Voice Secretary transcription Transcribe a push-to-talk audio payload through the daemon-managed first-party -Voice Secretary service. This endpoint only returns transcript text and service +Voice Secretary runtime. Python is the default distribution and Rust implements +the same HTTP contract. This endpoint only returns transcript text and service health; it does not create a chat message, proposal, or working document by itself. Call `assistant_voice_transcript_append` after transcription so the daemon can append stable transcript source material and update the current working document. -Args: +Request: ```ts -{ - group_id: string - by?: string - audio_base64: string - mime_type?: string - language?: string -} +POST /api/v1/groups/{group_id}/assistants/voice_secretary/transcriptions + ?language={language}&by={actor_id} +Content-Type: audio/pcm | audio/wav | application/octet-stream + + ``` Preconditions: - `voice_secretary` is enabled for the group. - `recognition_backend` is `assistant_service_local_asr`. -- The selected `service_model_id` is installed and exposes a managed command via - the manifest. The effective command receives the audio path as the final - argument unless it includes `{audio_path}` / `{input_path}` / `{input}`. +- The selected offline `service_model_id` is installed and its manifest exposes + a supported sherpa-onnx model configuration. HTTP transcription accepts mono + PCM16 or WAV up to 100 MiB. The HTTP body and WebSocket PCM16 frames are + streamed to auto-deleted temporary files; browser service capture sends binary + PCM16 WebSocket frames. Python also accepts the former JSON/Base64 HTTP body + for compatibility, but clients should send the binary form above. Result: ```ts @@ -1570,6 +1644,13 @@ prevents two Voice Secretary recording streams from running at the same time. The lease is TTL-based so a crashed tab or disconnected browser eventually expires without manual cleanup. +The service-local ASR WebSocket requires the active `owner_id` and `lease_id` as +query parameters and revalidates them while audio is streaming. Opening the +transcription WebSocket directly cannot bypass the daemon lease. +Lease mutations match `group_id`, `owner_id`, and `lease_id`; public status and +conflict payloads redact `lease_id`. The stable browser owner identifies the +lease holder, while every recording uses a fresh `session_id`. + Args: ```ts { @@ -1634,11 +1715,31 @@ created while the actor was stopped, the daemon re-dispatches that same notify: headless runtimes receive it as a control turn, and PTY runtimes receive it through the pending delivery queue so lazy preamble delivery is triggered. +Rust commits an input under the group lock in this order: validate or create the +Markdown target, append the stable segment log, append the semantic input log, +then advance group session/cursor state. Retrying the same `session_id` and +`segment_id` is idempotent. Document paths must be repository-relative `.md` +paths and must not traverse symbolic links. + +Idempotency is checked against the complete semantic input log, not the bounded +session display window. If the input log was committed but its ledger input or +notify event was interrupted, retrying the same segment reuses the canonical +input record and completes only the missing delivery work. + The public document identity for Voice Secretary APIs is `document_path`, a repository-relative markdown path. `document_id` may exist in daemon sidecar state as an implementation detail, but runtime actors and Web clients should route by `document_path`. +`assistant_index`, `assistant_voice_document_list`, and +`assistant_voice_document_select` reconcile repository Markdown edits into the +daemon document index before returning. Reconciliation updates content, hash, +character count, and revision only when file content changed. Missing files do +not clear indexed content, and path/symbolic-link validation is applied before +reading. The emitted `assistant.voice.document` reconciliation event is an +auxiliary signal; index persistence and ledger append are not one atomic +transaction. + Args: ```ts { @@ -1808,6 +1909,72 @@ Result: } ``` +#### `assistant_voice_input_append` (`kind="prompt_refine"`) + +Create or update a composer refinement request. The daemon persists the request +before emitting one targeted `voice_secretary_input` notification. Its canonical +`input_envelope` carries `request_id`, `operation`, `composer_snapshot_hash`, and +matching composer metadata. This operation creates work for Voice Secretary; it +does not create a prompt draft. + +Args: +```ts +{ + group_id: string + by?: string + kind: "prompt_refine" + request_id?: string + voice_transcript?: string + composer_text?: string + operation?: "append_to_composer_end" | "replace_with_refined_prompt" | string + composer_context?: Record + composer_snapshot_hash?: string +} +``` + +At least one of `voice_transcript` or `composer_text` must be non-empty. + +#### `assistant_voice_prompt_draft_submit` + +Submit the Voice Secretary result for an existing prompt refinement request. +Only `voice-secretary` / `assistant:voice_secretary` may call this operation. +The daemon inherits a missing operation and composer snapshot hash from the +request, stores the result as `pending`, and emits +`assistant.voice.prompt_draft`. `no_op=true` stores `no_change` with empty draft +text. Submission MUST NOT append another semantic input or emit another +`voice_secretary_input` notification. + +Args: +```ts +{ + group_id: string + by?: "voice-secretary" | "assistant:voice_secretary" + request_id: string + draft_text?: string + no_op?: boolean + summary?: string + operation?: string + composer_snapshot_hash?: string +} +``` + +`draft_text` is required unless `no_op=true`. + +#### `assistant_voice_prompt_draft_ack` + +Mark a submitted draft as `applied`, `dismissed`, or `stale`. Acknowledgement +removes it from the active `prompt_draft` projection while retaining bounded +request history. + +Args: +```ts +{ + group_id: string + request_id: string + status: "applied" | "dismissed" | "stale" +} +``` + #### `assistant_voice_request` Send a structured Voice Secretary action request to `@foreman` or one concrete @@ -1870,7 +2037,7 @@ update it for control-plane repair. Args: ```ts -{ group_id: string; by?: string; assistant_id: "voice_secretary" | "pet"; lifecycle: "disabled" | "idle" | "running" | "working" | "waiting" | "failed"; health?: Record } +{ group_id: string; by?: string; assistant_id: "voice_secretary"; lifecycle: "disabled" | "idle" | "running" | "working" | "waiting" | "failed"; health?: Record } ``` Result: @@ -2139,6 +2306,23 @@ Notes: - For linked actors (`profile_id` set), `actor_start` and `actor_restart` first resolve profile runtime config and profile secrets. - If the linked profile includes `capability_defaults`, daemon applies baseline capability enables through capability control plane before launch. +#### `actor_new_session` + +Args: +```ts +{ group_id: string; actor_id: string; by?: string } +``` + +Result: +```ts +{ actor: Record; event: CCCSEventV1; new_session: true } +``` + +Notes: +- Supported for `claude`, `codex`, and Grok PTY actors. +- Stops the current actor runtime if present, clears CCCC's saved runtime session metadata for that actor, then starts the actor with the same runtime settings. +- Does not delete provider-side conversation/session history. + #### `runtime_hermes_status` Return Hermes runtime setup diagnostics for the selected user Hermes profile. @@ -2442,6 +2626,8 @@ Args (core): path?: string // optional filesystem path to attribute scope_key attachments?: unknown[] // attachment refs (implementation-defined) refs?: ReferenceV1[] // structured message refs, e.g. presentation_ref/task_ref + insight?: string // optional provisional sender perspective; max 1200 characters + require_peer_insight?: boolean // profile gate; default false src_group_id?: string // relay provenance (both required if either is set) src_event_id?: string dst_group_id?: string // optional "send record" metadata (source messages) @@ -2469,6 +2655,8 @@ Args: priority?: "normal" | "attention" attachments?: unknown[] refs?: ReferenceV1[] + insight?: string + require_peer_insight?: boolean // profile gate; default false } ``` @@ -2500,6 +2688,8 @@ Args: reply_required?: boolean // default true idempotency_key?: string refs?: ReferenceV1[] + insight?: string + require_peer_insight?: boolean // profile gate; default false } ``` @@ -2531,7 +2721,7 @@ Cross-group send implemented as: Args: ```ts -{ group_id: string; dst_group_id: string; text: string; by?: string; to?: string[]; priority?: "normal" | "attention" } +{ group_id: string; dst_group_id: string; text: string; by?: string; to?: string[]; priority?: "normal" | "attention"; insight?: string; require_peer_insight?: boolean } ``` Result: @@ -2542,6 +2732,14 @@ Result: Notes: - Attachments are not supported in cross-group send in v1. +#### Agent Insight Profile marker + +`require_peer_insight` is an internal request-profile marker, not a global message-validity rule. It defaults to `false`. When `true`, the daemon resolves the operation's real audience and rejects a new peer-facing message whose normalized `insight` is empty. User-only sends remain valid without Insight. + +The check MUST occur after routing and successful-idempotency lookup, but before this request creates a new message, task, actor wake, or remote outbox entry. The recommended error code is `peer_insight_required`, with `details.delivery_state="not_sent"` and `details.new_side_effects=false`. Invalid Insight type or length SHOULD use `invalid_insight` instead. Existing accepted idempotent operations MUST replay their original result without being reinterpreted by a newer profile requirement. + +For a legacy Group Bridge wire that does not advertise structured Insight, an implementation MAY flatten the perspective into remote text with an explicit sender-perspective label. It MUST NOT infer structured `insight` back from that text. + #### `chat_ack` Append a `chat.ack` event (attention acknowledgement). @@ -2642,7 +2840,6 @@ Result: environment_summary?: string | null user_model?: string | null persona_notes?: string | null - resume_hint?: string | null } updated_at?: string | null }> @@ -2939,168 +3136,6 @@ Result: } ``` -### 8.15.1 First-Class Local Memory API - -These operations expose the same local CCCC memory index and persistence layer used -by `cccc_memory` to non-MCP clients such as SDK-based local workers. They MUST NOT -route through Group Space / NotebookLM bindings. - -All successful results SHOULD include: -```ts -{ - provider: "cccc-memory" - source: "local-index" | "local-file" - latencyMs: number -} -``` - -Common error codes: -- `memory_index_missing` -- `memory_write_failed` -- `memory_group_missing` -- `memory_permission_denied` - -#### `memory_search` - -Args: -```ts -{ - group_id: string - actor_id?: string - query: string - limit?: number - tags?: string[] - target?: "memory" | "daily" -} -``` - -Result: -```ts -{ - provider: "cccc-memory" - source: "local-index" - latencyMs: number - hits: Array<{ - path: string - startLine: number - score: number - snippet: string - content?: string - tags?: string[] - sourceRefs?: string[] - }> -} -``` - -The daemon SHOULD reuse `memory_reme_search` index/ranking behavior and adapt field -names for SDK consumers; it MUST NOT maintain a second memory index. - -#### `memory_get` - -Args: -```ts -{ - group_id: string - actor_id?: string - path?: string - target?: "memory" | "daily" - date?: string - offset?: number - limit?: number -} -``` - -Result: -```ts -{ - provider: "cccc-memory" - source: "local-file" - latencyMs: number - path: string - offset: number - limit: number - content: string -} -``` - -#### `memory_write` - -Args: -```ts -{ - group_id: string - actor_id?: string - target: "memory" | "daily" - content: string - tags?: string[] - source_refs?: string[] - idempotency_key?: string - dedup_intent?: "new" | "update" | "supersede" | "silent" - dedup_query?: string -} -``` - -Result: -```ts -{ - provider: "cccc-memory" - source: "local-file" - latencyMs: number - status: "written" | "silent" - path: string - contentHash?: string - dedup?: Record -} -``` - -`idempotency_key` MUST prevent duplicate writes from polling workers. - -#### `memory_profile_get` - -Args: -```ts -{ - group_id: string - actor_id?: string - user_id?: string - tags?: string[] -} -``` - -Result: -```ts -{ - provider: "cccc-memory" - source: "local-index" - latencyMs: number - profile: string - hits: Array<{ path: string; startLine: number; score: number; snippet: string }> -} -``` - -This operation MAY be implemented as a stable tagged `memory_search` wrapper. - -#### `memory_health` - -Args: -```ts -{ group_id: string } -``` - -Result: -```ts -{ - provider: "cccc-memory" - source: "local-index" - latencyMs: number - status: "ok" | "degraded" | "error" - indexReady: boolean - writable: boolean - memoryRoot: string - lastIndexedAt?: string -} -``` - #### `task_list` Args: @@ -3219,11 +3254,68 @@ Args: { group_id: string; actor_id: string; by?: string; max_chars?: number; strip_ansi?: boolean; compact?: boolean } ``` +`max_chars` limits the final returned Unicode text. Implementations MUST render the complete +retained PTY backlog before applying this limit; truncating the raw ANSI/VT byte stream first can +start replay inside an escape sequence or incremental screen update and produce corrupt snapshots. + +Result: +```ts +{ group_id: string; actor_id: string; warning: string; hint: string; text: string; end_cursor: number } +``` + +`end_cursor` is the exclusive raw PTY byte cursor captured with the backlog used to produce +`text`. A terminal client MAY display the rendered snapshot and then attach its live stream with +`since=end_cursor`; the stream must replay output produced after the snapshot so the transition is +gap-free. + +#### `terminal_history` + +Args: +```ts +{ group_id: string; actor_id: string; by?: string; before?: number; limit_bytes?: number; strip_ansi?: boolean; compact?: boolean } +``` + Result: ```ts -{ group_id: string; actor_id: string; warning: string; hint: string; text: string } +{ + group_id: string + actor_id: string + warning: string + hint: string + text: string + start_cursor: number + end_cursor: number + has_more: boolean + cursor_expired: boolean +} ``` +#### `terminal_since` + +Args: +```ts +{ group_id: string; actor_id: string; by?: string; after: number; limit_bytes?: number } +``` + +Result: +```ts +{ + history: { + data: string + start_cursor: number + end_cursor: number + has_more: boolean + cursor_expired: boolean + } +} +``` + +The cursors count raw PTY bytes. Because `data` is transported as UTF-8 JSON text, an +implementation MUST NOT advance `end_cursor` through an incomplete UTF-8 code point. It MAY return +up to three bytes beyond `limit_bytes` to finish a code point. If the retained stream currently ends +inside a code point, it returns the complete prefix and leaves the incomplete suffix for a later +call. + #### `terminal_clear` Args: @@ -3615,6 +3707,45 @@ Result: { remote_access: Record } ``` +### 8.17.1 Group Bridge delivery compatibility + +The daemon accepts the Python-compatible Group Bridge operations: + +- `remote_send`: send a payload through an active registration or trust. It + requires `group_id`, `registration_id`, `idempotency_key`, and an explicit + `payload.to` recipient list. +- `remote_delivery_status`: return the stored receipt identified by + `registration_id` and `idempotency_key`. +- `group_bridge_receive_remote_send`: authenticate an already-resolved inbound + session using `target_group_id`, `src_group_id`, `remote_peer_id`, and append + its payload idempotently to the target group. + +Implementations MUST persist delivery receipts and MUST NOT create duplicate +events when the same registration and idempotency key are retried. + +The Rust WebSocket owner and MCP bridge share live reverse-session state through +these daemon-internal operations: + +- `group_bridge_session_open`: register a live route identified by `group_id`, + `remote_group_id`, and `remote_peer_id`; returns a new opaque `generation`. +- `group_bridge_session_close`: remove the route only when its `generation` + still matches. A stale socket MUST NOT close a replacement session. +- `group_bridge_session_ready`: report whether that exact route currently has a + live session lease. +- `group_bridge_session_poll`: let the owning WebSocket take the next queued + server-to-peer request for its generation. +- `group_bridge_session_complete`: resolve a request using `response_to` and a + peer-provided `result`. +- `group_bridge_session_deliver`: enqueue a `remote_send` request and await its + response for at most `timeout_ms`. + +These operations are runtime-only and MUST NOT treat persisted trust status as +proof of reachability. Opening a replacement generation, closing the active +generation, and completing a response MUST wake pending callers immediately. +Delivery failures use `peer_session_unavailable` when no live lease exists or +disconnects, `peer_session_timeout` when the peer does not answer in time, and +`peer_session_failed` when a session is replaced or returns an invalid result. + ### 8.18 Group Space (Provider-Backed Shared Memory, dual-lane NotebookLM) These operations provide a thin control-plane for optional external memory providers. @@ -4332,6 +4463,46 @@ Notes: - Export MUST exclude live runtime state, browser profiles, credentials, connector secrets, lock files, and rebuildable caches. - Export MUST scrub actor environment secrets from packaged `group.yaml`. - `contains_secrets: false` means CCCC-managed live credentials and auth sessions are excluded. The package can still contain user-provided sensitive content such as ledger history, memory, blobs, and attachments. +- This compatibility operation is intended for small packages. Large packages SHOULD use `group_copy_export_file` and pass the returned `package_path` to preview/import. + +#### `group_copy_export_file` + +Export one group as a zip package stored on the daemon host filesystem. + +Args: +```ts +{ + group_id: string + by?: string +} +``` + +Result: +```ts +{ + package_path: string + package_size_bytes: number + filename: string + manifest: { + kind: "cccc.group_copy" + version: number + source_group_id: string + source_title?: string + exported_at: string + cccc_version?: string + source_platform?: string + export_mode: "group_state_only" + workspace_included: false + contains_secrets: false + content_digest?: string + content?: Record + } +} +``` + +Notes: +- The package path is a temporary daemon-local file path intended for local download flows. +- This operation uses the large package limit. Secret-scrubbing requirements match `group_copy_export`. #### `group_copy_preview_import` @@ -4340,11 +4511,14 @@ Validate a copy package and return an import preview without writing group state Args: ```ts { - package_b64: string + package_b64?: string + package_path?: string by?: string } ``` +Exactly one of `package_b64` or `package_path` is required. `package_b64` is a small-package compatibility path; large local flows SHOULD use `package_path`. + Result: ```ts { @@ -4377,13 +4551,16 @@ Import a group copy into the current `CCCC_HOME`. Args: ```ts { - package_b64: string + package_b64?: string + package_path?: string workspace_root?: string title?: string by?: string } ``` +Exactly one of `package_b64` or `package_path` is required. `package_b64` is a small-package compatibility path; large local flows SHOULD use `package_path`. + Result: ```ts { diff --git a/spec/CCCS_V1.md b/spec/CCCS_V1.md index 96dbad4..cf0098c 100644 --- a/spec/CCCS_V1.md +++ b/spec/CCCS_V1.md @@ -29,7 +29,7 @@ CCCS v1 MUST enable: CCCS v1 does NOT standardize: - Any specific workflow engine, DAG, or no-code builder. -- Any model/provider API (OpenAI/Claude/Gemini/etc.) or prompt format. +- Any model/provider API (OpenAI/Claude/etc.) or prompt format. - Any single transport (Unix socket, HTTP, SSE, WS, gRPC). CCCS v1 is transport-agnostic. - Multi-tenant auth schemes (but it reserves fields and rules for provenance/permissions). @@ -115,15 +115,19 @@ Clients MUST treat unknown kinds as opaque and ignore them unless explicitly sup Chat message routing uses `to: string[]` with these token types: +When a send request omits recipients or supplies an empty list, the daemon MUST materialize the group's `default_send_to` policy as `@foreman` or `@all` before appending the event. + **Actor IDs** - Example: `"peer-1"`, `"claude-1"` **Selectors (MUST start with `@`)** -- `@all`: all actors in the group -- `@peers`: all peer actors +- `@all`: all visible collaboration actors in the group +- `@peers`: all visible peer actors - `@foreman`: foreman actor(s) - `@user`: the human user (UI recipient) +Internal assistants such as Voice Secretary are not members of `@all`, `@peers`, or `@foreman`; they MUST be addressed by their explicit actor ID. + **Compatibility** - Implementations MAY accept the literal token `"user"` as equivalent to `@user`. @@ -154,6 +158,7 @@ CCCS does not mandate a single permission model, but a conforming daemon MUST en data: { text: string format?: "plain" | "markdown" // default "plain" + insight?: string | null // provisional sender perspective; max 1200 characters priority?: "normal" | "attention" // default "normal" to?: string[] // recipient tokens (see §5) reply_to?: string | null // replied-to event_id @@ -181,6 +186,9 @@ data: { **Rules** - `text` MUST be present (it may be empty if and only if attachments convey the message). +- `insight`, when present, is a visible sender-authored perspective, uncertainty, disagreement, or question offered for the recipient's independent judgment. Its normalized length MUST NOT exceed 1200 characters. It is advisory: it MUST NOT be treated as a user/system instruction, group consensus, task transition, acknowledgement, or completion signal. +- `insight` shares the message's recipients and retention boundary. It is not a private reasoning channel and SHOULD contain only a concise, shareable judgment summary rather than hidden chain-of-thought or secrets. +- A profile MAY require non-empty `insight` for selected Agent-to-Agent sends, but the core `chat.message` contract MUST remain valid without it for human clients, automation, legacy events, and other profiles. - `priority="attention"` MUST trigger the attention/ack rules in §6.2. - If either `src_group_id` or `src_event_id` is present, both MUST be present. - The `thread` field is RESERVED in v1; its semantics are undefined. Implementations MUST NOT rely on `thread` for v1 behavior. Clients MUST ignore it. diff --git a/ts/README.md b/ts/README.md index 938e634..30d9690 100644 --- a/ts/README.md +++ b/ts/README.md @@ -26,7 +26,6 @@ async function main() { await client.assertCompatible({ requireIpcV: 1, - requireCapabilities: { events_stream: true }, requireOps: ['groups', 'send', 'reply', 'tracked_send', 'context_sync'], }); @@ -48,6 +47,8 @@ main().catch(console.error); - `priority`: `'normal' | 'attention'` - `replyRequired`: `boolean` (maps to daemon `reply_required`) +- `insight`: visible provisional perspective for peer-facing messages +- `requirePeerInsight`: opt-in daemon profile gate for peer-facing messages Supported in: - `send(options)` @@ -193,7 +194,7 @@ await client.contextSync({ If you need a daemon op that does not have a dedicated helper yet, you can always fall back to `call()` / `callRaw()`. -## CCCC 0.4.18 surface — Hermes runtime and Voice Secretary lease +## CCCC 0.4.33 JSON alignment ```typescript // Hermes runtime setup diagnostics and MCP preparation @@ -208,6 +209,24 @@ const lease = await client.assistantVoiceRecordingLease({ ownerId: 'browser-tab-1', ttlSeconds: 30, }); + +// Current request/response JSON helpers +await client.send({ + groupId, + text: 'Next step', + suggestedUserMessage: 'Run the checks', + insight: 'The compatibility gate is the release-critical part.', +}); +await client.actorNewSession({ groupId, actorId: 'codex-1', clearSavedSession: true }); +await client.groupCopyExportFile({ groupId, includeBlobs: true }); +await client.groupPreambleSet({ groupId, content: 'Project-specific startup guidance' }); +await client.terminalHistory({ groupId, actorId: 'codex-1', limitBytes: 64_000 }); +await client.terminalSince({ groupId, actorId: 'codex-1', after: 0 }); +await client.termResize({ groupId, actorId: 'codex-1', cols: 120, rows: 40 }); +await client.blueprintGenerate({ groupId, taskId: 'task-1' }); +await client.memoryRemeSearch({ groupId, query: 'release notes' }); +await client.imListAuthorized({ platform: 'dingtalk' }); +await client.remoteAccessState({ groupId }); ``` ## CCCC 0.4.17 surface — new op families @@ -273,11 +292,20 @@ await client.ledgerSnapshot({ groupId, reason: 'manual' }); await client.brandingUpdate({ patch: { product_name: 'My CCCC' } }); ``` -Not yet wrapped (use `call()` for now): remaining Voice Secretary document/transcribe/prompt ops, remaining Memory ReMe write/index/compaction ops, ChatGPT Web Model runtime, IM bridge management, Remote Access, and the streaming socket-special browser/PTY attach ops. See `spec/ADAPTATION_PLAN.md` for the roadmap. +The current request/response JSON ops are wrapped in the Python and TypeScript +clients. Still deferred: streaming socket-special attach ops such as PTY, +browser, VNC, and web-model attach flows; those need a shared duplex transport +instead of a plain `call()` / `callRaw()` wrapper. See +`spec/ADAPTATION_PLAN.md` for the roadmap. ## Events stream +Require the operation explicitly before relying on it. This probes the daemon +instead of trusting a capability flag that may be stale or incorrect: + ```typescript +await client.assertCompatible({ requireOps: ['events_stream'] }); + for await (const item of client.eventsStream({ groupId })) { if (item.t === 'event') { console.log(item.event.kind, item.event.id); @@ -285,6 +313,13 @@ for await (const item of client.eventsStream({ groupId })) { } ``` +`sendAndWaitForReply()` performs the same probe before sending, so an unavailable +stream cannot produce a sent message followed by an `unknown_op` failure. + +Voice transcription is no longer a daemon JSON operation in Rust CCCC. Use the +HTTP Voice Secretary transcription endpoint; the deprecated +`assistantVoiceTranscribe()` helper now fails locally with a migration message. + ## Build and checks ```bash diff --git a/ts/__tests__/client_0430_contract.test.ts b/ts/__tests__/client_0430_contract.test.ts new file mode 100644 index 0000000..51556e9 --- /dev/null +++ b/ts/__tests__/client_0430_contract.test.ts @@ -0,0 +1,129 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { CCCCClient } from '../src/client.js'; +import { IncompatibleDaemonError } from '../src/errors.js'; + +type CallCapture = { op: string; args?: Record }; + +async function makeClient(calls: CallCapture[]): Promise { + const client = await CCCCClient.create({ + endpoint: { transport: 'tcp', host: '127.0.0.1', port: 1, path: '' }, + }); + client.call = async (op: string, args?: Record): Promise> => { + calls.push({ op, args }); + return {}; + }; + return client; +} + +describe('cccc 0.4.33 JSON op alignment', () => { + it('maps current message, group preamble, and terminal operations', async () => { + const calls: CallCapture[] = []; + const client = await makeClient(calls); + + await client.send({ + groupId: 'g_1', + text: 'next?', + suggestedUserMessage: 'ship it', + insight: 'Compatibility is the release gate.', + requirePeerInsight: true, + }); + await client.reply({ groupId: 'g_1', replyTo: 'e_1', text: 'done', insight: 'The probe now matches reality.' }); + await client.groupPreambleGet({ groupId: 'g_1' }); + await client.groupPreambleSet({ groupId: 'g_1', content: 'Project guidance' }); + await client.groupPreambleReset({ groupId: 'g_1' }); + await client.terminalHistory({ + groupId: 'g_1', + actorId: 'codex-1', + before: 100, + limitBytes: 2048, + stripAnsi: true, + compact: true, + }); + await client.terminalSince({ groupId: 'g_1', actorId: 'codex-1', after: 100, limitBytes: 4096 }); + await client.termResize({ groupId: 'g_1', actorId: 'codex-1', cols: 120, rows: 40 }); + + assert.deepEqual(calls.map((call) => call.op), [ + 'send', + 'reply', + 'group_preamble_get', + 'group_preamble_set', + 'group_preamble_reset', + 'terminal_history', + 'terminal_since', + 'terminal_resize', + ]); + assert.equal(calls[0]?.args?.['insight'], 'Compatibility is the release gate.'); + assert.equal(calls[0]?.args?.['require_peer_insight'], true); + assert.equal(calls[4]?.args?.['confirm'], 'preamble'); + assert.equal(calls[5]?.args?.['before'], 100); + assert.equal(calls[5]?.args?.['limit_bytes'], 2048); + assert.equal(calls[6]?.args?.['after'], 100); + }); + + it('maps current Voice Secretary request/response operations', async () => { + const calls: CallCapture[] = []; + const client = await makeClient(calls); + + await client.assistantVoiceTranscriptAppend({ + groupId: 'g_1', + sessionId: 's_1', + segmentId: 'seg_1', + text: 'hello', + documentPath: 'notes/meeting.md', + isFinal: true, + }); + await client.assistantVoiceDocumentList({ groupId: 'g_1', includeArchived: true }); + await client.assistantVoiceDocumentSave({ + groupId: 'g_1', + documentPath: 'notes/meeting.md', + content: '# Summary', + createNew: true, + }); + await client.assistantVoiceDocumentInstruction({ + groupId: 'g_1', + documentPath: 'notes/meeting.md', + instruction: 'Tighten the summary', + }); + await client.assistantVoiceInputAppend({ + groupId: 'g_1', + requestId: 'r_1', + composerText: 'draft', + operation: 'replace_with_refined_prompt', + }); + await client.assistantVoicePromptDraftSubmit({ groupId: 'g_1', requestId: 'r_1', draftText: 'refined' }); + await client.assistantVoicePromptDraftAck({ groupId: 'g_1', requestId: 'r_1', status: 'applied' }); + await client.assistantVoiceRequest({ + groupId: 'g_1', + requestText: 'Review the release', + target: '@foreman', + artifactPaths: ['notes/meeting.md'], + requiresAck: true, + }); + await client.assistantVoiceDocumentArchive({ groupId: 'g_1', documentPath: 'notes/meeting.md' }); + + assert.deepEqual(calls.map((call) => call.op), [ + 'assistant_voice_transcript_append', + 'assistant_voice_document_list', + 'assistant_voice_document_save', + 'assistant_voice_document_instruction', + 'assistant_voice_input_append', + 'assistant_voice_prompt_draft_submit', + 'assistant_voice_prompt_draft_ack', + 'assistant_voice_request', + 'assistant_voice_document_archive', + ]); + assert.equal(calls[0]?.args?.['session_id'], 's_1'); + assert.equal(calls[2]?.args?.['document_path'], 'notes/meeting.md'); + assert.equal(calls[4]?.args?.['kind'], 'prompt_refine'); + assert.equal(calls[7]?.args?.['request_text'], 'Review the release'); + }); + + it('fails clearly for the removed daemon IPC transcription operation', async () => { + const client = await makeClient([]); + await assert.rejects( + client.assistantVoiceTranscribe({ groupId: 'g_1', audioBase64: 'abc' }), + IncompatibleDaemonError + ); + }); +}); diff --git a/ts/__tests__/client_contract.test.ts b/ts/__tests__/client_contract.test.ts index a956cde..f424b6a 100644 --- a/ts/__tests__/client_contract.test.ts +++ b/ts/__tests__/client_contract.test.ts @@ -1,6 +1,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { CCCCClient } from '../src/client.js'; +import { DaemonAPIError, IncompatibleDaemonError } from '../src/errors.js'; async function captureCall( invoke: (client: CCCCClient) => Promise @@ -34,6 +35,8 @@ describe('CCCCClient newer CCCC operation wrappers', () => { priority: 'attention', replyRequired: true, waitingOn: 'actor', + insight: 'This task closes the release gap.', + requirePeerInsight: true, })); assert.equal(call.op, 'tracked_send'); @@ -49,6 +52,8 @@ describe('CCCCClient newer CCCC operation wrappers', () => { priority: 'attention', reply_required: true, waiting_on: 'actor', + insight: 'This task closes the release gap.', + require_peer_insight: true, }); }); @@ -324,6 +329,11 @@ describe('CCCCClient newer CCCC operation wrappers', () => { endpoint: { transport: 'tcp', host: '127.0.0.1', port: 9, path: '' }, }); const order: string[] = []; + client.callRaw = async (op) => { + assert.equal(op, 'events_stream'); + order.push('probe'); + return { ok: true, result: {} }; + }; client.eventsStream = async function* () { order.push('stream-start'); yield { @@ -358,6 +368,42 @@ describe('CCCCClient newer CCCC operation wrappers', () => { }); assert.equal(reply.id, 'reply-1'); - assert.deepEqual(order, ['stream-start', 'send']); + assert.deepEqual(order, ['probe', 'stream-start', 'send']); + }); + + it('sendAndWaitForReply does not send when events_stream is unavailable', async () => { + const client = await CCCCClient.create({ + endpoint: { transport: 'tcp', host: '127.0.0.1', port: 9, path: '' }, + }); + let sent = false; + client.callRaw = async () => { + throw new DaemonAPIError('unknown_op', 'unknown operation: events_stream'); + }; + client.send = async () => { + sent = true; + return {}; + }; + + await assert.rejects( + client.sendAndWaitForReply({ groupId: 'g1', listenAs: 'user', text: 'question' }), + DaemonAPIError + ); + assert.equal(sent, false); + }); + + it('assertCompatible probes events_stream instead of trusting the capability flag', async () => { + const client = await CCCCClient.create({ + endpoint: { transport: 'tcp', host: '127.0.0.1', port: 9, path: '' }, + }); + client.ping = async () => ({ ipc_v: 1, capabilities: { events_stream: true } }); + client.callRaw = async (op) => { + assert.equal(op, 'events_stream'); + throw new DaemonAPIError('unknown_op', 'unknown operation: events_stream'); + }; + + await assert.rejects( + client.assertCompatible({ requireOps: ['events_stream'] }), + IncompatibleDaemonError + ); }); }); diff --git a/ts/package-lock.json b/ts/package-lock.json index 304adf5..4540ed8 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -1,12 +1,12 @@ { "name": "cccc-sdk", - "version": "0.4.18", + "version": "0.4.33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cccc-sdk", - "version": "0.4.18", + "version": "0.4.33", "license": "Apache-2.0", "devDependencies": { "@types/node": "^20.0.0", @@ -14,7 +14,7 @@ "typescript": "^5.0.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/ts/package.json b/ts/package.json index 942f3d1..6c8c698 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,6 +1,6 @@ { "name": "cccc-sdk", - "version": "0.4.18", + "version": "0.4.33", "description": "Client SDK for the CCCC daemon (IPC v1)", "type": "module", "main": "./dist/index.js", diff --git a/ts/src/client.ts b/ts/src/client.ts index 03668d2..0cc3cb6 100644 --- a/ts/src/client.ts +++ b/ts/src/client.ts @@ -8,9 +8,6 @@ import type { DaemonResponse, CCCCClientOptions, CompatibilityOptions, - SendOptions, - SendCrossGroupOptions, - ReplyOptions, ActorAddOptions, ActorUpdateOptions, ActorEnvPrivateUpdateOptions, @@ -38,21 +35,6 @@ import type { GroupAutomationUpdateOptions, GroupAutomationManageOptions, GroupAutomationResetBaselineOptions, - GroupSpaceStatusOptions, - GroupSpaceSpacesOptions, - GroupSpaceCapabilitiesOptions, - GroupSpaceBindOptions, - GroupSpaceIngestOptions, - GroupSpaceQueryOptions, - GroupSpaceSourcesOptions, - GroupSpaceArtifactOptions, - GroupSpaceJobsOptions, - GroupSpaceSyncOptions, - GroupSpaceProviderCredentialStatusOptions, - GroupSpaceProviderCredentialUpdateOptions, - GroupSpaceProviderHealthCheckOptions, - GroupSpaceProviderAuthOptions, - InboxListOptions, ContextSyncOptions, CoordinationBriefUpdateOptions, CoordinationNoteAddOptions, @@ -65,9 +47,6 @@ import type { MetaMergeOptions, EventsStreamOptions, EventStreamItem, - CCCSEvent, - SendResult, - SendAndWaitOptions, PingResult, GroupsResult, GroupShowResult, @@ -130,6 +109,9 @@ import { openEventsStream, readLines, } from './transport.js'; +import { installCCCC0430Ops, type CCCC0430Ops } from './client_0430_ops.js'; +import { installGroupSpaceOps, type GroupSpaceOps } from './client_group_space_ops.js'; +import { installChatOps, type ChatOps } from './client_chat_ops.js'; function compactRecord(input: Record): Record { return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)); @@ -250,9 +232,7 @@ export class CCCCClient { const reservedOps = new Set([ 'ping', 'shutdown', - 'events_stream', 'term_attach', - 'term_resize', 'presentation_browser_attach', 'presentation_browser_vnc_attach', 'web_model_browser_attach', @@ -909,206 +889,6 @@ export class CCCCClient { })); } - // ============================================================ - // Convenience methods: messaging - // ============================================================ - - /** - * Send a chat message to a group. - * @param options - Message content, recipients, and priority. - * @returns The daemon result (includes event id). - * @throws {DaemonAPIError} On invalid group, missing permissions, etc. - */ - async send(options: SendOptions): Promise> { - const args: Record = { - group_id: options.groupId, - text: options.text, - by: options.by ?? 'user', - priority: options.priority ?? 'normal', - reply_required: options.replyRequired ?? false, - }; - - if (options.to) args['to'] = options.to; - if (options.path) args['path'] = options.path; - if (options.refs) args['refs'] = options.refs; - if (options.attachments) args['attachments'] = options.attachments; - if (options.clientId) args['client_id'] = options.clientId; - - return this.call('send', args); - } - - /** - * Send message across groups - */ - async sendCrossGroup(options: SendCrossGroupOptions): Promise> { - const args: Record = { - group_id: options.groupId, - dst_group_id: options.dstGroupId, - text: options.text, - by: options.by ?? 'user', - priority: options.priority ?? 'normal', - reply_required: options.replyRequired ?? false, - }; - - if (options.to) args['to'] = options.to; - if (options.refs) args['refs'] = options.refs; - if (options.attachments) args['attachments'] = options.attachments; - - return this.call('send_cross_group', args); - } - - /** - * Reply message - */ - async reply(options: ReplyOptions): Promise> { - const args: Record = { - group_id: options.groupId, - reply_to: options.replyTo, - text: options.text, - by: options.by ?? 'user', - priority: options.priority ?? 'normal', - reply_required: options.replyRequired ?? false, - }; - - if (options.to) args['to'] = options.to; - if (options.refs) args['refs'] = options.refs; - if (options.attachments) args['attachments'] = options.attachments; - if (options.clientId) args['client_id'] = options.clientId; - - return this.call('reply', args); - } - - /** - * Acknowledge chat message - */ - async chatAck( - groupId: string, - actorId: string, - eventId: string, - by?: string - ): Promise> { - return this.call('chat_ack', { - group_id: groupId, - actor_id: actorId, - event_id: eventId, - by: by ?? actorId, - }); - } - - /** - * Send a message and wait for a reply to it. - */ - async sendAndWaitForReply(options: SendAndWaitOptions): Promise { - const waitTimeout = options.waitTimeoutMs ?? 60_000; - const deadline = Date.now() + waitTimeout; - const stream = this.eventsStream({ - groupId: options.groupId, - by: options.listenAs, - kinds: ['chat.message'], - sinceTs: new Date().toISOString(), - signal: options.signal, - }); - let nextItem = stream.next(); - const sendResult = await this.send(options) as unknown as SendResult; - const sentEventId = sendResult.event.id; - - try { - while (true) { - if (options.signal?.aborted) { - throw new Error('sendAndWaitForReply aborted'); - } - if (Date.now() > deadline) { - throw new Error(`sendAndWaitForReply timed out after ${waitTimeout}ms`); - } - const { value: item, done } = await nextItem; - if (done) break; - nextItem = stream.next(); - if (isStreamEvent(item) && item.event.kind === 'chat.message') { - const data = item.event.data as Record; - if (data['reply_to'] === sentEventId) { - return item.event; - } - } - } - } finally { - await stream.return(undefined as unknown as EventStreamItem); - } - - throw new Error('sendAndWaitForReply: stream ended without reply'); - } - - // ============================================================ - // Convenience methods: inbox - // ============================================================ - - /** - * List inbox - */ - async inboxList(options: InboxListOptions): Promise> { - return this.call('inbox_list', { - group_id: options.groupId, - actor_id: options.actorId, - by: options.by ?? 'user', - limit: options.limit ?? 50, - kind_filter: options.kindFilter ?? 'all', - }); - } - - /** - * Mark message as read - */ - async inboxMarkRead( - groupId: string, - actorId: string, - eventId: string, - by = 'user' - ): Promise> { - return this.call('inbox_mark_read', { - group_id: groupId, - actor_id: actorId, - event_id: eventId, - by, - }); - } - - /** - * Mark all messages as read - */ - async inboxMarkAllRead( - groupId: string, - actorId: string, - by = 'user', - kindFilter = 'all' - ): Promise> { - return this.call('inbox_mark_all_read', { - group_id: groupId, - actor_id: actorId, - by, - kind_filter: kindFilter, - }); - } - - // ============================================================ - // Convenience methods: notifications - // ============================================================ - - /** - * Acknowledge notification - */ - async notifyAck( - groupId: string, - actorId: string, - notifyEventId: string, - by?: string - ): Promise> { - return this.call('notify_ack', { - group_id: groupId, - actor_id: actorId, - notify_event_id: notifyEventId, - by: by ?? actorId, - }); - } - // ============================================================ // Convenience methods: context // ============================================================ @@ -1276,6 +1056,8 @@ export class CCCCClient { if (options.handoffTo) args['handoff_to'] = options.handoffTo; if (options.assignee) args['assignee'] = options.assignee; if (options.refs) args['refs'] = options.refs; + if (options.insight) args['insight'] = options.insight; + if (options.requirePeerInsight !== undefined) args['require_peer_insight'] = options.requirePeerInsight; return this.call('tracked_send', args); } @@ -1691,208 +1473,6 @@ export class CCCCClient { }); } - // ============================================================ - // Convenience methods: Group Space - // ============================================================ - - /** - * Read Group Space provider and binding status. - */ - async groupSpaceStatus(options: GroupSpaceStatusOptions): Promise> { - return this.call('group_space_status', { - group_id: options.groupId, - provider: options.provider ?? 'notebooklm', - }); - } - - /** - * List available remote spaces for binding. - */ - async groupSpaceSpaces(options: GroupSpaceSpacesOptions): Promise> { - return this.call('group_space_spaces', { - group_id: options.groupId, - provider: options.provider ?? 'notebooklm', - }); - } - - /** - * Read the provider capability matrix for a group. - */ - async groupSpaceCapabilities(options: GroupSpaceCapabilitiesOptions): Promise> { - return this.call('group_space_capabilities', { - group_id: options.groupId, - provider: options.provider ?? 'notebooklm', - }); - } - - /** - * Bind or unbind one Group Space lane. - */ - async groupSpaceBind(options: GroupSpaceBindOptions): Promise> { - const args: Record = { - group_id: options.groupId, - provider: options.provider ?? 'notebooklm', - lane: options.lane, - action: options.action ?? 'bind', - by: options.by ?? 'user', - }; - if (options.remoteSpaceId) args['remote_space_id'] = options.remoteSpaceId; - return this.call('group_space_bind', args); - } - - /** - * Enqueue one Group Space ingest action. - */ - async groupSpaceIngest(options: GroupSpaceIngestOptions): Promise> { - const args: Record = { - group_id: options.groupId, - provider: options.provider ?? 'notebooklm', - lane: options.lane, - kind: options.kind ?? 'context_sync', - by: options.by ?? 'user', - }; - if (options.payload) args['payload'] = options.payload; - if (options.idempotencyKey) args['idempotency_key'] = options.idempotencyKey; - return this.call('group_space_ingest', args); - } - - /** - * Query Group Space knowledge for one lane. - */ - async groupSpaceQuery(options: GroupSpaceQueryOptions): Promise> { - const args: Record = { - group_id: options.groupId, - provider: options.provider ?? 'notebooklm', - lane: options.lane, - query: options.query, - }; - if (options.options) args['options'] = options.options; - return this.call('group_space_query', args); - } - - /** - * Manage remote sources in the bound Group Space lane. - */ - async groupSpaceSources(options: GroupSpaceSourcesOptions): Promise> { - const args: Record = { - group_id: options.groupId, - provider: options.provider ?? 'notebooklm', - lane: options.lane, - action: options.action ?? 'list', - by: options.by ?? 'user', - }; - if (options.sourceId) args['source_id'] = options.sourceId; - if (options.newTitle) args['new_title'] = options.newTitle; - return this.call('group_space_sources', args); - } - - /** - * List, generate, or download Group Space artifacts. - */ - async groupSpaceArtifact(options: GroupSpaceArtifactOptions): Promise> { - const args: Record = { - group_id: options.groupId, - provider: options.provider ?? 'notebooklm', - lane: options.lane, - action: options.action ?? 'list', - by: options.by ?? 'user', - }; - if (options.kind) args['kind'] = options.kind; - if (options.options) args['options'] = options.options; - if (options.wait !== undefined) args['wait'] = options.wait; - if (options.saveToSpace !== undefined) args['save_to_space'] = options.saveToSpace; - if (options.outputPath) args['output_path'] = options.outputPath; - if (options.outputFormat) args['output_format'] = options.outputFormat; - if (options.artifactId) args['artifact_id'] = options.artifactId; - if (options.timeoutSeconds !== undefined) args['timeout_seconds'] = options.timeoutSeconds; - if (options.initialInterval !== undefined) args['initial_interval'] = options.initialInterval; - if (options.maxInterval !== undefined) args['max_interval'] = options.maxInterval; - return this.call('group_space_artifact', args); - } - - /** - * List or manage Group Space jobs. - */ - async groupSpaceJobs(options: GroupSpaceJobsOptions): Promise> { - const args: Record = { - group_id: options.groupId, - provider: options.provider ?? 'notebooklm', - lane: options.lane, - action: options.action ?? 'list', - by: options.by ?? 'user', - }; - if (options.jobId) args['job_id'] = options.jobId; - if (options.state) args['state'] = options.state; - if (options.limit !== undefined) args['limit'] = options.limit; - return this.call('group_space_jobs', args); - } - - /** - * Read or run Group Space synchronization for one lane. - */ - async groupSpaceSync(options: GroupSpaceSyncOptions): Promise> { - return this.call('group_space_sync', { - group_id: options.groupId, - provider: options.provider ?? 'notebooklm', - lane: options.lane, - action: options.action ?? 'status', - force: options.force ?? false, - by: options.by ?? 'user', - }); - } - - /** - * Read provider credential status. - */ - async groupSpaceProviderCredentialStatus( - options: GroupSpaceProviderCredentialStatusOptions = {} - ): Promise> { - return this.call('group_space_provider_credential_status', { - provider: options.provider ?? 'notebooklm', - by: options.by ?? 'user', - }); - } - - /** - * Update provider credentials. - */ - async groupSpaceProviderCredentialUpdate( - options: GroupSpaceProviderCredentialUpdateOptions = {} - ): Promise> { - const args: Record = { - provider: options.provider ?? 'notebooklm', - by: options.by ?? 'user', - clear: options.clear ?? false, - }; - if (options.authJson) args['auth_json'] = options.authJson; - return this.call('group_space_provider_credential_update', args); - } - - /** - * Run provider health check. - */ - async groupSpaceProviderHealthCheck( - options: GroupSpaceProviderHealthCheckOptions = {} - ): Promise> { - return this.call('group_space_provider_health_check', { - provider: options.provider ?? 'notebooklm', - by: options.by ?? 'user', - }); - } - - /** - * Control provider auth flow. - */ - async groupSpaceProviderAuth(options: GroupSpaceProviderAuthOptions = {}): Promise> { - const args: Record = { - provider: options.provider ?? 'notebooklm', - action: options.action ?? 'status', - by: options.by ?? 'user', - }; - if (options.timeoutSeconds !== undefined) args['timeout_seconds'] = options.timeoutSeconds; - return this.call('group_space_provider_auth', args); - } - // ============================================================ // Event stream // ============================================================ @@ -1961,3 +1541,9 @@ export class CCCCClient { } } } + +export interface CCCCClient extends CCCC0430Ops, GroupSpaceOps, ChatOps {} + +installCCCC0430Ops(CCCCClient.prototype); +installGroupSpaceOps(CCCCClient.prototype); +installChatOps(CCCCClient.prototype); diff --git a/ts/src/client_0430_admin_ops.ts b/ts/src/client_0430_admin_ops.ts new file mode 100644 index 0000000..19888dc --- /dev/null +++ b/ts/src/client_0430_admin_ops.ts @@ -0,0 +1,182 @@ +import { + compactRecord, + type BasicGroupActorOptions, + type CCCC0430Client, + type GroupScopedOptions, +} from './client_0430_shared.js'; + +export interface CCCC0430AdminOps { + actorNewSession(options: BasicGroupActorOptions & { clearSavedSession?: boolean }): Promise>; + groupCopyExportFile(options: { groupId: string; includeBlobs?: boolean }): Promise>; + groupPreambleGet(options: { groupId: string }): Promise>; + groupPreambleSet(options: { groupId: string; content: string; by?: string }): Promise>; + groupPreambleReset(options: { groupId: string; by?: string }): Promise>; + terminalHistory(options: BasicGroupActorOptions & { + before?: number; + limitBytes?: number; + stripAnsi?: boolean; + compact?: boolean; + /** @deprecated Use limitBytes. */ + limit?: number; + /** @deprecated Use before. Numeric strings are accepted. */ + cursor?: string; + }): Promise>; + terminalSince(options: BasicGroupActorOptions & { after: number; limitBytes?: number }): Promise>; + termResize(options: BasicGroupActorOptions & { cols: number; rows: number }): Promise>; + imBindChat(options: { groupId: string; platform: string; chatId: string; threadId?: number; by?: string }): Promise>; + imListAuthorized(options?: { platform?: string }): Promise>; + imListPending(options?: { platform?: string }): Promise>; + imRejectPending(options: { platform?: string; key: string; by?: string }): Promise>; + imRevokeChat(options: { platform?: string; chatId: string; threadId?: number; by?: string }): Promise>; + remoteAccessState(options?: GroupScopedOptions): Promise>; + remoteAccessConfigure(options: GroupScopedOptions & { config: Record }): Promise>; + remoteAccessStart(options?: GroupScopedOptions): Promise>; + remoteAccessStop(options?: GroupScopedOptions): Promise>; + blueprintGenerate(options: { groupId: string; taskId: string; variant?: number }): Promise>; +} + +const adminOps: CCCC0430AdminOps & ThisType = { + async actorNewSession(options) { + return this.call('actor_new_session', compactRecord({ + group_id: options.groupId, + actor_id: options.actorId, + by: options.by ?? 'user', + clear_saved_session: options.clearSavedSession, + })); + }, + + async groupCopyExportFile(options) { + return this.call('group_copy_export_file', compactRecord({ + group_id: options.groupId, + include_blobs: options.includeBlobs, + })); + }, + + async groupPreambleGet(options) { + return this.call('group_preamble_get', { group_id: options.groupId }); + }, + + async groupPreambleSet(options) { + return this.call('group_preamble_set', { + group_id: options.groupId, + content: options.content, + by: options.by ?? 'user', + }); + }, + + async groupPreambleReset(options) { + return this.call('group_preamble_reset', { + group_id: options.groupId, + confirm: 'preamble', + by: options.by ?? 'user', + }); + }, + + async terminalHistory(options) { + const cursorBefore = options.cursor === undefined ? undefined : Number(options.cursor); + return this.call('terminal_history', compactRecord({ + group_id: options.groupId, + actor_id: options.actorId, + before: options.before ?? (Number.isSafeInteger(cursorBefore) ? cursorBefore : undefined), + limit_bytes: options.limitBytes ?? options.limit, + strip_ansi: options.stripAnsi, + compact: options.compact, + by: options.by ?? 'user', + })); + }, + + async terminalSince(options) { + return this.call('terminal_since', compactRecord({ + group_id: options.groupId, + actor_id: options.actorId, + after: options.after, + limit_bytes: options.limitBytes, + by: options.by ?? 'user', + })); + }, + + async termResize(options) { + return this.call('terminal_resize', { + group_id: options.groupId, + actor_id: options.actorId, + cols: options.cols, + rows: options.rows, + }); + }, + + async imBindChat(options) { + return this.call('im_bind_chat', compactRecord({ + group_id: options.groupId, + platform: options.platform, + chat_id: options.chatId, + thread_id: options.threadId, + by: options.by ?? 'user', + })); + }, + + async imListAuthorized(options = {}) { + return this.call('im_list_authorized', compactRecord({ platform: options.platform })); + }, + + async imListPending(options = {}) { + return this.call('im_list_pending', compactRecord({ platform: options.platform })); + }, + + async imRejectPending(options) { + return this.call('im_reject_pending', compactRecord({ + platform: options.platform, + key: options.key, + by: options.by ?? 'user', + })); + }, + + async imRevokeChat(options) { + return this.call('im_revoke_chat', compactRecord({ + platform: options.platform, + chat_id: options.chatId, + thread_id: options.threadId, + by: options.by ?? 'user', + })); + }, + + async remoteAccessState(options = {}) { + return this.call('remote_access_state', compactRecord({ + group_id: options.groupId, + by: options.by, + })); + }, + + async remoteAccessConfigure(options) { + return this.call('remote_access_configure', compactRecord({ + group_id: options.groupId, + by: options.by ?? 'user', + config: options.config, + })); + }, + + async remoteAccessStart(options = {}) { + return this.call('remote_access_start', compactRecord({ + group_id: options.groupId, + by: options.by ?? 'user', + })); + }, + + async remoteAccessStop(options = {}) { + return this.call('remote_access_stop', compactRecord({ + group_id: options.groupId, + by: options.by ?? 'user', + })); + }, + + async blueprintGenerate(options) { + return this.call('blueprint_generate', compactRecord({ + group_id: options.groupId, + task_id: options.taskId, + variant: options.variant, + })); + }, +}; + +export function installCCCC0430AdminOps(proto: CCCC0430Client & Partial): void { + Object.assign(proto, adminOps); +} diff --git a/ts/src/client_0430_assistant_ops.ts b/ts/src/client_0430_assistant_ops.ts new file mode 100644 index 0000000..d9aa2ed --- /dev/null +++ b/ts/src/client_0430_assistant_ops.ts @@ -0,0 +1,213 @@ +import { compactRecord, type CCCC0430Client } from './client_0430_shared.js'; +import { IncompatibleDaemonError } from './errors.js'; + +type VoiceSecretaryDocumentSaveOptions = { + groupId: string; + documentPath?: string; + workspacePath?: string; + title?: string; + content?: string; + status?: 'active' | 'archived'; + createNew?: boolean; + by?: string; +}; + +export interface CCCC0430AssistantOps { + assistantVoiceModelInstall(options: { groupId: string; modelId?: string; by?: string; force?: boolean }): Promise>; + /** @deprecated Rust CCCC uses the HTTP Voice Secretary transcription endpoint. */ + assistantVoiceTranscribe(options: { groupId: string; audioBase64?: string; path?: string; mimeType?: string; by?: string }): Promise>; + assistantVoiceTranscriptAppend(options: { + groupId: string; + sessionId: string; + segmentId?: string; + text?: string; + language?: string; + documentPath?: string; + isFinal?: boolean; + flush?: boolean; + trigger?: Record; + by?: string; + }): Promise>; + assistantVoiceDocumentList(options: { groupId: string; includeArchived?: boolean }): Promise>; + assistantVoiceDocumentInputRead(options: { groupId: string; by?: string }): Promise>; + assistantVoiceDocumentSave(options: VoiceSecretaryDocumentSaveOptions): Promise>; + assistantVoiceDocumentInstruction(options: { + groupId: string; + documentPath: string; + instruction?: string; + sourceText?: string; + trigger?: Record; + by?: string; + }): Promise>; + assistantVoiceDocumentArchive(options: { groupId: string; documentPath: string; by?: string }): Promise>; + assistantVoiceInputAppend(options: { + groupId: string; + requestId?: string; + voiceTranscript?: string; + composerText?: string; + operation?: string; + composerContext?: Record; + composerSnapshotHash?: string; + by?: string; + }): Promise>; + assistantVoicePromptDraftSubmit(options: { + groupId: string; + requestId: string; + draftText?: string; + noOp?: boolean; + summary?: string; + operation?: string; + composerSnapshotHash?: string; + by?: string; + }): Promise>; + assistantVoicePromptDraftAck(options: { + groupId: string; + requestId: string; + status: 'applied' | 'dismissed' | 'stale'; + }): Promise>; + assistantVoiceRequest(options: { + groupId: string; + requestText: string; + target?: string; + summary?: string; + documentPath?: string; + artifactPaths?: string[]; + sourceEventId?: string; + priority?: 'low' | 'normal' | 'high' | 'urgent'; + requiresAck?: boolean; + by?: string; + }): Promise>; +} + +const assistantOps: CCCC0430AssistantOps & ThisType = { + async assistantVoiceModelInstall(options) { + return this.call('assistant_voice_model_install', compactRecord({ + group_id: options.groupId, + model_id: options.modelId, + by: options.by ?? 'user', + force: options.force, + })); + }, + + async assistantVoiceTranscribe(options) { + void options; + throw new IncompatibleDaemonError( + 'assistant_voice_transcribe was removed from Rust daemon IPC; use the HTTP Voice Secretary transcription endpoint' + ); + }, + + async assistantVoiceTranscriptAppend(options) { + return this.call('assistant_voice_transcript_append', compactRecord({ + group_id: options.groupId, + session_id: options.sessionId, + segment_id: options.segmentId, + text: options.text, + language: options.language, + document_path: options.documentPath, + is_final: options.isFinal, + flush: options.flush, + trigger: options.trigger, + by: options.by ?? 'user', + })); + }, + + async assistantVoiceDocumentList(options) { + return this.call('assistant_voice_document_list', compactRecord({ + group_id: options.groupId, + include_archived: options.includeArchived, + })); + }, + + async assistantVoiceDocumentInputRead(options) { + return this.call('assistant_voice_document_input_read', compactRecord({ + group_id: options.groupId, + by: options.by, + })); + }, + + async assistantVoiceDocumentSave(options) { + return this.call('assistant_voice_document_save', compactRecord({ + group_id: options.groupId, + document_path: options.documentPath, + workspace_path: options.workspacePath, + title: options.title, + content: options.content, + status: options.status, + create_new: options.createNew, + by: options.by ?? 'user', + })); + }, + + async assistantVoiceDocumentInstruction(options) { + return this.call('assistant_voice_document_instruction', compactRecord({ + group_id: options.groupId, + document_path: options.documentPath, + instruction: options.instruction, + source_text: options.sourceText, + trigger: options.trigger, + by: options.by ?? 'user', + })); + }, + + async assistantVoiceDocumentArchive(options) { + return this.call('assistant_voice_document_archive', compactRecord({ + group_id: options.groupId, + document_path: options.documentPath, + by: options.by ?? 'user', + })); + }, + + async assistantVoiceInputAppend(options) { + return this.call('assistant_voice_input_append', compactRecord({ + group_id: options.groupId, + kind: 'prompt_refine', + request_id: options.requestId, + voice_transcript: options.voiceTranscript, + composer_text: options.composerText, + operation: options.operation, + composer_context: options.composerContext, + composer_snapshot_hash: options.composerSnapshotHash, + by: options.by ?? 'user', + })); + }, + + async assistantVoicePromptDraftSubmit(options) { + return this.call('assistant_voice_prompt_draft_submit', compactRecord({ + group_id: options.groupId, + request_id: options.requestId, + draft_text: options.draftText, + no_op: options.noOp, + summary: options.summary, + operation: options.operation, + composer_snapshot_hash: options.composerSnapshotHash, + by: options.by ?? 'voice-secretary', + })); + }, + + async assistantVoicePromptDraftAck(options) { + return this.call('assistant_voice_prompt_draft_ack', { + group_id: options.groupId, + request_id: options.requestId, + status: options.status, + }); + }, + + async assistantVoiceRequest(options) { + return this.call('assistant_voice_request', compactRecord({ + group_id: options.groupId, + request_text: options.requestText, + target: options.target, + summary: options.summary, + document_path: options.documentPath, + artifact_paths: options.artifactPaths, + source_event_id: options.sourceEventId, + priority: options.priority, + requires_ack: options.requiresAck, + by: options.by ?? 'voice-secretary', + })); + }, +}; + +export function installCCCC0430AssistantOps(proto: CCCC0430Client & Partial): void { + Object.assign(proto, assistantOps); +} diff --git a/ts/src/client_0430_memory_ops.ts b/ts/src/client_0430_memory_ops.ts new file mode 100644 index 0000000..d9d8810 --- /dev/null +++ b/ts/src/client_0430_memory_ops.ts @@ -0,0 +1,128 @@ +import { compactRecord, type CCCC0430Client, type GroupScopedOptions } from './client_0430_shared.js'; + +type MemoryRemeReadOptions = { + groupId?: string; + actorId?: string; + path?: string; + target?: string; + date?: string; + offset?: number; + limit?: number; +}; + +export interface CCCC0430MemoryOps { + memoryRemeLayoutGet(options?: GroupScopedOptions): Promise>; + memoryRemeSearch(options: { + query: string; + groupId?: string; + actorId?: string; + limit?: number; + maxResults?: number; + tags?: string[]; + target?: string; + vectorWeight?: number; + }): Promise>; + memoryRemeGet(options: MemoryRemeReadOptions): Promise>; + memoryRemeWrite(options: { + target: string; + content: string; + groupId?: string; + actorId?: string; + tags?: string[]; + sourceRefs?: string[]; + idempotencyKey?: string; + dedupIntent?: string; + dedupQuery?: string; + date?: string; + }): Promise>; + memoryRemeIndexSync(options?: GroupScopedOptions & { force?: boolean }): Promise>; + memoryRemeContextCheck(options: GroupScopedOptions & { messages: Array> }): Promise>; + memoryRemeCompact(options: GroupScopedOptions & { messages: Array>; returnPrompt?: boolean }): Promise>; + memoryRemeDailyFlush(options?: GroupScopedOptions & { date?: string }): Promise>; +} + +const memoryOps: CCCC0430MemoryOps & ThisType = { + async memoryRemeLayoutGet(options = {}) { + return this.call('memory_reme_layout_get', compactRecord({ + group_id: options.groupId, + by: options.by, + })); + }, + + async memoryRemeSearch(options) { + return this.call('memory_reme_search', compactRecord({ + group_id: options.groupId, + actor_id: options.actorId, + query: options.query, + limit: options.limit, + max_results: options.maxResults, + tags: options.tags, + target: options.target, + vector_weight: options.vectorWeight, + })); + }, + + async memoryRemeGet(options) { + return this.call('memory_reme_get', compactRecord({ + group_id: options.groupId, + actor_id: options.actorId, + path: options.path, + target: options.target, + date: options.date, + offset: options.offset, + limit: options.limit, + })); + }, + + async memoryRemeWrite(options) { + return this.call('memory_reme_write', compactRecord({ + group_id: options.groupId, + actor_id: options.actorId, + target: options.target, + content: options.content, + tags: options.tags, + source_refs: options.sourceRefs, + idempotency_key: options.idempotencyKey, + dedup_intent: options.dedupIntent, + dedup_query: options.dedupQuery, + date: options.date, + })); + }, + + async memoryRemeIndexSync(options = {}) { + return this.call('memory_reme_index_sync', compactRecord({ + group_id: options.groupId, + by: options.by, + force: options.force, + })); + }, + + async memoryRemeContextCheck(options) { + return this.call('memory_reme_context_check', compactRecord({ + group_id: options.groupId, + by: options.by, + messages: options.messages, + })); + }, + + async memoryRemeCompact(options) { + return this.call('memory_reme_compact', compactRecord({ + group_id: options.groupId, + by: options.by, + messages: options.messages, + return_prompt: options.returnPrompt, + })); + }, + + async memoryRemeDailyFlush(options = {}) { + return this.call('memory_reme_daily_flush', compactRecord({ + group_id: options.groupId, + by: options.by, + date: options.date, + })); + }, +}; + +export function installCCCC0430MemoryOps(proto: CCCC0430Client & Partial): void { + Object.assign(proto, memoryOps); +} diff --git a/ts/src/client_0430_ops.ts b/ts/src/client_0430_ops.ts new file mode 100644 index 0000000..23f883c --- /dev/null +++ b/ts/src/client_0430_ops.ts @@ -0,0 +1,12 @@ +import { installCCCC0430AdminOps, type CCCC0430AdminOps } from './client_0430_admin_ops.js'; +import { installCCCC0430AssistantOps, type CCCC0430AssistantOps } from './client_0430_assistant_ops.js'; +import { installCCCC0430MemoryOps, type CCCC0430MemoryOps } from './client_0430_memory_ops.js'; +import type { CCCC0430Client } from './client_0430_shared.js'; + +export interface CCCC0430Ops extends CCCC0430AdminOps, CCCC0430AssistantOps, CCCC0430MemoryOps {} + +export function installCCCC0430Ops(proto: CCCC0430Client & Partial): void { + installCCCC0430AdminOps(proto); + installCCCC0430AssistantOps(proto); + installCCCC0430MemoryOps(proto); +} diff --git a/ts/src/client_0430_shared.ts b/ts/src/client_0430_shared.ts new file mode 100644 index 0000000..c6b6801 --- /dev/null +++ b/ts/src/client_0430_shared.ts @@ -0,0 +1,20 @@ +export type ClientCall = (op: string, args?: Record) => Promise>; + +export type CCCC0430Client = { + call: ClientCall; +}; + +export type GroupScopedOptions = { + groupId?: string; + by?: string; +}; + +export type BasicGroupActorOptions = { + groupId: string; + actorId: string; + by?: string; +}; + +export function compactRecord(input: Record): Record { + return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)); +} diff --git a/ts/src/client_chat_ops.ts b/ts/src/client_chat_ops.ts new file mode 100644 index 0000000..70e895a --- /dev/null +++ b/ts/src/client_chat_ops.ts @@ -0,0 +1,195 @@ +import type { + CCCSEvent, + EventStreamItem, + InboxListOptions, + ReplyOptions, + SendAndWaitOptions, + SendCrossGroupOptions, + SendOptions, + SendResult, +} from './types.js'; +import { isStreamEvent } from './types.js'; + +type ClientCall = (op: string, args?: Record) => Promise>; + +type ChatClient = { + call: ClientCall; + callRaw: (op: string, args?: Record) => Promise; + eventsStream(options: { + groupId: string; + by?: string; + kinds?: string[]; + sinceTs?: string; + signal?: AbortSignal; + }): AsyncGenerator; +}; + +export interface ChatOps { + send(options: SendOptions): Promise>; + sendCrossGroup(options: SendCrossGroupOptions): Promise>; + reply(options: ReplyOptions): Promise>; + chatAck(groupId: string, actorId: string, eventId: string, by?: string): Promise>; + sendAndWaitForReply(options: SendAndWaitOptions): Promise; + inboxList(options: InboxListOptions): Promise>; + inboxMarkRead(groupId: string, actorId: string, eventId: string, by?: string): Promise>; + inboxMarkAllRead(groupId: string, actorId: string, by?: string, kindFilter?: string): Promise>; + notifyAck(groupId: string, actorId: string, notifyEventId: string, by?: string): Promise>; +} + +const chatOps: ChatOps & ThisType = { + async send(options) { + const args: Record = { + group_id: options.groupId, + text: options.text, + by: options.by ?? 'user', + priority: options.priority ?? 'normal', + reply_required: options.replyRequired ?? false, + }; + + if (options.to) args['to'] = options.to; + if (options.path) args['path'] = options.path; + if (options.refs) args['refs'] = options.refs; + if (options.attachments) args['attachments'] = options.attachments; + if (options.clientId) args['client_id'] = options.clientId; + if (options.suggestedUserMessage) args['suggested_user_message'] = options.suggestedUserMessage; + if (options.insight) args['insight'] = options.insight; + if (options.requirePeerInsight !== undefined) args['require_peer_insight'] = options.requirePeerInsight; + + return this.call('send', args); + }, + + async sendCrossGroup(options) { + const args: Record = { + group_id: options.groupId, + dst_group_id: options.dstGroupId, + text: options.text, + by: options.by ?? 'user', + priority: options.priority ?? 'normal', + reply_required: options.replyRequired ?? false, + }; + + if (options.to) args['to'] = options.to; + if (options.refs) args['refs'] = options.refs; + if (options.attachments) args['attachments'] = options.attachments; + if (options.insight) args['insight'] = options.insight; + if (options.requirePeerInsight !== undefined) args['require_peer_insight'] = options.requirePeerInsight; + + return this.call('send_cross_group', args); + }, + + async reply(options) { + const args: Record = { + group_id: options.groupId, + reply_to: options.replyTo, + text: options.text, + by: options.by ?? 'user', + priority: options.priority ?? 'normal', + reply_required: options.replyRequired ?? false, + }; + + if (options.to) args['to'] = options.to; + if (options.refs) args['refs'] = options.refs; + if (options.attachments) args['attachments'] = options.attachments; + if (options.clientId) args['client_id'] = options.clientId; + if (options.suggestedUserMessage) args['suggested_user_message'] = options.suggestedUserMessage; + if (options.insight) args['insight'] = options.insight; + if (options.requirePeerInsight !== undefined) args['require_peer_insight'] = options.requirePeerInsight; + + return this.call('reply', args); + }, + + async chatAck(groupId, actorId, eventId, by) { + return this.call('chat_ack', { + group_id: groupId, + actor_id: actorId, + event_id: eventId, + by: by ?? actorId, + }); + }, + + async sendAndWaitForReply(options) { + // Probe the real streaming upgrade before creating the message side effect. + // Some daemon builds have advertised events_stream without dispatching it. + await this.callRaw('events_stream', { + group_id: options.groupId, + by: options.listenAs, + }); + const waitTimeout = options.waitTimeoutMs ?? 60_000; + const deadline = Date.now() + waitTimeout; + const stream = this.eventsStream({ + groupId: options.groupId, + by: options.listenAs, + kinds: ['chat.message'], + sinceTs: new Date().toISOString(), + signal: options.signal, + }); + let nextItem = stream.next(); + const sendResult = await this.send(options) as unknown as SendResult; + const sentEventId = sendResult.event.id; + + try { + while (true) { + if (options.signal?.aborted) { + throw new Error('sendAndWaitForReply aborted'); + } + if (Date.now() > deadline) { + throw new Error(`sendAndWaitForReply timed out after ${waitTimeout}ms`); + } + const { value: item, done } = await nextItem; + if (done) break; + nextItem = stream.next(); + if (isStreamEvent(item) && item.event.kind === 'chat.message') { + const data = item.event.data as Record; + if (data['reply_to'] === sentEventId) { + return item.event; + } + } + } + } finally { + await stream.return(undefined as unknown as EventStreamItem); + } + + throw new Error('sendAndWaitForReply: stream ended without reply'); + }, + + async inboxList(options) { + return this.call('inbox_list', { + group_id: options.groupId, + actor_id: options.actorId, + by: options.by ?? 'user', + limit: options.limit ?? 50, + kind_filter: options.kindFilter ?? 'all', + }); + }, + + async inboxMarkRead(groupId, actorId, eventId, by = 'user') { + return this.call('inbox_mark_read', { + group_id: groupId, + actor_id: actorId, + event_id: eventId, + by, + }); + }, + + async inboxMarkAllRead(groupId, actorId, by = 'user', kindFilter = 'all') { + return this.call('inbox_mark_all_read', { + group_id: groupId, + actor_id: actorId, + by, + kind_filter: kindFilter, + }); + }, + + async notifyAck(groupId, actorId, notifyEventId, by) { + return this.call('notify_ack', { + group_id: groupId, + actor_id: actorId, + notify_event_id: notifyEventId, + by: by ?? actorId, + }); + }, +}; + +export function installChatOps(proto: ChatClient & Partial): void { + Object.assign(proto, chatOps); +} diff --git a/ts/src/client_group_space_ops.ts b/ts/src/client_group_space_ops.ts new file mode 100644 index 0000000..3692f14 --- /dev/null +++ b/ts/src/client_group_space_ops.ts @@ -0,0 +1,195 @@ +import type { + GroupSpaceArtifactOptions, + GroupSpaceBindOptions, + GroupSpaceCapabilitiesOptions, + GroupSpaceIngestOptions, + GroupSpaceJobsOptions, + GroupSpaceProviderAuthOptions, + GroupSpaceProviderCredentialStatusOptions, + GroupSpaceProviderCredentialUpdateOptions, + GroupSpaceProviderHealthCheckOptions, + GroupSpaceQueryOptions, + GroupSpaceSourcesOptions, + GroupSpaceSpacesOptions, + GroupSpaceStatusOptions, + GroupSpaceSyncOptions, +} from './types.js'; + +type ClientCall = (op: string, args?: Record) => Promise>; + +type GroupSpaceClient = { + call: ClientCall; +}; + +export interface GroupSpaceOps { + groupSpaceStatus(options: GroupSpaceStatusOptions): Promise>; + groupSpaceSpaces(options: GroupSpaceSpacesOptions): Promise>; + groupSpaceCapabilities(options: GroupSpaceCapabilitiesOptions): Promise>; + groupSpaceBind(options: GroupSpaceBindOptions): Promise>; + groupSpaceIngest(options: GroupSpaceIngestOptions): Promise>; + groupSpaceQuery(options: GroupSpaceQueryOptions): Promise>; + groupSpaceSources(options: GroupSpaceSourcesOptions): Promise>; + groupSpaceArtifact(options: GroupSpaceArtifactOptions): Promise>; + groupSpaceJobs(options: GroupSpaceJobsOptions): Promise>; + groupSpaceSync(options: GroupSpaceSyncOptions): Promise>; + groupSpaceProviderCredentialStatus(options?: GroupSpaceProviderCredentialStatusOptions): Promise>; + groupSpaceProviderCredentialUpdate(options?: GroupSpaceProviderCredentialUpdateOptions): Promise>; + groupSpaceProviderHealthCheck(options?: GroupSpaceProviderHealthCheckOptions): Promise>; + groupSpaceProviderAuth(options?: GroupSpaceProviderAuthOptions): Promise>; +} + +const groupSpaceOps: GroupSpaceOps & ThisType = { + async groupSpaceStatus(options) { + return this.call('group_space_status', { + group_id: options.groupId, + provider: options.provider ?? 'notebooklm', + }); + }, + + async groupSpaceSpaces(options) { + return this.call('group_space_spaces', { + group_id: options.groupId, + provider: options.provider ?? 'notebooklm', + }); + }, + + async groupSpaceCapabilities(options) { + return this.call('group_space_capabilities', { + group_id: options.groupId, + provider: options.provider ?? 'notebooklm', + }); + }, + + async groupSpaceBind(options) { + const args: Record = { + group_id: options.groupId, + provider: options.provider ?? 'notebooklm', + lane: options.lane, + action: options.action ?? 'bind', + by: options.by ?? 'user', + }; + if (options.remoteSpaceId) args['remote_space_id'] = options.remoteSpaceId; + return this.call('group_space_bind', args); + }, + + async groupSpaceIngest(options) { + const args: Record = { + group_id: options.groupId, + provider: options.provider ?? 'notebooklm', + lane: options.lane, + kind: options.kind ?? 'context_sync', + by: options.by ?? 'user', + }; + if (options.payload) args['payload'] = options.payload; + if (options.idempotencyKey) args['idempotency_key'] = options.idempotencyKey; + return this.call('group_space_ingest', args); + }, + + async groupSpaceQuery(options) { + const args: Record = { + group_id: options.groupId, + provider: options.provider ?? 'notebooklm', + lane: options.lane, + query: options.query, + }; + if (options.options) args['options'] = options.options; + return this.call('group_space_query', args); + }, + + async groupSpaceSources(options) { + const args: Record = { + group_id: options.groupId, + provider: options.provider ?? 'notebooklm', + lane: options.lane, + action: options.action ?? 'list', + by: options.by ?? 'user', + }; + if (options.sourceId) args['source_id'] = options.sourceId; + if (options.newTitle) args['new_title'] = options.newTitle; + return this.call('group_space_sources', args); + }, + + async groupSpaceArtifact(options) { + const args: Record = { + group_id: options.groupId, + provider: options.provider ?? 'notebooklm', + lane: options.lane, + action: options.action ?? 'list', + by: options.by ?? 'user', + }; + if (options.kind) args['kind'] = options.kind; + if (options.options) args['options'] = options.options; + if (options.wait !== undefined) args['wait'] = options.wait; + if (options.saveToSpace !== undefined) args['save_to_space'] = options.saveToSpace; + if (options.outputPath) args['output_path'] = options.outputPath; + if (options.outputFormat) args['output_format'] = options.outputFormat; + if (options.artifactId) args['artifact_id'] = options.artifactId; + if (options.timeoutSeconds !== undefined) args['timeout_seconds'] = options.timeoutSeconds; + if (options.initialInterval !== undefined) args['initial_interval'] = options.initialInterval; + if (options.maxInterval !== undefined) args['max_interval'] = options.maxInterval; + return this.call('group_space_artifact', args); + }, + + async groupSpaceJobs(options) { + const args: Record = { + group_id: options.groupId, + provider: options.provider ?? 'notebooklm', + lane: options.lane, + action: options.action ?? 'list', + by: options.by ?? 'user', + }; + if (options.jobId) args['job_id'] = options.jobId; + if (options.state) args['state'] = options.state; + if (options.limit !== undefined) args['limit'] = options.limit; + return this.call('group_space_jobs', args); + }, + + async groupSpaceSync(options) { + return this.call('group_space_sync', { + group_id: options.groupId, + provider: options.provider ?? 'notebooklm', + lane: options.lane, + action: options.action ?? 'status', + force: options.force ?? false, + by: options.by ?? 'user', + }); + }, + + async groupSpaceProviderCredentialStatus(options = {}) { + return this.call('group_space_provider_credential_status', { + provider: options.provider ?? 'notebooklm', + by: options.by ?? 'user', + }); + }, + + async groupSpaceProviderCredentialUpdate(options = {}) { + const args: Record = { + provider: options.provider ?? 'notebooklm', + by: options.by ?? 'user', + clear: options.clear ?? false, + }; + if (options.authJson) args['auth_json'] = options.authJson; + return this.call('group_space_provider_credential_update', args); + }, + + async groupSpaceProviderHealthCheck(options = {}) { + return this.call('group_space_provider_health_check', { + provider: options.provider ?? 'notebooklm', + by: options.by ?? 'user', + }); + }, + + async groupSpaceProviderAuth(options = {}) { + const args: Record = { + provider: options.provider ?? 'notebooklm', + action: options.action ?? 'status', + by: options.by ?? 'user', + }; + if (options.timeoutSeconds !== undefined) args['timeout_seconds'] = options.timeoutSeconds; + return this.call('group_space_provider_auth', args); + }, +}; + +export function installGroupSpaceOps(proto: GroupSpaceClient & Partial): void { + Object.assign(proto, groupSpaceOps); +} diff --git a/ts/src/types.ts b/ts/src/types.ts index 137b77f..9b8d812 100644 --- a/ts/src/types.ts +++ b/ts/src/types.ts @@ -99,6 +99,7 @@ export interface CCCSEvent { export interface ChatMessageEventData { text: string; format?: string; + insight?: string | null; priority?: 'normal' | 'attention'; reply_required?: boolean; to?: string[]; @@ -220,6 +221,9 @@ export interface SendOptions { refs?: MessageRef[]; attachments?: MessageAttachment[]; clientId?: string; + suggestedUserMessage?: string; + insight?: string; + requirePeerInsight?: boolean; } /** Send-cross-group options */ @@ -233,6 +237,8 @@ export interface SendCrossGroupOptions { replyRequired?: boolean; refs?: MessageRef[]; attachments?: MessageAttachment[]; + insight?: string; + requirePeerInsight?: boolean; } /** Reply message options */ @@ -247,6 +253,9 @@ export interface ReplyOptions { refs?: MessageRef[]; attachments?: MessageAttachment[]; clientId?: string; + suggestedUserMessage?: string; + insight?: string; + requirePeerInsight?: boolean; } /** @@ -282,6 +291,8 @@ export interface TrackedSendOptions { handoffTo?: string; assignee?: string; refs?: MessageRef[]; + insight?: string; + requirePeerInsight?: boolean; } /** Task list (returns either all tasks or one task plus its children). */