diff --git a/.gitignore b/.gitignore index 38119aa..8921fee 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,9 @@ npm-debug.log* .vscode/ .idea/ +# Local AI / project-harness tooling +.claude/ +.codex/ +.project-harness/ +.ts-agent/ +docs/project-harness*.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 802e5f2..edf7976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,33 @@ `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] — Unreleased + +### Added + +- Current Rust-daemon wrappers for group preamble, terminal cursor/resize, + Voice Secretary documents and prompts, low-level ReMe maintenance, Web Model + turns, IM bridge management, and Remote Access administration. +- `require_peer_insight` / `requirePeerInsight` support on chat workflows and + profile-marker support on actor profile operations. + +### Changed + +- `assert_compatible` / `assertCompatible` now probes `events_stream` as a real + operation instead of trusting the advertised capability flag. TypeScript + streaming also probes before opening the long-lived socket. +- `term_resize` / `termResize` maps to the Rust daemon's current + `terminal_resize` operation; terminal history also exposes `terminal_since`. +- The removed voice transcription IPC helper now fails locally with migration + guidance to the supported HTTP Voice Secretary endpoint. +- Refactored large client implementations into focused operation-family + mixins without changing the public client entry points. + +### Tests + +- Added Python and TypeScript contract coverage for the 0.4.33 operation and + argument mappings, compatibility probing, and removed-operation behavior. + ## [0.4.32] — Unreleased ### Added diff --git a/README.ja.md b/README.ja.md index 7872b7a..99f93ed 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**。`main` の -> ソースパッケージは CCCC 0.4.32 を対象とします。公開は別の release 手順です。 +> ソースパッケージは CCCC 0.4.33 を対象とします。公開は別の release 手順です。 > 範囲は `CHANGELOG.md` と `spec/ADAPTATION_PLAN.md` を参照してください。 CCCC SDK は CCCC プラットフォーム向けの **クライアント SDK** です。 @@ -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", "send", "reply", "tracked_send", "context_sync"], 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 c04acee..47bfb1e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **English** | [中文](README.zh-CN.md) | [日本語](README.ja.md) > Status: **contract-first SDK for CCCC daemon IPC v1**. Source packages on -> `main` target CCCC 0.4.32; publishing remains a separate release step. See +> `main` targets CCCC 0.4.33; publishing remains a separate release step. See > `CHANGELOG.md` and `spec/ADAPTATION_PLAN.md` for exact scope. CCCC SDK provides **client SDKs** for building applications on top of the CCCC platform. @@ -65,7 +65,7 @@ from cccc_sdk import CCCCClient c = CCCCClient() c.assert_compatible( require_ipc_v=1, - require_capabilities={"events_stream": True}, + require_ops=["groups", "send", "reply", "tracked_send", "context_sync"], 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 a52c526..3535fc1 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**。`main` 上的源码包面向 -> CCCC 0.4.32;发布仍是独立的 release 步骤。具体范围见 `CHANGELOG.md` +> 当前源码包面向 CCCC 0.4.33;发布仍是独立的 release 步骤。具体范围见 `CHANGELOG.md` > 与 `spec/ADAPTATION_PLAN.md`。 CCCC SDK 是一套用于 CCCC 平台的**客户端 SDK**。 @@ -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", "send", "reply", "tracked_send", "context_sync"], 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 6a73dc9..6a09657 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -6,8 +6,8 @@ This repo is a monorepo with two deliverables: ## Versioning policy -- SDK version tracks the supported CCCC line: currently `0.4.32`. -- RC sequence is SDK-owned (`0.4.32rcN` for Python, `0.4.32-rc.N` for npm). +- SDK version tracks the supported CCCC line: currently `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) @@ -39,8 +39,8 @@ Edit `python/pyproject.toml` (`project.version`). ### Publish RC to TestPyPI ```bash -git tag v0.4.32rcN -git push origin v0.4.32rcN +git tag v0.4.33rcN +git push origin v0.4.33rcN ``` This triggers `.github/workflows/python-publish-testpypi.yml`. @@ -50,14 +50,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.32rcN + cccc-sdk==0.4.33rcN ``` ### Publish stable to PyPI ```bash -git tag v0.4.32 -git push origin v0.4.32 +git tag v0.4.33 +git push origin v0.4.33 ``` This triggers `.github/workflows/python-publish.yml`. @@ -69,8 +69,8 @@ This triggers `.github/workflows/python-publish.yml`. Edit `ts/package.json` (`version`). Examples: -- RC: `0.4.32-rc.N` -- Stable: `0.4.32` +- RC: `0.4.33-rc.N` +- Stable: `0.4.33` ### Local checks diff --git a/python/README.md b/python/README.md index a4642eb..7b76954 100644 --- a/python/README.md +++ b/python/README.md @@ -58,7 +58,10 @@ 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", "send", "reply", "tracked_send", "context_sync"], +) groups = c.groups() print(groups) @@ -192,7 +195,7 @@ See `spec/SDK_LOCAL_MEMORY_API.md` in the repository root. If you need an op that does not have a dedicated helper yet, use `call()` / `call_raw()`. -## CCCC 0.4.32 compatibility delta +## CCCC 0.4.33 compatibility delta ```python # Deliberately rotate provider session metadata for Claude/Codex/Grok PTY. @@ -210,8 +213,16 @@ page = c.terminal_history( exported = c.group_copy_export_file(group_id="g_xxx") preview = c.group_copy_preview_import(package_path=exported["package_path"]) copied = c.group_copy_import(package_path=exported["package_path"]) + +# Current Rust-daemon administration and terminal operations. +preamble = c.group_preamble_get(group_id="g_xxx") +recent = c.terminal_since(group_id="g_xxx", actor_id="reviewer", cursor=0) +c.term_resize(group_id="g_xxx", actor_id="reviewer", cols=120, rows=40) ``` +`events_stream` compatibility is verified by probing the operation itself; +the SDK does not rely only on the daemon's advertised capability flag. + `group_reset` is destructive: it creates a clean replacement and removes the old group after copying selected configuration. The explicit confirmation must equal the source group id: diff --git a/python/examples/compat_check.py b/python/examples/compat_check.py index 88d6239..5370325 100644 --- a/python/examples/compat_check.py +++ b/python/examples/compat_check.py @@ -9,7 +9,6 @@ def main() -> int: c = CCCCClient() info = c.assert_compatible( require_ipc_v=1, - require_capabilities={"events_stream": True}, require_ops=[ "groups", "group_show", @@ -32,6 +31,9 @@ def main() -> int: "group_reset", "group_copy_export_file", "terminal_history", + "terminal_since", + "terminal_resize", + "group_preamble_get", ], ) print(json.dumps({"ok": True, "daemon": info}, ensure_ascii=False, indent=2)) diff --git a/python/pyproject.toml b/python/pyproject.toml index 59c99ba..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.32" +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 4a22833..0bb4bc5 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", @@ -802,450 +804,6 @@ def memory_profile_get( ), ) - def memory_reme_search( - self, - *, - group_id: str, - query: str, - actor_id: str = "", - limit: Optional[int] = None, - max_results: Optional[int] = None, - vector_weight: Optional[float] = None, - candidate_multiplier: Optional[float] = None, - min_score: Optional[float] = None, - sources: Optional[List[str]] = None, - ) -> Dict[str, Any]: - """Call the lower-level ReMe search operation explicitly.""" - args: Dict[str, Any] = {"group_id": str(group_id), "query": str(query)} - if actor_id: - args["actor_id"] = str(actor_id) - if max_results is not None or limit is not None: - args["max_results"] = int(max_results if max_results is not None else limit) - if vector_weight is not None: - args["vector_weight"] = float(vector_weight) - if candidate_multiplier is not None: - args["candidate_multiplier"] = float(candidate_multiplier) - if min_score is not None: - args["min_score"] = float(min_score) - if sources is not None: - args["sources"] = [str(x) for x in sources] - return self.call("memory_reme_search", args) - - def memory_reme_get( - self, - *, - group_id: str, - path: str, - actor_id: str = "", - offset: Optional[int] = None, - limit: Optional[int] = None, - ) -> Dict[str, Any]: - """Call the lower-level ReMe file-slice operation explicitly.""" - args: Dict[str, Any] = {"group_id": str(group_id), "path": str(path)} - if actor_id: - args["actor_id"] = str(actor_id) - if offset is not None: - args["offset"] = int(offset) - if limit is not None: - args["limit"] = int(limit) - return self.call("memory_reme_get", args) - - 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, - insight: 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 insight: - args["insight"] = str(insight) - 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, - insight: str = "", - suggested_user_message: 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 insight: - args["insight"] = str(insight) - if suggested_user_message: - args["suggested_user_message"] = str(suggested_user_message) - 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, - insight: str = "", - suggested_user_message: 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 insight: - args["insight"] = str(insight) - if suggested_user_message: - args["suggested_user_message"] = str(suggested_user_message) - 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)}) @@ -1471,6 +1029,7 @@ def tracked_send( handoff_to: str = "", assignee: str = "", refs: Optional[List[Dict[str, Any]]] = None, + require_peer_insight: Optional[bool] = None, ) -> Dict[str, Any]: """Atomically create a tracked task and send the linked chat message.""" args: Dict[str, Any] = { @@ -1515,6 +1074,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..1dd1326 --- /dev/null +++ b/python/src/cccc_sdk/client_0430_memory_ops.py @@ -0,0 +1,131 @@ +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, + candidate_multiplier: Optional[int] = None, + min_score: Optional[float] = None, + sources: Optional[List[str]] = None, + ) -> Dict[str, Any]: + return self.call( + "memory_reme_search", + _compact( + { + "group_id": group_id, + "actor_id": actor_id, + "query": str(query), + "max_results": int(max_results if max_results is not None else limit) + if max_results is not None or limit 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, + "candidate_multiplier": int(candidate_multiplier) if candidate_multiplier is not None else None, + "min_score": float(min_score) if min_score is not None else None, + "sources": [str(x) for x in sources] if sources 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 831f59c..e7f2702 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 44d800f..fe29082 100644 --- a/spec/ADAPTATION_PLAN.md +++ b/spec/ADAPTATION_PLAN.md @@ -1,7 +1,7 @@ # CCCC SDK — Adaptation Plan -This plan is based on an operation-by-operation audit of CCCC v0.4.18 versus -v0.4.32 performed on 2026-07-19. The SDK source packages now target v0.4.32; +This plan is based on operation-by-operation audits through CCCC v0.4.33, +completed on 2026-08-03. The SDK source packages now target v0.4.33; the work remains unreleased until the normal package release process is run. The goal is contract alignment, not one wrapper per daemon implementation @@ -10,7 +10,40 @@ external application can use safely. Internal relay operations and operator-only mechanisms stay out of the default client unless a real SDK consumer establishes a durable contract for them. -## v0.4.32 alignment status +## v0.4.33 alignment status + +The v0.4.33 pass preserves the complete v0.4.32 public surface and adds the +current Rust-daemon contracts for: + +``` +group_preamble_get / group_preamble_set / group_preamble_reset +terminal_since / terminal_resize +assistant_voice_* document, input, prompt, and request workflows +memory_reme_* maintenance controls +web_model_runtime_wait_next_turn / web_model_runtime_complete_turn +im_* management / remote_access_* administration +``` + +Chat helpers now map `require_peer_insight`, actor profiles map the current +profile marker, and compatibility checks probe `events_stream` itself rather +than trusting a capability advertisement. The old `assistant_voice_transcribe` +IPC operation is deliberately rejected locally with migration guidance because +the Rust daemon moved transcription to its supported HTTP workflow. + +Client implementations are split into operation-family mixins to keep each +module focused while preserving the existing public client classes. + +### Validation evidence (2026-08-03) + +- Python: 52 tests passed; source compilation and wheel build succeeded. +- TypeScript: 89 tests passed; typecheck, build, and npm package dry-run + succeeded. +- A live Rust 0.4.33 daemon accepted the current group-preamble and terminal + operations. It advertised `events_stream` but returned `unknown_op` to an + operation probe; the SDK now detects and reports that mismatch before a + message workflow begins. + +## v0.4.32 baseline retained Compared with v0.4.18, the daemon added 13 regular request/response operations and removed the three PET decision operations. @@ -52,7 +85,7 @@ methods: This distinction matters: matching implementation op counts is not the same as maintaining a coherent public API. -### Validation evidence (2026-07-19) +### Baseline validation evidence (2026-07-19) - Python: 48 tests passed; sdist and wheel built successfully. - TypeScript: 84 tests passed; typecheck and package build succeeded. @@ -68,62 +101,9 @@ These are older gaps rather than regressions introduced by v0.4.32. They should be added only when a concrete external SDK use case justifies their contract and support burden. -### 1. Voice Secretary / Assistant Voice (18 ops) +### 1. ChatGPT Web Model browser lifecycle (5 ops) ``` -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 -``` - -These operations are mostly request/response, but they form a product workflow -rather than a bag of independent calls. Add them only alongside a consumer -flow, typed document/transcript states, and end-to-end workflow tests. If the -dedicated MCP/workflow interface remains the supported integration boundary, -duplicating it in the general SDK would add maintenance without reducing -consumer complexity. - -### 2. Low-level ReMe controls (6 ops) - -The first-class local-memory API is complete for normal callers. The SDK keeps -explicit `memory_reme_search` and `memory_reme_get` compatibility wrappers for -advanced source controls and raw ReMe response shapes. The remaining daemon -controls are: - -``` -memory_reme_layout_get -memory_reme_write -memory_reme_index_sync -memory_reme_context_check -memory_reme_compact -memory_reme_daily_flush -``` - -These expose storage and maintenance policy. They should not become the normal -memory API. Add individual methods only for an operator or diagnostics client -that needs them, with the distinction from first-class memory documented in -`SDK_LOCAL_MEMORY_API.md`. - -### 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 @@ -131,34 +111,14 @@ web_model_browser_attach # streaming web_model_browser_vnc_attach # streaming ``` -The two turn operations may suit an automation client. Browser lifecycle and -attach operations are operator-facing and should be designed with the client -that will actually consume them. - -### 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 -``` - -These belong to an administrative control plane. Keep them out of the main -surface until an SDK-based operator client needs them; at that point, group -them under an explicit admin API rather than mixing them into everyday chat -and actor workflows. +The runtime turn operations are wrapped. Browser lifecycle and attach +operations remain operator-facing and should be designed with the client that +will actually consume them. -### 5. Socket-special and terminal operations +### 2. Socket-special operations ``` term_attach -term_resize presentation_browser_attach presentation_browser_vnc_attach web_model_browser_attach @@ -167,7 +127,8 @@ space_provider_auth_browser_attach space_provider_auth_browser_vnc_attach ``` -`term_resize` is regular request/response. The attach operations switch from a +Terminal resize is wrapped through the daemon's `terminal_resize` operation. +The attach operations switch from a JSON request/response exchange to a duplex byte stream after the handshake. They require a deliberate transport abstraction, ownership and close semantics, backpressure behavior, and binary-stream tests in both languages. @@ -178,7 +139,7 @@ smallest transport contract that supports its lifecycle correctly. ## Recommended next iterations -1. **Finalize v0.4.32 alignment.** Review the validated diff as a public API +1. **Finalize v0.4.33 alignment.** Review the validated diff as a public API change, then commit only after explicit approval. Tagging and publishing remain separate release actions. 2. **Add drift detection.** Turn the operation/type comparison used for this diff --git a/spec/CCCC_DAEMON_IPC_V1.md b/spec/CCCC_DAEMON_IPC_V1.md index 9d5b17c..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,8 +1430,9 @@ 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 @@ -1410,8 +1466,10 @@ Result: 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 -installed sherpa-onnx version and whether a newer official PyPI release is -available. Voice model records may include `installed_manifest_sha256`, +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. @@ -1457,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 @@ -1533,32 +1591,34 @@ Result: } ``` -#### `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 @@ -1584,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 { @@ -1648,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 { @@ -1822,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 @@ -3101,11 +3254,20 @@ 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 } +{ 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: @@ -3128,6 +3290,32 @@ Result: } ``` +#### `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: @@ -3519,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. diff --git a/spec/CCCS_V1.md b/spec/CCCS_V1.md index 32345f7..cf0098c 100644 --- a/spec/CCCS_V1.md +++ b/spec/CCCS_V1.md @@ -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`. diff --git a/ts/README.md b/ts/README.md index 3233fb6..30a99de 100644 --- a/ts/README.md +++ b/ts/README.md @@ -28,7 +28,6 @@ async function main() { await client.assertCompatible({ requireIpcV: 1, - requireCapabilities: { events_stream: true }, requireOps: ['groups', 'send', 'reply', 'tracked_send', 'context_sync'], }); @@ -203,7 +202,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.32 compatibility delta +## CCCC 0.4.33 compatibility delta ```typescript // Deliberately rotate provider session metadata for Claude/Codex/Grok PTY. @@ -221,8 +220,16 @@ const exported = await client.groupCopyExportFile({ groupId }); const packagePath = String(exported.package_path); const preview = await client.groupCopyPreviewImport({ packagePath }); const copied = await client.groupCopyImport({ packagePath }); + +// Current Rust-daemon administration and terminal operations. +const preamble = await client.groupPreambleGet({ groupId }); +const recent = await client.terminalSince({ groupId, actorId: 'reviewer', after: 0 }); +await client.termResize({ groupId, actorId: 'reviewer', cols: 120, rows: 40 }); ``` +`events_stream` compatibility is verified by probing the operation itself; +the SDK does not rely only on the daemon's advertised capability flag. + `groupReset` is destructive: it creates a clean replacement and removes the old group after copying selected configuration. `confirmGroupId` must equal `groupId`: 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 fc87673..1f63103 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, }); }); @@ -372,6 +377,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 { @@ -406,6 +416,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 2d01e49..4540ed8 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -1,12 +1,12 @@ { "name": "cccc-sdk", - "version": "0.4.32", + "version": "0.4.33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cccc-sdk", - "version": "0.4.32", + "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 73973ba..6c8c698 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,6 +1,6 @@ { "name": "cccc-sdk", - "version": "0.4.32", + "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 567ec8c..afc14a9 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, GroupResetOptions, @@ -39,21 +36,6 @@ import type { GroupAutomationUpdateOptions, GroupAutomationManageOptions, GroupAutomationResetBaselineOptions, - GroupSpaceStatusOptions, - GroupSpaceSpacesOptions, - GroupSpaceCapabilitiesOptions, - GroupSpaceBindOptions, - GroupSpaceIngestOptions, - GroupSpaceQueryOptions, - GroupSpaceSourcesOptions, - GroupSpaceArtifactOptions, - GroupSpaceJobsOptions, - GroupSpaceSyncOptions, - GroupSpaceProviderCredentialStatusOptions, - GroupSpaceProviderCredentialUpdateOptions, - GroupSpaceProviderHealthCheckOptions, - GroupSpaceProviderAuthOptions, - InboxListOptions, ContextSyncOptions, CoordinationBriefUpdateOptions, CoordinationNoteAddOptions, @@ -66,9 +48,6 @@ import type { MetaMergeOptions, EventsStreamOptions, EventStreamItem, - CCCSEvent, - SendResult, - SendAndWaitOptions, PingResult, GroupsResult, GroupShowResult, @@ -131,6 +110,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)); @@ -251,9 +233,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', @@ -545,8 +525,27 @@ export class CCCCClient { } /** Start a fresh provider session for a supported Claude, Codex, or Grok PTY actor. */ - async actorNewSession(groupId: string, actorId: string, by = 'user'): Promise> { - return this.call('actor_new_session', { group_id: groupId, actor_id: actorId, by }); + async actorNewSession(groupId: string, actorId: string, by?: string): Promise>; + async actorNewSession(options: { + groupId: string; + actorId: string; + by?: string; + clearSavedSession?: boolean; + }): Promise>; + async actorNewSession( + optionsOrGroupId: string | { groupId: string; actorId: string; by?: string; clearSavedSession?: boolean }, + actorId?: string, + by = 'user', + ): Promise> { + const options = typeof optionsOrGroupId === 'string' + ? { groupId: optionsOrGroupId, actorId: String(actorId ?? ''), by } + : optionsOrGroupId; + return this.call('actor_new_session', compactRecord({ + group_id: options.groupId, + actor_id: options.actorId, + by: options.by ?? 'user', + clear_saved_session: 'clearSavedSession' in options ? options.clearSavedSession : undefined, + })); } async runtimeHermesStatus(): Promise> { @@ -960,211 +959,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.insight) args['insight'] = options.insight; - if (options.suggestedUserMessage) args['suggested_user_message'] = options.suggestedUserMessage; - 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.insight) args['insight'] = options.insight; - 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.insight) args['insight'] = options.insight; - if (options.suggestedUserMessage) args['suggested_user_message'] = options.suggestedUserMessage; - 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 // ============================================================ @@ -1332,6 +1126,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); } @@ -1760,208 +1556,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 // ============================================================ @@ -2030,3 +1624,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..76f2b6c --- /dev/null +++ b/ts/src/client_0430_admin_ops.ts @@ -0,0 +1,190 @@ +import { + compactRecord, + type BasicGroupActorOptions, + type CCCC0430Client, + type GroupScopedOptions, +} from './client_0430_shared.js'; + +export interface CCCC0430AdminOps { + actorNewSession(groupId: string, actorId: string, by?: string): Promise>; + 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( + optionsOrGroupId: (BasicGroupActorOptions & { clearSavedSession?: boolean }) | string, + legacyActorId?: string, + legacyBy: string = 'user', + ) { + const options: BasicGroupActorOptions & { clearSavedSession?: boolean } = typeof optionsOrGroupId === 'string' + ? { groupId: optionsOrGroupId, actorId: String(legacyActorId ?? ''), by: legacyBy } + : optionsOrGroupId; + 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..cb977d4 --- /dev/null +++ b/ts/src/client_0430_memory_ops.ts @@ -0,0 +1,133 @@ +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; + candidateMultiplier?: number; + minScore?: number; + sources?: string[]; + }): 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, + max_results: options.maxResults ?? options.limit, + tags: options.tags, + target: options.target, + vector_weight: options.vectorWeight, + candidate_multiplier: options.candidateMultiplier, + min_score: options.minScore, + sources: options.sources, + })); + }, + + 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 5f3bfc2..c78197b 100644 --- a/ts/src/types.ts +++ b/ts/src/types.ts @@ -257,6 +257,7 @@ export interface SendOptions { refs?: MessageRef[]; attachments?: MessageAttachment[]; clientId?: string; + requirePeerInsight?: boolean; } /** Send-cross-group options */ @@ -271,6 +272,7 @@ export interface SendCrossGroupOptions { replyRequired?: boolean; refs?: MessageRef[]; attachments?: MessageAttachment[]; + requirePeerInsight?: boolean; } /** Reply message options */ @@ -287,6 +289,7 @@ export interface ReplyOptions { refs?: MessageRef[]; attachments?: MessageAttachment[]; clientId?: string; + requirePeerInsight?: boolean; } /** @@ -323,6 +326,7 @@ export interface TrackedSendOptions { handoffTo?: string; assignee?: string; refs?: MessageRef[]; + requirePeerInsight?: boolean; } /** Task list (returns either all tasks or one task plus its children). */