diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d36c2c..f1b1d70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: working-directory: python - name: Type-check samples run: | - for sample in quickstart multi-turn-conversation streaming-chat code-review tool-permissions ask-user-question model-selection hooks custom-tools subagents; do - uv run mypy "$sample/main.py" + for sample in quickstart multi-turn-conversation streaming-chat code-review tool-permissions ask-user-question model-selection hooks custom-tools subagents external-session-storage; do + uv run mypy "$sample" done working-directory: python diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c9579a..0c201e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,11 +46,12 @@ Use an existing sample (for example, `quickstart`) as a template, then register the new sample everywhere it needs to appear. Replace `` with your kebab-case sample name. -1. **Create the TypeScript sample** at `typescript//` with `index.ts`, - `README.md`, and `package.json`. Copy `package.json` from an existing sample - and update the `name` field to `@qoder-samples/typescript-`. -2. **Create the Python sample** at `python//` with `main.py`, - `README.md`, and `requirements.txt`. +1. **Create the TypeScript sample** at `typescript//` with one or more + runnable `.ts` entry files, `README.md`, and `package.json`. Copy + `package.json` from an existing sample and update the `name` field to + `@qoder-samples/typescript-`. +2. **Create the Python sample** at `python//` with one or more runnable + `.py` entry files, `README.md`, and `requirements.txt`. 3. **Register the TypeScript workspace:** add `` to the `workspaces` array in `typescript/package.json`. 4. **Register the Python type check:** add `` to the `for sample in ...` @@ -59,7 +60,8 @@ kebab-case sample name. in `README.zh-CN.md`. Keep the TypeScript and Python versions behaviorally equivalent so readers can -switch between languages. +switch between languages. When one concept has multiple independently copyable +variants, keep them in one sample directory and list every entry in its README. ## Running the checks @@ -69,8 +71,8 @@ request: ```bash cd typescript && npm ci && npm run check --workspaces cd ../python && uv sync && uv run ruff check . && uv run ruff format --check . -for sample in quickstart multi-turn-conversation streaming-chat code-review tool-permissions ask-user-question model-selection hooks custom-tools subagents; do - uv run mypy "$sample/main.py" +for sample in quickstart multi-turn-conversation streaming-chat code-review tool-permissions ask-user-question model-selection hooks custom-tools subagents external-session-storage; do + uv run mypy "$sample" done ``` diff --git a/README.md b/README.md index 48c848c..5ce3dec 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,13 @@ Every sample is available in TypeScript and Python. | Hooks | Add lifecycle observation, context injection, and tool policy | [Open](typescript/hooks) | [Open](python/hooks) | | Custom tools | Expose application functions as in-process MCP tools | [Open](typescript/custom-tools) | [Open](python/custom-tools) | | Subagents | Delegate a task to specialized SDK-defined agents | [Open](typescript/subagents) | [Open](python/subagents) | +| External session storage | Resume a session on another runtime through Redis or PostgreSQL | [Open](typescript/external-session-storage) | [Open](python/external-session-storage) | ## Setup -These samples read a Personal Access Token from the environment. See -[SDK Authentication](https://docs.qoder.com/en/cli/sdk/authentication) for the +Unless a sample README says otherwise, samples read a Personal Access Token +from the environment. See +[SDK Authentication](https://docs.qoder.com/en/cli/sdk/authentication) for setup and other supported authentication methods. ```bash @@ -45,10 +47,10 @@ commands. The sample manifests declare compatible SDK version ranges, while the repository lockfiles record the exact versions used by CI. -Last verified on July 20, 2026: +Last verified on July 29, 2026: -- TypeScript SDK 1.0.15 -- Python SDK 1.0.9 +- TypeScript SDK 1.0.16 +- Python SDK 1.0.10 ## License and terms diff --git a/README.zh-CN.md b/README.zh-CN.md index d3dc3a5..68f575a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,10 +17,12 @@ TypeScript 和 Python 实现。 | Hooks | 添加生命周期观测、上下文注入和工具策略 | [查看](typescript/hooks) | [查看](python/hooks) | | Custom tools | 将应用函数注册为进程内 MCP 工具 | [查看](typescript/custom-tools) | [查看](python/custom-tools) | | Subagents | 将任务委派给 SDK 定义的专业子 Agent | [查看](typescript/subagents) | [查看](python/subagents) | +| External session storage | 通过 Redis 或 PostgreSQL 在另一个运行实例上恢复会话 | [查看](typescript/external-session-storage) | [查看](python/external-session-storage) | ## 准备工作 -这些示例从环境变量读取 Personal Access Token。认证配置和其他可用认证方式见 +除非示例 README 另有说明,否则示例从环境变量读取 Personal Access Token。认证 +配置和其他可用认证方式见 [SDK 认证文档](https://docs.qoder.com/en/cli/sdk/authentication)。 ```bash @@ -36,10 +38,10 @@ TypeScript 需要 Node.js 18 或更高版本,Python 需要 Python 3.10 或更 各示例的依赖清单声明了兼容的 SDK 版本范围,仓库锁文件则记录了 CI 使用的确切版本。 -最近一次验证时间:2026 年 7 月 20 日: +最近一次验证时间:2026 年 7 月 29 日: -- TypeScript SDK 1.0.15 -- Python SDK 1.0.9 +- TypeScript SDK 1.0.16 +- Python SDK 1.0.10 ## 许可与条款 diff --git a/python/README.md b/python/README.md index fb2a3d1..4bfd5cd 100644 --- a/python/README.md +++ b/python/README.md @@ -1,8 +1,9 @@ # Python samples Each directory is an independent Python project. Create a virtual environment -inside the sample directory, install its requirements, and run `main.py`. -Complete the repository-level setup before running a sample. +inside the sample directory, install its requirements, and run the entry file +documented in its README. Complete the repository-level setup before running a +sample. The repository-level development environment uses `uv` only for maintainer checks; users do not need `uv` to run a sample. diff --git a/python/ask-user-question/requirements.txt b/python/ask-user-question/requirements.txt index 621626d..c72e900 100644 --- a/python/ask-user-question/requirements.txt +++ b/python/ask-user-question/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/code-review/requirements.txt b/python/code-review/requirements.txt index 621626d..c72e900 100644 --- a/python/code-review/requirements.txt +++ b/python/code-review/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/custom-tools/requirements.txt b/python/custom-tools/requirements.txt index 621626d..c72e900 100644 --- a/python/custom-tools/requirements.txt +++ b/python/custom-tools/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/external-session-storage/README.md b/python/external-session-storage/README.md new file mode 100644 index 0000000..63b0971 --- /dev/null +++ b/python/external-session-storage/README.md @@ -0,0 +1,143 @@ +# External session storage with Redis or PostgreSQL + +Copy and run one self-contained sample for the storage backend used by the +application: + +| Sample | Backend | Run | +| --- | --- | --- | +| [`redis_sample.py`](redis_sample.py) | Redis lists and indexes | `python redis_sample.py /path/to/repository` | +| [`postgres_sample.py`](postgres_sample.py) | PostgreSQL JSONB rows | `python postgres_sample.py /path/to/repository` | + +Each file contains both a complete `SessionStore` adapter and the same +end-to-end handoff: + +```text +host A shared store host B +local transcript -- append --> Redis/PostgreSQL <-- load -- no transcript + | | + creates session resumes session + | + local transcript is deleted before host B starts +``` + +The application asks Host A to remember a unique marker, deletes its local +transcript, resumes the session through the external store, and verifies that +Host B returns the marker. + +Complete the [repository setup](../../README.md#setup), then: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +Both samples use the same absolute workspace path for Host A and Host B. +Session storage uses the canonical workspace path to identify the project. + +## Redis sample + +Start Redis: + +```bash +docker run --rm --name qoder-session-redis \ + -p 6379:6379 \ + redis:7-alpine +``` + +In another terminal: + +```bash +python redis_sample.py /path/to/repository +``` + +The default connection is `redis://127.0.0.1:6379`. Override it when needed: + +```bash +export QODER_SAMPLE_REDIS_URL="redis://127.0.0.1:6379" +``` + +`redis_sample.py` is independently copyable. It needs `qoder-agent-sdk` and +`redis`, and stores: + +```text +entries:::main Redis list, append order +entries:::child:* Redis list, append order +sessions: sorted set, modification time +subkeys:: set of child transcript paths +``` + +Project keys, session IDs, and child paths are base64url-encoded before they +become Redis key segments. + +## PostgreSQL sample + +Start PostgreSQL: + +```bash +docker run --rm --name qoder-session-postgres \ + -e POSTGRES_USER=qoder \ + -e POSTGRES_PASSWORD=qoder \ + -e POSTGRES_DB=qoder_session_store \ + -p 5432:5432 \ + postgres:17-alpine +``` + +In another terminal: + +```bash +python postgres_sample.py /path/to/repository +``` + +The default connection uses the development credentials from the Docker +command. Override it when needed: + +```bash +export QODER_SAMPLE_POSTGRES_URL="postgresql://qoder:qoder@127.0.0.1:5432/qoder_session_store" +``` + +`postgres_sample.py` is independently copyable. It needs `qoder-agent-sdk` and +`asyncpg`. The adapter creates `qoder_session_entries`: each transcript entry +is one JSONB row, the increasing `seq` column preserves replay order, and the +`(project_key, session_id, subpath, seq)` index supports loading main and child +transcripts. + +## SessionStore behavior + +The SDK-facing application code needs only a store instance: + +```python +options = QoderAgentOptions( + auth=access_token_from_env(), + cwd=workspace, + session_store=store, + resume=session_id, +) + +async for message in query(prompt=prompt, options=options): + ... +``` + +`append()` receives newly committed entries. `load()` returns the complete +history for one key in append order. `list_sessions()`, `list_subkeys()`, and +`delete()` enable latest-session resume, child-agent restoration, and deletion. + +Each sample removes its local and external transcript data during cleanup. + +## Production considerations + +These adapters are readable reference implementations, not production-ready +database packages. Before deployment: + +- isolate tenants in the Redis prefix or PostgreSQL schema +- define retention, backup, encryption, and access control +- serialize concurrent writers for the same session +- account for a failed `append()` being retried with the same entries +- monitor `SDKMirrorErrorMessage`; the conversation can succeed even when an + external write fails +- configure Redis eviction and TTL behavior for transcript retention +- size the PostgreSQL pool and schedule deletion of expired rows +- add backend-specific load, concurrency, failover, and cleanup tests + +SessionStore contains transcript history only. It does not store credentials, +application settings, file checkpoints, or external tool side effects. diff --git a/python/external-session-storage/postgres_sample.py b/python/external-session-storage/postgres_sample.py new file mode 100644 index 0000000..3d71723 --- /dev/null +++ b/python/external-session-storage/postgres_sample.py @@ -0,0 +1,278 @@ +"""Runnable PostgreSQL SessionStore reference sample.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import re +import uuid +from pathlib import Path +from typing import cast + +import asyncpg +from qoder_agent_sdk import ( + QoderAgentOptions, + ResultMessage, + SDKMirrorErrorMessage, + SessionKey, + SessionListSubkeysKey, + SessionStore, + SessionStoreEntry, + SessionStoreListEntry, + access_token_from_env, + delete_session, + delete_session_via_store, + get_session_info, + query, +) + +_SQL_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +class PostgresSessionStore(SessionStore): + """Store one transcript entry per PostgreSQL JSONB row.""" + + def __init__( + self, + pool: asyncpg.Pool, + table_name: str = "qoder_session_entries", + ) -> None: + if not _SQL_IDENTIFIER.fullmatch(table_name): + raise ValueError( + f"Invalid table name {table_name!r}. " + "Use letters, digits, and underscores." + ) + self._pool = pool + self._table_name = table_name + + async def ensure_schema(self) -> None: + """Create the entry table and lookup index when absent.""" + + await self._pool.execute( + f""" + CREATE TABLE IF NOT EXISTS {self._table_name} ( + seq BIGSERIAL PRIMARY KEY, + project_key TEXT NOT NULL, + session_id TEXT NOT NULL, + subpath TEXT NOT NULL DEFAULT '', + entry JSONB NOT NULL, + modified_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() + ); + CREATE INDEX IF NOT EXISTS {self._table_name}_lookup_idx + ON {self._table_name} + (project_key, session_id, subpath, seq); + """ + ) + + async def append( + self, + key: SessionKey, + entries: list[SessionStoreEntry], + ) -> None: + if not entries: + return + + await self._pool.execute( + f""" + INSERT INTO {self._table_name} + (project_key, session_id, subpath, entry) + SELECT $1, $2, $3, value + FROM unnest($4::jsonb[]) WITH ORDINALITY AS item(value, position) + ORDER BY position + """, + key["project_key"], + key["session_id"], + key.get("subpath", ""), + [json.dumps(entry, separators=(",", ":")) for entry in entries], + ) + + async def load(self, key: SessionKey) -> list[SessionStoreEntry] | None: + rows = await self._pool.fetch( + f""" + SELECT entry + FROM {self._table_name} + WHERE project_key = $1 + AND session_id = $2 + AND subpath = $3 + ORDER BY seq + """, + key["project_key"], + key["session_id"], + key.get("subpath", ""), + ) + if not rows: + return None + entries: list[SessionStoreEntry] = [] + for row in rows: + value = row["entry"] + decoded = json.loads(value) if isinstance(value, str) else value + entries.append(cast(SessionStoreEntry, decoded)) + return entries + + async def list_sessions( + self, + project_key: str, + ) -> list[SessionStoreListEntry]: + rows = await self._pool.fetch( + f""" + SELECT session_id, + EXTRACT(EPOCH FROM MAX(modified_at)) * 1000 AS mtime + FROM {self._table_name} + WHERE project_key = $1 AND subpath = '' + GROUP BY session_id + ORDER BY mtime DESC + """, + project_key, + ) + return [ + { + "session_id": cast(str, row["session_id"]), + "mtime": int(row["mtime"]), + } + for row in rows + ] + + async def list_subkeys(self, key: SessionListSubkeysKey) -> list[str]: + rows = await self._pool.fetch( + f""" + SELECT DISTINCT subpath + FROM {self._table_name} + WHERE project_key = $1 + AND session_id = $2 + AND subpath <> '' + ORDER BY subpath + """, + key["project_key"], + key["session_id"], + ) + return [cast(str, row["subpath"]) for row in rows] + + async def delete(self, key: SessionKey) -> None: + subpath = key.get("subpath") + if subpath is None: + await self._pool.execute( + f""" + DELETE FROM {self._table_name} + WHERE project_key = $1 AND session_id = $2 + """, + key["project_key"], + key["session_id"], + ) + return + + await self._pool.execute( + f""" + DELETE FROM {self._table_name} + WHERE project_key = $1 + AND session_id = $2 + AND subpath = $3 + """, + key["project_key"], + key["session_id"], + subpath, + ) + + +async def run_query( + *, + workspace: Path, + prompt: str, + store: SessionStore, + resume: str | None = None, +) -> ResultMessage: + options = QoderAgentOptions( + auth=access_token_from_env(), + cwd=workspace, + tools=[], + max_turns=1, + model="auto", + session_store=store, + resume=resume, + ) + result: ResultMessage | None = None + async for message in query(prompt=prompt, options=options): + if isinstance(message, SDKMirrorErrorMessage): + raise RuntimeError( + f"SessionStore could not persist {message.key}: {message.error}" + ) + if isinstance(message, ResultMessage): + if message.subtype != "success": + raise RuntimeError("\n".join(message.errors or [message.subtype])) + result = message + + if result is None: + raise RuntimeError("The query ended without a success result.") + return result + + +async def run(workspace: Path) -> None: + pool = await asyncpg.create_pool( + os.getenv( + "QODER_SAMPLE_POSTGRES_URL", + "postgresql://qoder:qoder@127.0.0.1:5432/qoder_session_store", + ) + ) + if pool is None: + raise RuntimeError("Could not create the PostgreSQL connection pool.") + store = PostgresSessionStore(pool) + marker = f"session-storage-{uuid.uuid4()}" + session_id: str | None = None + + try: + await store.ensure_schema() + print("[host-a] Starting a session with PostgreSQL storage.") + first = await run_query( + workspace=workspace, + store=store, + prompt=( + f"Remember this exact deployment marker: {marker}. " + "Reply only that it is stored." + ), + ) + session_id = first.session_id + delete_session(session_id, str(workspace)) + print(f"[host-a] Stored session {session_id}; local transcript deleted.") + + print("[host-b] Starting without host A's local transcript.") + resumed = await run_query( + workspace=workspace, + store=store, + resume=session_id, + prompt=( + "What exact deployment marker did I ask you to remember? " + "Reply only with the marker." + ), + ) + print(f"[host-b] Resumed session {resumed.session_id}.") + print(f"[host-b] Agent response: {resumed.result}") + + if marker not in (resumed.result or ""): + raise RuntimeError( + "The resumed response did not contain the stored marker." + ) + print("[app] External session handoff verified.") + finally: + try: + if session_id: + if get_session_info(session_id, str(workspace)) is not None: + delete_session(session_id, str(workspace)) + await delete_session_via_store(store, session_id, workspace) + print("[app] Deleted sample transcript.") + finally: + await pool.close() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("workspace", nargs="?", type=Path, default=Path.cwd()) + workspace = cast(Path, parser.parse_args().workspace).resolve() + asyncio.run(run(workspace)) + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, OSError, ValueError) as error: + raise SystemExit(str(error)) from error diff --git a/python/external-session-storage/redis_sample.py b/python/external-session-storage/redis_sample.py new file mode 100644 index 0000000..d18ed9b --- /dev/null +++ b/python/external-session-storage/redis_sample.py @@ -0,0 +1,258 @@ +"""Runnable Redis SessionStore reference sample.""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import os +import time +import uuid +from pathlib import Path +from typing import cast + +from qoder_agent_sdk import ( + QoderAgentOptions, + ResultMessage, + SDKMirrorErrorMessage, + SessionKey, + SessionListSubkeysKey, + SessionStore, + SessionStoreEntry, + SessionStoreListEntry, + access_token_from_env, + delete_session, + delete_session_via_store, + get_session_info, + query, +) +from redis.asyncio import Redis + +_MAIN_TRANSCRIPT = "main" + + +def _encoded(value: str) -> str: + encoded = base64.urlsafe_b64encode(value.encode()).decode() + return encoded.rstrip("=") + + +class RedisSessionStore(SessionStore): + """Store transcript entries in Redis lists with lightweight indexes.""" + + def __init__( + self, + client: Redis, + prefix: str = "qoder:samples:session-storage", + ) -> None: + self._client = client + self._prefix = prefix.rstrip(":") + + async def append( + self, + key: SessionKey, + entries: list[SessionStoreEntry], + ) -> None: + if not entries: + return + + transaction = self._client.pipeline(transaction=True) + transaction.rpush( + self._entries_key(key), + *(json.dumps(entry, separators=(",", ":")) for entry in entries), + ) + subpath = key.get("subpath") + if subpath is None: + transaction.zadd( + self._sessions_key(key["project_key"]), + {key["session_id"]: int(time.time() * 1000)}, + ) + else: + transaction.sadd(self._subkeys_key(key), subpath) + await transaction.execute() + + async def load(self, key: SessionKey) -> list[SessionStoreEntry] | None: + values = cast( + list[str], + await self._client.lrange(self._entries_key(key), 0, -1), + ) + if not values: + return None + return [cast(SessionStoreEntry, json.loads(value)) for value in values] + + async def list_sessions( + self, + project_key: str, + ) -> list[SessionStoreListEntry]: + values = cast( + list[tuple[str, float]], + await self._client.zrange( + self._sessions_key(project_key), + 0, + -1, + desc=True, + withscores=True, + ), + ) + return [ + {"session_id": session_id, "mtime": int(mtime)} + for session_id, mtime in values + ] + + async def list_subkeys(self, key: SessionListSubkeysKey) -> list[str]: + values = cast( + set[str], + await self._client.smembers(self._subkeys_key(key)), + ) + return sorted(values) + + async def delete(self, key: SessionKey) -> None: + subpath = key.get("subpath") + if subpath is not None: + transaction = self._client.pipeline(transaction=True) + transaction.delete(self._entries_key(key)) + transaction.srem(self._subkeys_key(key), subpath) + await transaction.execute() + return + + subpaths = cast( + set[str], + await self._client.smembers(self._subkeys_key(key)), + ) + keys = [self._entries_key(key), self._subkeys_key(key)] + keys.extend( + self._entries_key({**key, "subpath": subpath}) for subpath in subpaths + ) + transaction = self._client.pipeline(transaction=True) + transaction.delete(*keys) + transaction.zrem( + self._sessions_key(key["project_key"]), + key["session_id"], + ) + await transaction.execute() + + def _entries_key(self, key: SessionKey) -> str: + subpath = key.get("subpath") + transcript = ( + f"child:{_encoded(subpath)}" if subpath is not None else _MAIN_TRANSCRIPT + ) + return ":".join( + [ + self._prefix, + "entries", + _encoded(key["project_key"]), + _encoded(key["session_id"]), + transcript, + ] + ) + + def _sessions_key(self, project_key: str) -> str: + return ":".join([self._prefix, "sessions", _encoded(project_key)]) + + def _subkeys_key(self, key: SessionListSubkeysKey) -> str: + return ":".join( + [ + self._prefix, + "subkeys", + _encoded(key["project_key"]), + _encoded(key["session_id"]), + ] + ) + + +async def run_query( + *, + workspace: Path, + prompt: str, + store: SessionStore, + resume: str | None = None, +) -> ResultMessage: + options = QoderAgentOptions( + auth=access_token_from_env(), + cwd=workspace, + tools=[], + max_turns=1, + model="auto", + session_store=store, + resume=resume, + ) + result: ResultMessage | None = None + async for message in query(prompt=prompt, options=options): + if isinstance(message, SDKMirrorErrorMessage): + raise RuntimeError( + f"SessionStore could not persist {message.key}: {message.error}" + ) + if isinstance(message, ResultMessage): + if message.subtype != "success": + raise RuntimeError("\n".join(message.errors or [message.subtype])) + result = message + + if result is None: + raise RuntimeError("The query ended without a success result.") + return result + + +async def run(workspace: Path) -> None: + client = Redis.from_url( + os.getenv("QODER_SAMPLE_REDIS_URL", "redis://127.0.0.1:6379"), + decode_responses=True, + ) + store = RedisSessionStore(client) + marker = f"session-storage-{uuid.uuid4()}" + session_id: str | None = None + + try: + print("[host-a] Starting a session with Redis storage.") + first = await run_query( + workspace=workspace, + store=store, + prompt=( + f"Remember this exact deployment marker: {marker}. " + "Reply only that it is stored." + ), + ) + session_id = first.session_id + delete_session(session_id, str(workspace)) + print(f"[host-a] Stored session {session_id}; local transcript deleted.") + + print("[host-b] Starting without host A's local transcript.") + resumed = await run_query( + workspace=workspace, + store=store, + resume=session_id, + prompt=( + "What exact deployment marker did I ask you to remember? " + "Reply only with the marker." + ), + ) + print(f"[host-b] Resumed session {resumed.session_id}.") + print(f"[host-b] Agent response: {resumed.result}") + + if marker not in (resumed.result or ""): + raise RuntimeError( + "The resumed response did not contain the stored marker." + ) + print("[app] External session handoff verified.") + finally: + try: + if session_id: + if get_session_info(session_id, str(workspace)) is not None: + delete_session(session_id, str(workspace)) + await delete_session_via_store(store, session_id, workspace) + print("[app] Deleted sample transcript.") + finally: + await client.aclose() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("workspace", nargs="?", type=Path, default=Path.cwd()) + workspace = cast(Path, parser.parse_args().workspace).resolve() + asyncio.run(run(workspace)) + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, OSError, ValueError) as error: + raise SystemExit(str(error)) from error diff --git a/python/external-session-storage/requirements.txt b/python/external-session-storage/requirements.txt new file mode 100644 index 0000000..895f096 --- /dev/null +++ b/python/external-session-storage/requirements.txt @@ -0,0 +1,3 @@ +qoder-agent-sdk>=1.0.10,<2 +redis>=5,<9 +asyncpg>=0.29,<1 diff --git a/python/hooks/requirements.txt b/python/hooks/requirements.txt index 621626d..c72e900 100644 --- a/python/hooks/requirements.txt +++ b/python/hooks/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/model-selection/requirements.txt b/python/model-selection/requirements.txt index 621626d..c72e900 100644 --- a/python/model-selection/requirements.txt +++ b/python/model-selection/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/multi-turn-conversation/requirements.txt b/python/multi-turn-conversation/requirements.txt index 621626d..c72e900 100644 --- a/python/multi-turn-conversation/requirements.txt +++ b/python/multi-turn-conversation/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/pyproject.toml b/python/pyproject.toml index 488b730..782cbb3 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -3,12 +3,14 @@ name = "qoder-agent-sdk-samples-python" version = "0.0.0" requires-python = ">=3.10" dependencies = [ - "qoder-agent-sdk>=1.0.9,<2", + "qoder-agent-sdk>=1.0.10,<2", ] [dependency-groups] dev = [ + "asyncpg>=0.29,<1", "mypy>=1.17,<2", + "redis>=5,<9", "ruff>=0.12,<1", ] @@ -25,3 +27,7 @@ select = ["E", "F", "I", "UP", "B", "SIM", "PTH"] [tool.mypy] python_version = "3.10" strict = true + +[[tool.mypy.overrides]] +module = ["asyncpg"] +ignore_missing_imports = true diff --git a/python/quickstart/requirements.txt b/python/quickstart/requirements.txt index 621626d..c72e900 100644 --- a/python/quickstart/requirements.txt +++ b/python/quickstart/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/streaming-chat/requirements.txt b/python/streaming-chat/requirements.txt index 621626d..c72e900 100644 --- a/python/streaming-chat/requirements.txt +++ b/python/streaming-chat/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/subagents/requirements.txt b/python/subagents/requirements.txt index 621626d..c72e900 100644 --- a/python/subagents/requirements.txt +++ b/python/subagents/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/tool-permissions/requirements.txt b/python/tool-permissions/requirements.txt index 621626d..c72e900 100644 --- a/python/tool-permissions/requirements.txt +++ b/python/tool-permissions/requirements.txt @@ -1 +1 @@ -qoder-agent-sdk>=1.0.9,<2 +qoder-agent-sdk>=1.0.10,<2 diff --git a/python/uv.lock b/python/uv.lock index c588c34..de623a2 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", @@ -34,6 +34,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/d9/507c80bdac2e95e5a525644af94b03fa7f9a44596a84bd48a6e80f854f92/asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61", size = 644865, upload-time = "2025-11-24T23:25:23.527Z" }, + { url = "https://files.pythonhosted.org/packages/ea/03/f93b5e543f65c5f504e91405e8d21bb9e600548be95032951a754781a41d/asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be", size = 639297, upload-time = "2025-11-24T23:25:25.192Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/de2177e57e03a06e697f6c1ddf2a9a7fcfdc236ce69966f54ffc830fd481/asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8", size = 2816679, upload-time = "2025-11-24T23:25:26.718Z" }, + { url = "https://files.pythonhosted.org/packages/d0/98/1a853f6870ac7ad48383a948c8ff3c85dc278066a4d69fc9af7d3d4b1106/asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1", size = 2867087, upload-time = "2025-11-24T23:25:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/7e76f2a51f2360a7c90d2cf6d0d9b210c8bb0ae342edebd16173611a55c2/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3", size = 2747631, upload-time = "2025-11-24T23:25:30.154Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3f/716e10cb57c4f388248db46555e9226901688fbfabd0afb85b5e1d65d5a7/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8", size = 2855107, upload-time = "2025-11-24T23:25:31.888Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ec/3ebae9dfb23a1bd3f68acfd4f795983b65b413291c0e2b0d982d6ae6c920/asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095", size = 521990, upload-time = "2025-11-24T23:25:33.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/b4/9fbb4b0af4e36d96a61d026dd37acab3cf521a70290a09640b215da5ab7c/asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540", size = 581629, upload-time = "2025-11-24T23:25:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" }, + { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" }, + { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" }, + { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -245,7 +313,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -739,22 +807,22 @@ wheels = [ [[package]] name = "qoder-agent-sdk" -version = "1.0.9" +version = "1.0.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "mcp" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/4b/365b30bdce7ba4b15ead11c0c8a452d07e144f6e0ac361ca719b42d785b5/qoder_agent_sdk-1.0.9.tar.gz", hash = "sha256:55e6df3679cef9e78476646309cf3ccbe2763a24b2c2090b6396681bb8f7a861", size = 351954, upload-time = "2026-07-16T13:27:12.599Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/8c/95c83f3625103c8504532cab111d121d610773c6a6b21a38aff4b81a2ea6/qoder_agent_sdk-1.0.10.tar.gz", hash = "sha256:2c29e077fa8e75758d1bc4e014f07027a338c8f63521933455d12c13d0478c25", size = 406243, upload-time = "2026-07-29T02:38:17.553Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/26/913f95260f645c4775adfc3a496248d51abb219c26a3e37a59f822a41987/qoder_agent_sdk-1.0.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1239d42e2c82cb4d67f4abc451e7baae696e8b803a1d42a7eb6ee9d2811493ee", size = 37734017, upload-time = "2026-07-16T13:26:38.023Z" }, - { url = "https://files.pythonhosted.org/packages/43/7f/84e5ee41d10e7ad3b313bbe9ddf8585e1ec149c2e1c21647be8a2cd080d6/qoder_agent_sdk-1.0.9-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:360f25389bd795146bd6a7bcae4abac7ada2ffe115448e1b6e8b95d65005d4b3", size = 41727133, upload-time = "2026-07-16T13:26:42.917Z" }, - { url = "https://files.pythonhosted.org/packages/24/b0/16c0e65e62092402d538e6a3154c223294ef9f899e6d997c9cc263e76418/qoder_agent_sdk-1.0.9-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:888a46133abe1524823c18f1874cd84aad36c3e3ba708884c8d91498efd34c43", size = 49864726, upload-time = "2026-07-16T13:26:47.69Z" }, - { url = "https://files.pythonhosted.org/packages/62/14/1e8a57896c38f147f0c98f58e58d9994ef1650c3dc5fd03f139a81395c89/qoder_agent_sdk-1.0.9-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:c127d606046263a6acc545d8c1271d9885708e6fab5b5428bd893567af12b296", size = 50064858, upload-time = "2026-07-16T13:26:53.181Z" }, - { url = "https://files.pythonhosted.org/packages/8d/84/e6059d2e0c18dec8f615655b8fa4b027c1d3777d3f4d60c44af2ba3cb6e0/qoder_agent_sdk-1.0.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6319a2dd209ee56b177d60a8fb0e1fd536785030096e49f51b17f1f7c789e2d", size = 48560014, upload-time = "2026-07-16T13:26:57.85Z" }, - { url = "https://files.pythonhosted.org/packages/2b/73/eb60a43c2dd8c5cd59bc1f2ec63a72b94c72d26034a7bfa357df6b2eef01/qoder_agent_sdk-1.0.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f3cbff5cc0687a0768cd554477b47be381b4490258ce8d903eb769e633c01a3", size = 49196341, upload-time = "2026-07-16T13:27:03.659Z" }, - { url = "https://files.pythonhosted.org/packages/33/9d/2a0c2cbd2a357be558e7af3b57f4a9bc48dee3533b6ad14f6c383c754ef4/qoder_agent_sdk-1.0.9-py3-none-win_amd64.whl", hash = "sha256:e4c85e882022e73f9327dd892b0c42b8ffc11ce5d55a3c06a192b07eafd31dd6", size = 59921947, upload-time = "2026-07-16T13:27:09.625Z" }, + { url = "https://files.pythonhosted.org/packages/f2/18/fff90bdcc548f61f091ae03a9d788ca739c62d9fafdf1cbe46771bf30590/qoder_agent_sdk-1.0.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8d18e2593a4dad792d4af60f265b37e37f88dc83c7ee8dd7dc3155c6c5387a51", size = 38021797, upload-time = "2026-07-29T02:37:43.847Z" }, + { url = "https://files.pythonhosted.org/packages/f3/04/1a1910527da7d387661ba1eb95afb995154df8322df4fd6f287b814e07e2/qoder_agent_sdk-1.0.10-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:cde668ab49ef7234d6558afce7c0aa3798f8c1efbe6ae062378ca8945c3061a1", size = 42022149, upload-time = "2026-07-29T02:37:49.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/74/9b8c6c8848a56de15acc2785b75022832e264c971785f63d96a7269fa52e/qoder_agent_sdk-1.0.10-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:08ecf4dd1ce9be880a2789e2804cf588dcea250ce6684bc8196c968fd13622a2", size = 50137536, upload-time = "2026-07-29T02:37:54.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fa/c5a94bca2fceb7e2ce122cf12807126e020f54062c4c180c70871fdc77cc/qoder_agent_sdk-1.0.10-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:c00086750fac739318698f461e1ad88e417af4c39f37741d0f717473da0b164f", size = 50351700, upload-time = "2026-07-29T02:37:59.63Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/c9c751a9a0b1a6deeba0650f127df1846ed2c2ad58581e122a250d26fb2a/qoder_agent_sdk-1.0.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0d4f51aa7ae8f37bfe6259c3775b617fdf588afc67db9b196bc667da701de329", size = 48847837, upload-time = "2026-07-29T02:38:04.48Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e3/011493b6cf7d38323b13317731d4e5f274db8828a696fa1108dc91b4c673/qoder_agent_sdk-1.0.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c19822bfc85d94c9dcb17187dd6353c89191a4db1852ec4a99a65c6ef398d76b", size = 49478352, upload-time = "2026-07-29T02:38:09.2Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cf/ebae3ad182b55f8d6c1aa4abeff468f13cf7ea0f725a8bd25dccb75899e1/qoder_agent_sdk-1.0.10-py3-none-win_amd64.whl", hash = "sha256:ba57c2bd2e76ce46b3395a729793b5306a67d50559558e22333835fa11b95bab", size = 60205845, upload-time = "2026-07-29T02:38:14.927Z" }, ] [[package]] @@ -767,19 +835,35 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "asyncpg" }, { name = "mypy" }, + { name = "redis" }, { name = "ruff" }, ] [package.metadata] -requires-dist = [{ name = "qoder-agent-sdk", specifier = ">=1.0.9,<2" }] +requires-dist = [{ name = "qoder-agent-sdk", specifier = ">=1.0.10,<2" }] [package.metadata.requires-dev] dev = [ + { name = "asyncpg", specifier = ">=0.29,<1" }, { name = "mypy", specifier = ">=1.17,<2" }, + { name = "redis", specifier = ">=5,<9" }, { name = "ruff", specifier = ">=0.12,<1" }, ] +[[package]] +name = "redis" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, +] + [[package]] name = "referencing" version = "0.37.0" diff --git a/typescript/README.md b/typescript/README.md index 782be03..0788e3f 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -7,5 +7,5 @@ once, run: npm install ``` -You can then run a sample from its directory with `npm start`. Complete the -repository-level setup before running a sample. +Run a sample with the script documented in its README; most use `npm start`. +Complete the repository-level setup before running a sample. diff --git a/typescript/ask-user-question/package.json b/typescript/ask-user-question/package.json index 2b4e93a..0e64a08 100644 --- a/typescript/ask-user-question/package.json +++ b/typescript/ask-user-question/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/code-review/package.json b/typescript/code-review/package.json index ae523b5..fa2acf0 100644 --- a/typescript/code-review/package.json +++ b/typescript/code-review/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/custom-tools/package.json b/typescript/custom-tools/package.json index e7750a9..b6c3ac6 100644 --- a/typescript/custom-tools/package.json +++ b/typescript/custom-tools/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15", + "@qoder-ai/qoder-agent-sdk": "^1.0.16", "zod": "^4.0.0" }, "devDependencies": { diff --git a/typescript/external-session-storage/README.md b/typescript/external-session-storage/README.md new file mode 100644 index 0000000..1860dbe --- /dev/null +++ b/typescript/external-session-storage/README.md @@ -0,0 +1,142 @@ +# External session storage with Redis or PostgreSQL + +Copy and run one self-contained sample for the storage backend used by the +application: + +| Sample | Backend | Run | +| --- | --- | --- | +| [`redis-sample.ts`](redis-sample.ts) | Redis lists and indexes | `npm run redis -- /path/to/repository` | +| [`postgres-sample.ts`](postgres-sample.ts) | PostgreSQL JSONB rows | `npm run postgres -- /path/to/repository` | + +Each file contains both a complete `SessionStore` adapter and the same +end-to-end handoff: + +```text +host A shared store host B +local transcript -- append --> Redis/PostgreSQL <-- load -- no transcript + | | + creates session resumes session + | + local transcript is deleted before host B starts +``` + +The application asks Host A to remember a unique marker, deletes its local +transcript, resumes the session through the external store, and verifies that +Host B returns the marker. + +Complete the [repository setup](../../README.md#setup), then: + +```bash +npm install +``` + +Both samples use the same absolute workspace path for Host A and Host B. +Session storage uses the canonical workspace path to identify the project. + +## Redis sample + +Start Redis: + +```bash +docker run --rm --name qoder-session-redis \ + -p 6379:6379 \ + redis:7-alpine +``` + +In another terminal: + +```bash +npm run redis -- /path/to/repository +``` + +The default connection is `redis://127.0.0.1:6379`. Override it when needed: + +```bash +export QODER_SAMPLE_REDIS_URL="redis://127.0.0.1:6379" +``` + +`redis-sample.ts` is independently copyable. It needs +`@qoder-ai/qoder-agent-sdk` and `ioredis`, and stores: + +```text +entries:::main Redis list, append order +entries:::child:* Redis list, append order +sessions: sorted set, modification time +subkeys:: set of child transcript paths +``` + +Project keys, session IDs, and child paths are base64url-encoded before they +become Redis key segments. + +## PostgreSQL sample + +Start PostgreSQL: + +```bash +docker run --rm --name qoder-session-postgres \ + -e POSTGRES_USER=qoder \ + -e POSTGRES_PASSWORD=qoder \ + -e POSTGRES_DB=qoder_session_store \ + -p 5432:5432 \ + postgres:17-alpine +``` + +In another terminal: + +```bash +npm run postgres -- /path/to/repository +``` + +The default connection uses the development credentials from the Docker +command. Override it when needed: + +```bash +export QODER_SAMPLE_POSTGRES_URL="postgresql://qoder:qoder@127.0.0.1:5432/qoder_session_store" +``` + +`postgres-sample.ts` is independently copyable. It needs +`@qoder-ai/qoder-agent-sdk`, `pg`, and `@types/pg`. The adapter creates +`qoder_session_entries`: each transcript entry is one JSONB row, the increasing +`seq` column preserves replay order, and the +`(project_key, session_id, subpath, seq)` index supports loading main and child +transcripts. + +## SessionStore behavior + +The SDK-facing application code needs only a store instance: + +```ts +const stream = query({ + prompt, + options: { + auth: accessTokenFromEnv(), + cwd: workspace, + sessionStore: store, + resume: sessionId, + }, +}); +``` + +`append()` receives newly committed entries. `load()` returns the complete +history for one key in append order. `listSessions()`, `listSubkeys()`, and +`delete()` enable latest-session resume, child-agent restoration, and deletion. + +Each sample removes its local and external transcript data during cleanup. + +## Production considerations + +These adapters are readable reference implementations, not production-ready +database packages. Before deployment: + +- isolate tenants in the Redis prefix or PostgreSQL schema +- define retention, backup, encryption, and access control +- serialize concurrent writers for the same session +- account for a failed `append()` being retried with the same entries +- monitor `system/mirror_error`; the conversation can succeed even when an + external write fails +- configure Redis eviction and TTL behavior for transcript retention +- size the PostgreSQL pool and schedule deletion of expired rows +- add backend-specific load, concurrency, failover, and cleanup tests + +SessionStore contains transcript history only. It does not store credentials, +application settings, file checkpoints, or external tool side effects. diff --git a/typescript/external-session-storage/package.json b/typescript/external-session-storage/package.json new file mode 100644 index 0000000..75f5d23 --- /dev/null +++ b/typescript/external-session-storage/package.json @@ -0,0 +1,21 @@ +{ + "name": "@qoder-samples/typescript-external-session-storage", + "private": true, + "type": "module", + "scripts": { + "redis": "tsx redis-sample.ts", + "postgres": "tsx postgres-sample.ts", + "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node redis-sample.ts postgres-sample.ts" + }, + "dependencies": { + "@qoder-ai/qoder-agent-sdk": "^1.0.16", + "ioredis": "^5.7.0", + "pg": "^8.16.3" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/pg": "^8.15.5", + "tsx": "^4.20.0", + "typescript": "^5.9.0" + } +} diff --git a/typescript/external-session-storage/postgres-sample.ts b/typescript/external-session-storage/postgres-sample.ts new file mode 100644 index 0000000..e262acf --- /dev/null +++ b/typescript/external-session-storage/postgres-sample.ts @@ -0,0 +1,271 @@ +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + accessTokenFromEnv, + deleteSession, + getSessionInfo, + query, + type SDKResultSuccess, + type SessionKey, + type SessionStore, + type SessionStoreEntry, +} from "@qoder-ai/qoder-agent-sdk"; +import pg, { type Pool } from "pg"; + +const SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** + * PostgreSQL reference adapter with one row per transcript entry. + * + * The increasing seq column preserves the order returned by load(). + */ +export class PostgresSessionStore implements SessionStore { + constructor( + private readonly pool: Pool, + private readonly tableName = "qoder_session_entries", + ) { + if (!SQL_IDENTIFIER.test(tableName)) { + throw new Error( + `Invalid table name ${JSON.stringify(tableName)}. Use letters, digits, and underscores.`, + ); + } + } + + async ensureSchema(): Promise { + await this.pool.query(` + CREATE TABLE IF NOT EXISTS ${this.tableName} ( + seq BIGSERIAL PRIMARY KEY, + project_key TEXT NOT NULL, + session_id TEXT NOT NULL, + subpath TEXT NOT NULL DEFAULT '', + entry JSONB NOT NULL, + modified_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() + ) + `); + await this.pool.query(` + CREATE INDEX IF NOT EXISTS ${this.tableName}_lookup_idx + ON ${this.tableName} (project_key, session_id, subpath, seq) + `); + } + + async append( + key: SessionKey, + entries: SessionStoreEntry[], + ): Promise { + if (entries.length === 0) return; + + const values: unknown[] = [ + key.projectKey, + key.sessionId, + key.subpath ?? "", + ]; + const rows = entries.map((entry, index) => { + values.push(JSON.stringify(entry)); + return `($1, $2, $3, $${index + 4}::jsonb)`; + }); + await this.pool.query( + ` + INSERT INTO ${this.tableName} + (project_key, session_id, subpath, entry) + VALUES ${rows.join(", ")} + `, + values, + ); + } + + async load(key: SessionKey): Promise { + const result = await this.pool.query<{ entry: SessionStoreEntry }>( + ` + SELECT entry + FROM ${this.tableName} + WHERE project_key = $1 + AND session_id = $2 + AND subpath = $3 + ORDER BY seq + `, + [key.projectKey, key.sessionId, key.subpath ?? ""], + ); + return result.rows.length === 0 + ? null + : result.rows.map(({ entry }) => entry); + } + + async listSessions( + projectKey: string, + ): Promise> { + const result = await this.pool.query<{ + session_id: string; + modified_at: Date; + }>( + ` + SELECT session_id, MAX(modified_at) AS modified_at + FROM ${this.tableName} + WHERE project_key = $1 AND subpath = '' + GROUP BY session_id + ORDER BY modified_at DESC + `, + [projectKey], + ); + return result.rows.map(({ session_id, modified_at }) => ({ + sessionId: session_id, + mtime: modified_at.getTime(), + })); + } + + async listSubkeys( + key: Omit, + ): Promise { + const result = await this.pool.query<{ subpath: string }>( + ` + SELECT DISTINCT subpath + FROM ${this.tableName} + WHERE project_key = $1 + AND session_id = $2 + AND subpath <> '' + ORDER BY subpath + `, + [key.projectKey, key.sessionId], + ); + return result.rows.map(({ subpath }) => subpath); + } + + async delete(key: SessionKey): Promise { + if (key.subpath === undefined) { + await this.pool.query( + ` + DELETE FROM ${this.tableName} + WHERE project_key = $1 AND session_id = $2 + `, + [key.projectKey, key.sessionId], + ); + return; + } + await this.pool.query( + ` + DELETE FROM ${this.tableName} + WHERE project_key = $1 + AND session_id = $2 + AND subpath = $3 + `, + [key.projectKey, key.sessionId, key.subpath], + ); + } +} + +async function runQuery(options: { + workspace: string; + prompt: string; + store: SessionStore; + resume?: string; +}): Promise { + const stream = query({ + prompt: options.prompt, + options: { + auth: accessTokenFromEnv(), + cwd: options.workspace, + tools: [], + maxTurns: 1, + model: "auto", + sessionStore: options.store, + ...(options.resume ? { resume: options.resume } : {}), + }, + }); + + let result: SDKResultSuccess | undefined; + try { + for await (const message of stream) { + if (message.type === "system" && message.subtype === "mirror_error") { + throw new Error( + `SessionStore could not persist ${JSON.stringify(message.key)}: ${message.error}`, + ); + } + if (message.type === "result") { + if (message.subtype !== "success") { + throw new Error(message.errors?.join("\n") || message.subtype); + } + result = message; + } + } + } finally { + await stream.close(); + } + + if (!result) throw new Error("The query ended without a success result."); + return result; +} + +export async function run(workspace: string): Promise { + const pool = new pg.Pool({ + connectionString: + process.env["QODER_SAMPLE_POSTGRES_URL"] ?? + "postgresql://qoder:qoder@127.0.0.1:5432/qoder_session_store", + }); + const store = new PostgresSessionStore(pool); + const marker = `session-storage-${randomUUID()}`; + let sessionId: string | undefined; + + try { + await store.ensureSchema(); + console.log("[host-a] Starting a session with PostgreSQL storage."); + const first = await runQuery({ + workspace, + store, + prompt: `Remember this exact deployment marker: ${marker}. Reply only that it is stored.`, + }); + sessionId = first.session_id; + await deleteSession(sessionId, { dir: workspace }); + console.log( + `[host-a] Stored session ${sessionId}; local transcript deleted.`, + ); + + console.log("[host-b] Starting without host A's local transcript."); + const resumed = await runQuery({ + workspace, + store, + resume: sessionId, + prompt: + "What exact deployment marker did I ask you to remember? Reply only with the marker.", + }); + console.log(`[host-b] Resumed session ${resumed.session_id}.`); + console.log(`[host-b] Agent response: ${resumed.result}`); + + if (!resumed.result.includes(marker)) { + throw new Error("The resumed response did not contain the stored marker."); + } + console.log("[app] External session handoff verified."); + } finally { + try { + if (sessionId) { + const localSession = await getSessionInfo(sessionId, { + dir: workspace, + }); + if (localSession) { + await deleteSession(sessionId, { dir: workspace }); + } + await deleteSession(sessionId, { + dir: workspace, + sessionStore: store, + }); + console.log("[app] Deleted sample transcript."); + } + } finally { + await pool.end(); + } + } +} + +async function main(): Promise { + await run(resolve(process.argv[2] ?? process.cwd())); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/typescript/external-session-storage/redis-sample.ts b/typescript/external-session-storage/redis-sample.ts new file mode 100644 index 0000000..70ea5a7 --- /dev/null +++ b/typescript/external-session-storage/redis-sample.ts @@ -0,0 +1,265 @@ +import { Buffer } from "node:buffer"; +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + accessTokenFromEnv, + deleteSession, + getSessionInfo, + query, + type SDKResultSuccess, + type SessionKey, + type SessionStore, + type SessionStoreEntry, +} from "@qoder-ai/qoder-agent-sdk"; +import { Redis } from "ioredis"; + +const MAIN_TRANSCRIPT = "main"; + +function encoded(value: string): string { + return Buffer.from(value, "utf8").toString("base64url"); +} + +async function execute( + transaction: ReturnType, +): Promise { + const results = await transaction.exec(); + if (results === null) { + throw new Error("The Redis transaction was discarded."); + } + for (const [error] of results) { + if (error) throw error; + } +} + +/** + * Redis reference adapter. + * + * Lists hold transcript entries in append order. A sorted set indexes main + * sessions by modification time, and a set indexes child transcript paths. + */ +export class RedisSessionStore implements SessionStore { + private readonly prefix: string; + + constructor( + private readonly client: Redis, + prefix = "qoder:samples:session-storage", + ) { + this.prefix = prefix.replace(/:+$/, ""); + } + + async append( + key: SessionKey, + entries: SessionStoreEntry[], + ): Promise { + if (entries.length === 0) return; + + const transaction = this.client.multi(); + transaction.rpush( + this.entriesKey(key), + ...entries.map((entry) => JSON.stringify(entry)), + ); + if (key.subpath === undefined) { + transaction.zadd( + this.sessionsKey(key.projectKey), + Date.now(), + key.sessionId, + ); + } else { + transaction.sadd(this.subkeysKey(key), key.subpath); + } + await execute(transaction); + } + + async load(key: SessionKey): Promise { + const values = await this.client.lrange(this.entriesKey(key), 0, -1); + if (values.length === 0) return null; + return values.map((value) => JSON.parse(value) as SessionStoreEntry); + } + + async listSessions( + projectKey: string, + ): Promise> { + const values = await this.client.zrevrange( + this.sessionsKey(projectKey), + 0, + -1, + "WITHSCORES", + ); + const sessions: Array<{ sessionId: string; mtime: number }> = []; + for (let index = 0; index < values.length; index += 2) { + sessions.push({ + sessionId: values[index]!, + mtime: Number(values[index + 1]), + }); + } + return sessions; + } + + async listSubkeys( + key: Omit, + ): Promise { + return (await this.client.smembers(this.subkeysKey(key))).sort(); + } + + async delete(key: SessionKey): Promise { + if (key.subpath !== undefined) { + const transaction = this.client + .multi() + .del(this.entriesKey(key)) + .srem(this.subkeysKey(key), key.subpath); + await execute(transaction); + return; + } + + const subpaths = await this.client.smembers(this.subkeysKey(key)); + const childKeys = subpaths.map((subpath) => + this.entriesKey({ ...key, subpath }), + ); + const transaction = this.client + .multi() + .del(this.entriesKey(key), this.subkeysKey(key), ...childKeys) + .zrem(this.sessionsKey(key.projectKey), key.sessionId); + await execute(transaction); + } + + private entriesKey(key: SessionKey): string { + const transcript = key.subpath !== undefined + ? `child:${encoded(key.subpath)}` + : MAIN_TRANSCRIPT; + return [ + this.prefix, + "entries", + encoded(key.projectKey), + encoded(key.sessionId), + transcript, + ].join(":"); + } + + private sessionsKey(projectKey: string): string { + return [this.prefix, "sessions", encoded(projectKey)].join(":"); + } + + private subkeysKey(key: Omit): string { + return [ + this.prefix, + "subkeys", + encoded(key.projectKey), + encoded(key.sessionId), + ].join(":"); + } +} + +async function runQuery(options: { + workspace: string; + prompt: string; + store: SessionStore; + resume?: string; +}): Promise { + const stream = query({ + prompt: options.prompt, + options: { + auth: accessTokenFromEnv(), + cwd: options.workspace, + tools: [], + maxTurns: 1, + model: "auto", + sessionStore: options.store, + ...(options.resume ? { resume: options.resume } : {}), + }, + }); + + let result: SDKResultSuccess | undefined; + try { + for await (const message of stream) { + if (message.type === "system" && message.subtype === "mirror_error") { + throw new Error( + `SessionStore could not persist ${JSON.stringify(message.key)}: ${message.error}`, + ); + } + if (message.type === "result") { + if (message.subtype !== "success") { + throw new Error(message.errors?.join("\n") || message.subtype); + } + result = message; + } + } + } finally { + await stream.close(); + } + + if (!result) throw new Error("The query ended without a success result."); + return result; +} + +export async function run(workspace: string): Promise { + const client = new Redis( + process.env["QODER_SAMPLE_REDIS_URL"] ?? "redis://127.0.0.1:6379", + ); + const store = new RedisSessionStore(client); + const marker = `session-storage-${randomUUID()}`; + let sessionId: string | undefined; + + try { + console.log("[host-a] Starting a session with Redis storage."); + const first = await runQuery({ + workspace, + store, + prompt: `Remember this exact deployment marker: ${marker}. Reply only that it is stored.`, + }); + sessionId = first.session_id; + await deleteSession(sessionId, { dir: workspace }); + console.log( + `[host-a] Stored session ${sessionId}; local transcript deleted.`, + ); + + console.log("[host-b] Starting without host A's local transcript."); + const resumed = await runQuery({ + workspace, + store, + resume: sessionId, + prompt: + "What exact deployment marker did I ask you to remember? Reply only with the marker.", + }); + console.log(`[host-b] Resumed session ${resumed.session_id}.`); + console.log(`[host-b] Agent response: ${resumed.result}`); + + if (!resumed.result.includes(marker)) { + throw new Error("The resumed response did not contain the stored marker."); + } + console.log("[app] External session handoff verified."); + } finally { + try { + if (sessionId) { + const localSession = await getSessionInfo(sessionId, { + dir: workspace, + }); + if (localSession) { + await deleteSession(sessionId, { dir: workspace }); + } + await deleteSession(sessionId, { + dir: workspace, + sessionStore: store, + }); + console.log("[app] Deleted sample transcript."); + } + } finally { + await client.quit(); + } + } +} + +async function main(): Promise { + await run(resolve(process.argv[2] ?? process.cwd())); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/typescript/hooks/package.json b/typescript/hooks/package.json index 105c401..27170c1 100644 --- a/typescript/hooks/package.json +++ b/typescript/hooks/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/model-selection/package.json b/typescript/model-selection/package.json index 05a8422..577a607 100644 --- a/typescript/model-selection/package.json +++ b/typescript/model-selection/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/multi-turn-conversation/package.json b/typescript/multi-turn-conversation/package.json index 90f6a0f..74fe0c8 100644 --- a/typescript/multi-turn-conversation/package.json +++ b/typescript/multi-turn-conversation/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 0ff4898..02b2c16 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -15,13 +15,14 @@ "model-selection", "hooks", "custom-tools", - "subagents" + "subagents", + "external-session-storage" ] }, "ask-user-question": { "name": "@qoder-samples/typescript-ask-user-question", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", @@ -32,7 +33,7 @@ "code-review": { "name": "@qoder-samples/typescript-code-review", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", @@ -43,7 +44,7 @@ "custom-tools": { "name": "@qoder-samples/typescript-custom-tools", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15", + "@qoder-ai/qoder-agent-sdk": "^1.0.16", "zod": "^4.0.0" }, "devDependencies": { @@ -52,10 +53,24 @@ "typescript": "^5.9.0" } }, + "external-session-storage": { + "name": "@qoder-samples/typescript-external-session-storage", + "dependencies": { + "@qoder-ai/qoder-agent-sdk": "^1.0.16", + "ioredis": "^5.7.0", + "pg": "^8.16.3" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/pg": "^8.15.5", + "tsx": "^4.20.0", + "typescript": "^5.9.0" + } + }, "hooks": { "name": "@qoder-samples/typescript-hooks", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", @@ -66,7 +81,7 @@ "model-selection": { "name": "@qoder-samples/typescript-model-selection", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", @@ -77,7 +92,7 @@ "multi-turn-conversation": { "name": "@qoder-samples/typescript-multi-turn-conversation", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", @@ -528,24 +543,30 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", + "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" } }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -580,9 +601,9 @@ } }, "node_modules/@qoder-ai/qoder-agent-sdk": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@qoder-ai/qoder-agent-sdk/-/qoder-agent-sdk-1.0.15.tgz", - "integrity": "sha512-6WyA0wEcM3ThwVEmDJsJcuc7jmCIO02SrbYIOSz6MvDQuWY/on+3usLX7WIpeIKg7pfQQMqUeSrQLXo1DkBQ+w==", + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@qoder-ai/qoder-agent-sdk/-/qoder-agent-sdk-1.0.16.tgz", + "integrity": "sha512-hcKmA6jjt1TJizoZ1ezhyYfO5sJ3LgkqHjz0T5TtmhfYVBv8rgiq1VdijEF4PYT8v9347/tXcRzHqKfFmMn7Xw==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -607,6 +628,10 @@ "resolved": "custom-tools", "link": true }, + "node_modules/@qoder-samples/typescript-external-session-storage": { + "resolved": "external-session-storage", + "link": true + }, "node_modules/@qoder-samples/typescript-hooks": { "resolved": "hooks", "link": true @@ -645,6 +670,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -766,6 +803,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -854,6 +900,15 @@ } } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1044,9 +1099,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", - "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -1221,9 +1276,9 @@ } }, "node_modules/hono": { - "version": "4.12.31", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", - "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1271,10 +1326,32 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", "license": "MIT", "engines": { "node": ">= 12" @@ -1302,9 +1379,9 @@ "license": "ISC" }, "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -1332,12 +1409,16 @@ } }, "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", "license": "MIT", "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/merge-descriptors": { @@ -1462,6 +1543,95 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -1471,6 +1641,45 @@ "node": ">=16.20.0" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1528,6 +1737,27 @@ "node": ">= 0.10" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1703,6 +1933,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1831,6 +2076,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", @@ -1852,7 +2106,7 @@ "quickstart": { "name": "@qoder-samples/typescript-quickstart", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", @@ -1863,7 +2117,7 @@ "streaming-chat": { "name": "@qoder-samples/typescript-streaming-chat", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", @@ -1874,7 +2128,7 @@ "subagents": { "name": "@qoder-samples/typescript-subagents", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", @@ -1885,7 +2139,7 @@ "tool-permissions": { "name": "@qoder-samples/typescript-tool-permissions", "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/package.json b/typescript/package.json index cd66fc9..6f53655 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -11,6 +11,7 @@ "model-selection", "hooks", "custom-tools", - "subagents" + "subagents", + "external-session-storage" ] } diff --git a/typescript/quickstart/package.json b/typescript/quickstart/package.json index 451c61f..7a1d0d7 100644 --- a/typescript/quickstart/package.json +++ b/typescript/quickstart/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/streaming-chat/package.json b/typescript/streaming-chat/package.json index a3e96ec..4b97243 100644 --- a/typescript/streaming-chat/package.json +++ b/typescript/streaming-chat/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/subagents/package.json b/typescript/subagents/package.json index 78311cb..82e16f2 100644 --- a/typescript/subagents/package.json +++ b/typescript/subagents/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/typescript/tool-permissions/package.json b/typescript/tool-permissions/package.json index 8909699..56bd2ce 100644 --- a/typescript/tool-permissions/package.json +++ b/typescript/tool-permissions/package.json @@ -7,7 +7,7 @@ "check": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types node index.ts" }, "dependencies": { - "@qoder-ai/qoder-agent-sdk": "^1.0.15" + "@qoder-ai/qoder-agent-sdk": "^1.0.16" }, "devDependencies": { "@types/node": "^22.0.0",