threadlessai 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ node_modules
2
+ dist
3
+ .next
4
+ .vercel
5
+ .env
6
+ .env.*
7
+ !.env.example
8
+ .DS_Store
9
+ plans
10
+ *.tsbuildinfo
11
+ .claude/*
12
+ !.claude/skills
13
+ packages/sdk-ts/dist
14
+ .venv
15
+ __pycache__
16
+ .pytest_cache
17
+ deploy/hetzner/threadlessai.env
18
+ apps/bench/data
19
+ apps/bench/results
20
+ packages/*/src/**/*.d.ts
21
+
22
+ # Next.js generates this on build and dev
23
+ next-env.d.ts
@@ -0,0 +1,8 @@
1
+ Copyright (c) 2026 Threadless AI. All rights reserved.
2
+
3
+ This software is licensed, not sold. You may install and use it only to call
4
+ the Threadless AI service (threadlessai.com) under its Terms of Service. You
5
+ may not copy, modify, distribute, sublicense or reverse engineer it, or use it
6
+ with any other service, except as those Terms allow.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.5
2
+ Name: threadlessai
3
+ Version: 0.1.0
4
+ Summary: Threadless AI: memory for agents. Decorate every model call, report every reply.
5
+ Project-URL: Homepage, https://threadlessai.com
6
+ License: Copyright (c) 2026 Threadless AI. All rights reserved.
7
+
8
+ This software is licensed, not sold. You may install and use it only to call
9
+ the Threadless AI service (threadlessai.com) under its Terms of Service. You
10
+ may not copy, modify, distribute, sublicense or reverse engineer it, or use it
11
+ with any other service, except as those Terms allow.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
14
+ License-File: LICENSE
15
+ Keywords: agents,context,llm,long-term memory,memory,openai
16
+ Classifier: License :: Other/Proprietary License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.9
20
+ Requires-Dist: httpx>=0.23
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # threadlessai
26
+
27
+ Threadless AI: memory for agents. Decorate every model call, report every reply.
28
+
29
+ ```
30
+ pip install threadlessai
31
+ ```
32
+
33
+ Set `THREADLESS_API_KEY` (a `tl_live_…` or `tl_test_…` project key), or pass `api_key=`.
34
+
35
+ ## Chat Completions
36
+
37
+ ```python
38
+ from openai import OpenAI
39
+ from threadlessai import NOTE_RESULT, Threadless, is_note_call
40
+
41
+ openai = OpenAI()
42
+ threadless = Threadless()
43
+ memory = threadless.memories.create() # one per conversation
44
+
45
+ new_messages = [
46
+ {"role": "system", "content": "You are a helpful assistant."},
47
+ {"role": "user", "content": "Hi, I'm planning a launch."},
48
+ ]
49
+ while True:
50
+ request = threadless.chat.decorate(
51
+ memory["id"],
52
+ new_messages,
53
+ {"model": "gpt-5", "tools": [threadless.chat.note_tool]},
54
+ )
55
+ completion = openai.chat.completions.create(**request)
56
+ threadless.chat.report(memory["id"], completion) # background, never raises
57
+
58
+ message = completion.choices[0].message
59
+ calls = message.tool_calls or []
60
+ if not calls:
61
+ print(message.content)
62
+ break
63
+ # Hand over only what is new since the last decorate: the reply and the tool results.
64
+ new_messages = [message]
65
+ for call in calls:
66
+ output = NOTE_RESULT if is_note_call(call.function.name) else run_tool(call)
67
+ new_messages.append({"role": "tool", "tool_call_id": call.id, "content": output})
68
+ ```
69
+
70
+ ## Responses
71
+
72
+ ```python
73
+ from openai import OpenAI
74
+ from threadlessai import NOTE_RESULT, Threadless, is_note_call
75
+
76
+ openai = OpenAI()
77
+ threadless = Threadless()
78
+ memory = threadless.memories.create()
79
+
80
+ new_items = [{"role": "user", "content": "Hi, I'm planning a launch."}]
81
+ while True:
82
+ request = threadless.responses.decorate(
83
+ memory["id"],
84
+ new_items,
85
+ {"model": "gpt-5", "tools": [threadless.responses.note_tool]},
86
+ )
87
+ response = openai.responses.create(**request)
88
+ threadless.responses.report(memory["id"], response)
89
+
90
+ calls = [item for item in response.output if item.type == "function_call"]
91
+ if not calls:
92
+ print(response.output_text)
93
+ break
94
+ new_items = [item.model_dump(mode="json", exclude_none=True) for item in response.output]
95
+ for call in calls:
96
+ output = NOTE_RESULT if is_note_call(call.name) else run_tool(call)
97
+ new_items.append({"type": "function_call_output", "call_id": call.call_id, "output": output})
98
+ ```
99
+
100
+ ## Notes
101
+
102
+ - **Decorate never fails your model call** on a network error, a timeout, a 5xx, 429 or 408: it builds the request from a local tail of recent turns and tells `on_error`. Any other 4xx raises `ThreadlessError` (`status`, `code`, `message`, `hint`).
103
+ - **Report runs in the background** and never raises. The next decorate for the same memory waits up to 2 seconds for it. Call `threadless.close()` (or use `with Threadless() as threadless:`) to wait for reports still in flight.
104
+ - **Async:** `AsyncThreadless` has the same surface; `await client.chat.decorate(...)`, and `client.chat.report(...)` schedules a task. Close it with `await client.aclose()`.
105
+ - `client.memories.get(id)`, `.delete(id)` and `.export(id)` manage a memory.
@@ -0,0 +1,81 @@
1
+ # threadlessai
2
+
3
+ Threadless AI: memory for agents. Decorate every model call, report every reply.
4
+
5
+ ```
6
+ pip install threadlessai
7
+ ```
8
+
9
+ Set `THREADLESS_API_KEY` (a `tl_live_…` or `tl_test_…` project key), or pass `api_key=`.
10
+
11
+ ## Chat Completions
12
+
13
+ ```python
14
+ from openai import OpenAI
15
+ from threadlessai import NOTE_RESULT, Threadless, is_note_call
16
+
17
+ openai = OpenAI()
18
+ threadless = Threadless()
19
+ memory = threadless.memories.create() # one per conversation
20
+
21
+ new_messages = [
22
+ {"role": "system", "content": "You are a helpful assistant."},
23
+ {"role": "user", "content": "Hi, I'm planning a launch."},
24
+ ]
25
+ while True:
26
+ request = threadless.chat.decorate(
27
+ memory["id"],
28
+ new_messages,
29
+ {"model": "gpt-5", "tools": [threadless.chat.note_tool]},
30
+ )
31
+ completion = openai.chat.completions.create(**request)
32
+ threadless.chat.report(memory["id"], completion) # background, never raises
33
+
34
+ message = completion.choices[0].message
35
+ calls = message.tool_calls or []
36
+ if not calls:
37
+ print(message.content)
38
+ break
39
+ # Hand over only what is new since the last decorate: the reply and the tool results.
40
+ new_messages = [message]
41
+ for call in calls:
42
+ output = NOTE_RESULT if is_note_call(call.function.name) else run_tool(call)
43
+ new_messages.append({"role": "tool", "tool_call_id": call.id, "content": output})
44
+ ```
45
+
46
+ ## Responses
47
+
48
+ ```python
49
+ from openai import OpenAI
50
+ from threadlessai import NOTE_RESULT, Threadless, is_note_call
51
+
52
+ openai = OpenAI()
53
+ threadless = Threadless()
54
+ memory = threadless.memories.create()
55
+
56
+ new_items = [{"role": "user", "content": "Hi, I'm planning a launch."}]
57
+ while True:
58
+ request = threadless.responses.decorate(
59
+ memory["id"],
60
+ new_items,
61
+ {"model": "gpt-5", "tools": [threadless.responses.note_tool]},
62
+ )
63
+ response = openai.responses.create(**request)
64
+ threadless.responses.report(memory["id"], response)
65
+
66
+ calls = [item for item in response.output if item.type == "function_call"]
67
+ if not calls:
68
+ print(response.output_text)
69
+ break
70
+ new_items = [item.model_dump(mode="json", exclude_none=True) for item in response.output]
71
+ for call in calls:
72
+ output = NOTE_RESULT if is_note_call(call.name) else run_tool(call)
73
+ new_items.append({"type": "function_call_output", "call_id": call.call_id, "output": output})
74
+ ```
75
+
76
+ ## Notes
77
+
78
+ - **Decorate never fails your model call** on a network error, a timeout, a 5xx, 429 or 408: it builds the request from a local tail of recent turns and tells `on_error`. Any other 4xx raises `ThreadlessError` (`status`, `code`, `message`, `hint`).
79
+ - **Report runs in the background** and never raises. The next decorate for the same memory waits up to 2 seconds for it. Call `threadless.close()` (or use `with Threadless() as threadless:`) to wait for reports still in flight.
80
+ - **Async:** `AsyncThreadless` has the same surface; `await client.chat.decorate(...)`, and `client.chat.report(...)` schedules a task. Close it with `await client.aclose()`.
81
+ - `client.memories.get(id)`, `.delete(id)` and `.export(id)` manage a memory.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "threadlessai"
7
+ version = "0.1.0"
8
+ description = "Threadless AI: memory for agents. Decorate every model call, report every reply."
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ requires-python = ">=3.9"
12
+ dependencies = ["httpx>=0.23"]
13
+ keywords = ["memory", "agents", "llm", "openai", "context", "long-term memory"]
14
+ classifiers = [
15
+ "License :: Other/Proprietary License",
16
+ "Programming Language :: Python :: 3",
17
+ "Typing :: Typed",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://threadlessai.com"
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=7"]
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/threadlessai"]
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
31
+
32
+ [tool.hatch.build.targets.sdist]
33
+ include = ["src", "LICENSE", "README.md"]
@@ -0,0 +1,37 @@
1
+ """Threadless AI: memory for agents. Decorate every model call, report every reply."""
2
+
3
+ from ._async_client import AsyncSurface, AsyncThreadless
4
+ from ._client import Surface, Threadless
5
+ from ._core import ErrorContext, ThreadlessError, trim_tail
6
+ from ._note_tool import (
7
+ NOTE_RESULT,
8
+ NOTE_TOOL_DESCRIPTION,
9
+ NOTE_TOOL_NAME,
10
+ NOTE_TOOL_PARAMETERS,
11
+ Format,
12
+ NoteFact,
13
+ is_note_call,
14
+ note_tool_definition,
15
+ read_note_facts,
16
+ )
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "AsyncSurface",
22
+ "AsyncThreadless",
23
+ "ErrorContext",
24
+ "Format",
25
+ "NOTE_RESULT",
26
+ "NOTE_TOOL_DESCRIPTION",
27
+ "NOTE_TOOL_NAME",
28
+ "NOTE_TOOL_PARAMETERS",
29
+ "NoteFact",
30
+ "Surface",
31
+ "Threadless",
32
+ "ThreadlessError",
33
+ "is_note_call",
34
+ "note_tool_definition",
35
+ "read_note_facts",
36
+ "trim_tail",
37
+ ]
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any, Dict, List, Optional, Set
5
+
6
+ import httpx
7
+
8
+ from ._core import (
9
+ DEFAULT_TAIL_TOKENS,
10
+ DEFAULT_TIMEOUT,
11
+ REPORT_WAIT,
12
+ BaseClient,
13
+ OnError,
14
+ Raw,
15
+ falls_back,
16
+ items_of,
17
+ plain,
18
+ quote_id,
19
+ )
20
+ from ._note_tool import Format, note_tool_definition
21
+
22
+
23
+ class AsyncMemories:
24
+ def __init__(self, client: "AsyncThreadless") -> None:
25
+ self._client = client
26
+
27
+ async def create(self, metadata: Optional[Dict[str, str]] = None) -> Raw:
28
+ body: Raw = {} if metadata is None else {"metadata": metadata}
29
+ return await self._client._call("POST", "/memories", body)
30
+
31
+ async def get(self, memory_id: str) -> Raw:
32
+ return await self._client._call("GET", f"/memories/{quote_id(memory_id)}")
33
+
34
+ async def delete(self, memory_id: str) -> None:
35
+ await self._client._call("DELETE", f"/memories/{quote_id(memory_id)}")
36
+ self._client._forget_tail(memory_id)
37
+
38
+ async def export(self, memory_id: str) -> Raw:
39
+ return await self._client._call("GET", f"/memories/{quote_id(memory_id)}/export")
40
+
41
+
42
+ class AsyncSurface:
43
+ """One OpenAI API: `client.chat` (Chat Completions) or `client.responses`."""
44
+
45
+ def __init__(self, client: "AsyncThreadless", format: Format) -> None:
46
+ self._client = client
47
+ self._format = format
48
+ #: The note tool for this API: add it to your tools once.
49
+ self.note_tool: Raw = note_tool_definition(format)
50
+
51
+ async def decorate(self, memory_id: str, new_messages: List[Any], request: Raw) -> Raw:
52
+ """Before every model call: hand over what is new since the last call and get
53
+ back the request to submit. When we cannot be reached it never fails the
54
+ call: the request is built from the local tail of recent turns instead."""
55
+ client = self._client
56
+ pending = client._reports.get(memory_id)
57
+ if pending is not None:
58
+ await asyncio.wait({pending}, timeout=REPORT_WAIT)
59
+ messages: List[Raw] = [plain(item) for item in new_messages]
60
+ try:
61
+ out = await client._call(
62
+ "POST",
63
+ f"/memories/{quote_id(memory_id)}/decorate",
64
+ {"format": self._format, "messages": messages, "request": request},
65
+ )
66
+ decorated = out.get("request") if isinstance(out, dict) else None
67
+ client._remember_tail(memory_id, items_of(self._format, decorated))
68
+ return decorated # type: ignore[return-value]
69
+ except Exception as error:
70
+ if not falls_back(error):
71
+ raise
72
+ client._notify(error, "decorate", memory_id)
73
+ return client._fallback(self._format, memory_id, messages, request)
74
+
75
+ def report(self, memory_id: str, reply: Any) -> None:
76
+ """After the model replies: hand over the reply. Schedules a task on the
77
+ running loop and never raises."""
78
+ client = self._client
79
+ try:
80
+ body = {"format": self._format, "reply": plain(reply)}
81
+ task = asyncio.get_running_loop().create_task(client._send_report(memory_id, body))
82
+ except Exception as error:
83
+ client._notify_quietly(error, memory_id)
84
+ return
85
+ client._reports[memory_id] = task
86
+ client._tasks.add(task)
87
+ task.add_done_callback(lambda done: client._report_done(memory_id, done))
88
+
89
+
90
+ class AsyncThreadless(BaseClient):
91
+ """The Threadless AI client for asyncio."""
92
+
93
+ def __init__(
94
+ self,
95
+ api_key: Optional[str] = None,
96
+ base_url: Optional[str] = None,
97
+ timeout: float = DEFAULT_TIMEOUT,
98
+ tail_tokens: int = DEFAULT_TAIL_TOKENS,
99
+ on_error: Optional[OnError] = None,
100
+ http_client: Optional[httpx.AsyncClient] = None,
101
+ ) -> None:
102
+ super().__init__(api_key, base_url, timeout, tail_tokens, on_error)
103
+ self._owns_http = http_client is None
104
+ self._http = http_client if http_client is not None else httpx.AsyncClient()
105
+ #: Per memory: a report still in flight.
106
+ self._reports: Dict[str, "asyncio.Task[None]"] = {}
107
+ #: Every report task, held so none is garbage-collected mid-flight.
108
+ self._tasks: Set["asyncio.Task[None]"] = set()
109
+ self.memories = AsyncMemories(self)
110
+ self.chat = AsyncSurface(self, "chat.completions")
111
+ self.responses = AsyncSurface(self, "responses")
112
+
113
+ async def _call(self, method: str, path: str, body: Any = None) -> Any:
114
+ """One request to the runtime API. Raises ThreadlessError on an error response."""
115
+ response = await self._http.request(**self._request(method, path, body))
116
+ return self._parse(response)
117
+
118
+ async def _send_report(self, memory_id: str, body: Raw) -> None:
119
+ try:
120
+ await self._call("POST", f"/memories/{quote_id(memory_id)}/report", body)
121
+ except Exception as error:
122
+ self._notify_quietly(error, memory_id)
123
+
124
+ def _notify_quietly(self, error: BaseException, memory_id: str) -> None:
125
+ try:
126
+ self._notify(error, "report", memory_id)
127
+ except Exception:
128
+ pass
129
+
130
+ def _report_done(self, memory_id: str, task: "asyncio.Task[None]") -> None:
131
+ self._tasks.discard(task)
132
+ if self._reports.get(memory_id) is task:
133
+ del self._reports[memory_id]
134
+
135
+ async def aclose(self) -> None:
136
+ """Waits for reports still in flight, then closes the HTTP client."""
137
+ if self._tasks:
138
+ await asyncio.gather(*self._tasks, return_exceptions=True)
139
+ if self._owns_http:
140
+ await self._http.aclose()
141
+
142
+ async def __aenter__(self) -> "AsyncThreadless":
143
+ return self
144
+
145
+ async def __aexit__(self, *exc: Any) -> None:
146
+ await self.aclose()
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ import concurrent.futures
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ import httpx
7
+
8
+ from ._core import (
9
+ DEFAULT_TAIL_TOKENS,
10
+ DEFAULT_TIMEOUT,
11
+ REPORT_WAIT,
12
+ BaseClient,
13
+ OnError,
14
+ Raw,
15
+ falls_back,
16
+ items_of,
17
+ plain,
18
+ quote_id,
19
+ )
20
+ from ._note_tool import Format, note_tool_definition
21
+
22
+ REPORT_WORKERS = 4
23
+
24
+
25
+ class Memories:
26
+ def __init__(self, client: "Threadless") -> None:
27
+ self._client = client
28
+
29
+ def create(self, metadata: Optional[Dict[str, str]] = None) -> Raw:
30
+ body: Raw = {} if metadata is None else {"metadata": metadata}
31
+ return self._client._call("POST", "/memories", body)
32
+
33
+ def get(self, memory_id: str) -> Raw:
34
+ return self._client._call("GET", f"/memories/{quote_id(memory_id)}")
35
+
36
+ def delete(self, memory_id: str) -> None:
37
+ self._client._call("DELETE", f"/memories/{quote_id(memory_id)}")
38
+ self._client._forget_tail(memory_id)
39
+
40
+ def export(self, memory_id: str) -> Raw:
41
+ return self._client._call("GET", f"/memories/{quote_id(memory_id)}/export")
42
+
43
+
44
+ class Surface:
45
+ """One OpenAI API: `client.chat` (Chat Completions) or `client.responses`."""
46
+
47
+ def __init__(self, client: "Threadless", format: Format) -> None:
48
+ self._client = client
49
+ self._format = format
50
+ #: The note tool for this API: add it to your tools once.
51
+ self.note_tool: Raw = note_tool_definition(format)
52
+
53
+ def decorate(self, memory_id: str, new_messages: List[Any], request: Raw) -> Raw:
54
+ """Before every model call: hand over what is new since the last call and get
55
+ back the request to submit. When we cannot be reached it never fails the
56
+ call: the request is built from the local tail of recent turns instead."""
57
+ client = self._client
58
+ client._wait_for_report(memory_id)
59
+ messages: List[Raw] = [plain(item) for item in new_messages]
60
+ try:
61
+ out = client._call(
62
+ "POST",
63
+ f"/memories/{quote_id(memory_id)}/decorate",
64
+ {"format": self._format, "messages": messages, "request": request},
65
+ )
66
+ decorated = out.get("request") if isinstance(out, dict) else None
67
+ client._remember_tail(memory_id, items_of(self._format, decorated))
68
+ return decorated # type: ignore[return-value]
69
+ except Exception as error:
70
+ if not falls_back(error):
71
+ raise
72
+ client._notify(error, "decorate", memory_id)
73
+ return client._fallback(self._format, memory_id, messages, request)
74
+
75
+ def report(self, memory_id: str, reply: Any) -> None:
76
+ """After the model replies: hand over the reply. Runs in the background and
77
+ never raises."""
78
+ client = self._client
79
+ try:
80
+ body = {"format": self._format, "reply": plain(reply)}
81
+ future = client._executor.submit(client._send_report, memory_id, body)
82
+ except Exception as error:
83
+ client._notify_quietly(error, memory_id)
84
+ return
85
+ with client._lock:
86
+ client._reports[memory_id] = future
87
+ future.add_done_callback(lambda done: client._report_done(memory_id, done))
88
+
89
+
90
+ class Threadless(BaseClient):
91
+ """The Threadless AI client. Reports run on a small background thread pool."""
92
+
93
+ def __init__(
94
+ self,
95
+ api_key: Optional[str] = None,
96
+ base_url: Optional[str] = None,
97
+ timeout: float = DEFAULT_TIMEOUT,
98
+ tail_tokens: int = DEFAULT_TAIL_TOKENS,
99
+ on_error: Optional[OnError] = None,
100
+ http_client: Optional[httpx.Client] = None,
101
+ ) -> None:
102
+ super().__init__(api_key, base_url, timeout, tail_tokens, on_error)
103
+ self._owns_http = http_client is None
104
+ self._http = http_client if http_client is not None else httpx.Client()
105
+ self._executor = concurrent.futures.ThreadPoolExecutor(
106
+ max_workers=REPORT_WORKERS, thread_name_prefix="threadless-report"
107
+ )
108
+ #: Per memory: a report still in flight.
109
+ self._reports: Dict[str, "concurrent.futures.Future[None]"] = {}
110
+ self.memories = Memories(self)
111
+ self.chat = Surface(self, "chat.completions")
112
+ self.responses = Surface(self, "responses")
113
+
114
+ def _call(self, method: str, path: str, body: Any = None) -> Any:
115
+ """One request to the runtime API. Raises ThreadlessError on an error response."""
116
+ response = self._http.request(**self._request(method, path, body))
117
+ return self._parse(response)
118
+
119
+ def _send_report(self, memory_id: str, body: Raw) -> None:
120
+ try:
121
+ self._call("POST", f"/memories/{quote_id(memory_id)}/report", body)
122
+ except Exception as error:
123
+ self._notify_quietly(error, memory_id)
124
+
125
+ def _notify_quietly(self, error: BaseException, memory_id: str) -> None:
126
+ try:
127
+ self._notify(error, "report", memory_id)
128
+ except Exception:
129
+ pass
130
+
131
+ def _report_done(self, memory_id: str, future: "concurrent.futures.Future[None]") -> None:
132
+ with self._lock:
133
+ if self._reports.get(memory_id) is future:
134
+ del self._reports[memory_id]
135
+
136
+ def _wait_for_report(self, memory_id: str) -> None:
137
+ with self._lock:
138
+ pending = self._reports.get(memory_id)
139
+ if pending is not None:
140
+ concurrent.futures.wait([pending], timeout=REPORT_WAIT)
141
+
142
+ def close(self) -> None:
143
+ """Waits for reports still in flight, then closes the HTTP client."""
144
+ self._executor.shutdown(wait=True)
145
+ if self._owns_http:
146
+ self._http.close()
147
+
148
+ def __enter__(self) -> "Threadless":
149
+ return self
150
+
151
+ def __exit__(self, *exc: Any) -> None:
152
+ self.close()
@@ -0,0 +1,217 @@
1
+ """What the sync and async clients share: errors, the local tail, the HTTP shape."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import os
8
+ import threading
9
+ from collections import OrderedDict
10
+ from typing import Any, Callable, Dict, List, Optional, TypedDict
11
+ from urllib.parse import quote
12
+
13
+ import httpx
14
+
15
+ from ._note_tool import Format
16
+
17
+ Raw = Dict[str, Any]
18
+
19
+ DEFAULT_BASE_URL = "https://api.threadlessai.com/v1"
20
+ DEFAULT_TIMEOUT = 15.0
21
+ DEFAULT_TAIL_TOKENS = 60_000
22
+ #: A report still in flight holds the memory's next decorate this long at most (seconds).
23
+ REPORT_WAIT = 2.0
24
+ TAILS_KEPT = 200
25
+
26
+
27
+ class ErrorContext(TypedDict):
28
+ operation: str # "decorate" | "report"
29
+ memory_id: str
30
+
31
+
32
+ OnError = Callable[[BaseException, ErrorContext], None]
33
+
34
+
35
+ class ThreadlessError(Exception):
36
+ """An error response from the Threadless API."""
37
+
38
+ def __init__(self, status: int, code: str, message: str, hint: Optional[str] = None) -> None:
39
+ super().__init__(message)
40
+ self.status = status
41
+ self.code = code
42
+ self.message = message
43
+ self.hint = hint
44
+
45
+ def __repr__(self) -> str:
46
+ return (
47
+ f"ThreadlessError(status={self.status!r}, code={self.code!r}, "
48
+ f"message={self.message!r}, hint={self.hint!r})"
49
+ )
50
+
51
+
52
+ def _json_length(value: Any) -> int:
53
+ """The length of JSON.stringify(value) in UTF-16 code units, as JavaScript counts it."""
54
+ text = json.dumps(value, separators=(",", ":"), ensure_ascii=False, default=str)
55
+ return len(text.encode("utf-16-le")) // 2
56
+
57
+
58
+ def estimate_tokens(item: Any) -> int:
59
+ return math.ceil(_json_length(item if item is not None else "") / 4) + 4
60
+
61
+
62
+ def _get(item: Any, key: str) -> Any:
63
+ return item.get(key) if isinstance(item, dict) else None
64
+
65
+
66
+ def is_instruction(item: Any) -> bool:
67
+ return _get(item, "role") in ("system", "developer") and _get(item, "type") in (None, "message")
68
+
69
+
70
+ def is_user_message(item: Any) -> bool:
71
+ return _get(item, "role") == "user" and _get(item, "type") in (None, "message")
72
+
73
+
74
+ def trim_tail(items: List[Raw], budget: int) -> List[Raw]:
75
+ """The local tail: the instructions, then the newest history that fits, cut at
76
+ a user message so a tool call is never separated from its output."""
77
+ instructions = [item for item in items if is_instruction(item)]
78
+ history = [item for item in items if not is_instruction(item)]
79
+ used = sum(estimate_tokens(item) for item in instructions)
80
+ start = len(history)
81
+ for index in range(len(history) - 1, -1, -1):
82
+ used += estimate_tokens(history[index])
83
+ if used > budget:
84
+ break
85
+ if is_user_message(history[index]):
86
+ start = index
87
+ if start == len(history) and history:
88
+ # Not even one turn fits: keep the newest user message onward.
89
+ users = [index for index, item in enumerate(history) if is_user_message(item)]
90
+ start = users[-1] if users else len(history) - 1
91
+ return instructions + history[start:]
92
+
93
+
94
+ def _or(value: Any, default: Any) -> Any:
95
+ """JavaScript's `??`."""
96
+ return default if value is None else value
97
+
98
+
99
+ def quote_id(memory_id: str) -> str:
100
+ """encodeURIComponent."""
101
+ return quote(memory_id, safe="!~*'()")
102
+
103
+
104
+ def items_of(format: Format, request: Any) -> List[Raw]:
105
+ if request is None:
106
+ raise TypeError("decorate returned no request")
107
+ items = _get(request, "input" if format == "responses" else "messages")
108
+ return items if isinstance(items, list) else []
109
+
110
+
111
+ def plain(value: Any, **dump: Any) -> Any:
112
+ """An openai SDK object as plain JSON data, with the API's own field names
113
+ (`async`, not the Python `async_`) and no empty fields; anything else as it is."""
114
+ model_dump = getattr(value, "model_dump", None)
115
+ if callable(model_dump):
116
+ return model_dump(mode="json", by_alias=True, exclude_none=True, **dump)
117
+ return value
118
+
119
+
120
+ def falls_back(error: BaseException) -> bool:
121
+ """Whether decorate goes ahead on the local tail rather than raising.
122
+ A request we sent wrong is ours to see, not a reason to go on without memory."""
123
+ if isinstance(error, ThreadlessError):
124
+ return error.status >= 500 or error.status in (429, 408)
125
+ return isinstance(error, Exception)
126
+
127
+
128
+ class BaseClient:
129
+ def __init__(
130
+ self,
131
+ api_key: Optional[str],
132
+ base_url: Optional[str],
133
+ timeout: float,
134
+ tail_tokens: int,
135
+ on_error: Optional[OnError],
136
+ ) -> None:
137
+ key = api_key if api_key is not None else os.environ.get("THREADLESS_API_KEY")
138
+ if not key:
139
+ raise ValueError("Threadless: pass api_key or set THREADLESS_API_KEY.")
140
+ self._api_key = key
141
+ url = base_url if base_url is not None else os.environ.get("THREADLESS_BASE_URL")
142
+ url = url if url is not None else DEFAULT_BASE_URL
143
+ self.base_url = url[:-1] if url.endswith("/") else url
144
+ self.timeout = timeout
145
+ self.tail_tokens = tail_tokens
146
+ self._on_error = on_error
147
+ #: Per memory: the last request's items, for when we cannot be reached.
148
+ self.tails: "OrderedDict[str, List[Raw]]" = OrderedDict()
149
+ self._lock = threading.Lock()
150
+
151
+ def _request(self, method: str, path: str, body: Any) -> Dict[str, Any]:
152
+ headers = {"authorization": f"Bearer {self._api_key}"}
153
+ kwargs: Dict[str, Any] = {
154
+ "method": method,
155
+ "url": f"{self.base_url}{path}",
156
+ "headers": headers,
157
+ "timeout": self.timeout,
158
+ }
159
+ if body is not None:
160
+ headers["content-type"] = "application/json"
161
+ kwargs["content"] = json.dumps(body, separators=(",", ":")).encode("utf-8")
162
+ return kwargs
163
+
164
+ @staticmethod
165
+ def _parse(response: httpx.Response) -> Any:
166
+ if response.status_code == 204:
167
+ return None
168
+ text = response.text
169
+ data = json.loads(text) if text else {}
170
+ if not response.is_success:
171
+ error = data.get("error") if isinstance(data, dict) else None
172
+ error = error if isinstance(error, dict) else {}
173
+ raise ThreadlessError(
174
+ response.status_code,
175
+ _or(error.get("code"), "internal"),
176
+ _or(error.get("message"), f"HTTP {response.status_code}"),
177
+ error.get("hint"),
178
+ )
179
+ return data
180
+
181
+ def _notify(self, error: BaseException, operation: str, memory_id: str) -> None:
182
+ if self._on_error is not None:
183
+ self._on_error(error, {"operation": operation, "memory_id": memory_id})
184
+
185
+ def _remember_tail(self, memory_id: str, items: List[Raw]) -> None:
186
+ trimmed = trim_tail(items, self.tail_tokens)
187
+ with self._lock:
188
+ self.tails.pop(memory_id, None)
189
+ self.tails[memory_id] = trimmed
190
+ while len(self.tails) > TAILS_KEPT:
191
+ self.tails.popitem(last=False)
192
+
193
+ def _forget_tail(self, memory_id: str) -> None:
194
+ with self._lock:
195
+ self.tails.pop(memory_id, None)
196
+
197
+ def _fallback(
198
+ self, format: Format, memory_id: str, new_messages: List[Raw], request: Raw
199
+ ) -> Raw:
200
+ """The request built from the local tail of recent turns."""
201
+ with self._lock:
202
+ tail = list(self.tails.get(memory_id, []))
203
+ instructions = [item for item in new_messages if is_instruction(item)]
204
+ items = trim_tail(
205
+ (instructions or [item for item in tail if is_instruction(item)])
206
+ + [item for item in tail if not is_instruction(item)]
207
+ + [item for item in new_messages if not is_instruction(item)],
208
+ self.tail_tokens,
209
+ )
210
+ self._remember_tail(memory_id, items)
211
+ rest = {
212
+ key: value
213
+ for key, value in request.items()
214
+ if key not in ("messages", "input", "previous_response_id", "store")
215
+ }
216
+ rest["input" if format == "responses" else "messages"] = items
217
+ return rest
@@ -0,0 +1,147 @@
1
+ """The note tool: the customer registers it like any tool. Nothing runs; the
2
+ call is the note. When part of the history is leaving live context the
3
+ request asks the model for its facts, and the model answers with one call.
4
+ The facts reach us in the reply, so the handler only returns NOTE_RESULT.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from typing import Any, Dict, List, Literal, Optional, TypedDict
12
+
13
+ Format = Literal["chat.completions", "responses"]
14
+
15
+ NOTE_TOOL_NAME = "memory_note"
16
+
17
+ NOTE_RESULT = "noted"
18
+
19
+ NOTE_TOOL_DESCRIPTION = (
20
+ "Writes facts into this conversation's long-term memory. Nothing runs: the call itself is "
21
+ "the note. When the system asks for facts from history that is leaving live context, answer "
22
+ "with one call holding every fact: one entry per fact, a knowledge-graph triple readable on "
23
+ "its own, identifiers and the source's own terms verbatim. Once those turns leave, these "
24
+ "facts are what stays in view of them. When they hold nothing needed later, one entry with "
25
+ "an empty note."
26
+ )
27
+
28
+ NOTE_TOOL_PARAMETERS: Dict[str, Any] = {
29
+ "type": "object",
30
+ "properties": {
31
+ "notes": {
32
+ "type": "array",
33
+ "description": "Every fact being written, one entry each.",
34
+ "items": {
35
+ "type": "object",
36
+ "properties": {
37
+ "note": {
38
+ "type": "string",
39
+ "description": (
40
+ "One fact, as a sentence readable on its own. Empty when the history "
41
+ "leaving holds nothing needed later."
42
+ ),
43
+ },
44
+ "subject": {
45
+ "type": "string",
46
+ "description": (
47
+ "The triple's subject entity, resolved to its canonical name (entity "
48
+ "resolution): the name earlier facts already use, else the most "
49
+ "specific name in the conversation."
50
+ ),
51
+ },
52
+ "object": {
53
+ "type": "string",
54
+ "description": (
55
+ "The triple's object entity, resolved to its canonical name (entity "
56
+ "resolution). The fact becomes the edge between subject and object "
57
+ "in a knowledge graph."
58
+ ),
59
+ },
60
+ "milestone": {
61
+ "type": "boolean",
62
+ "description": (
63
+ "True when the fact says where the work stands (a milestone reached, "
64
+ "a point of alignment, what is next or blocked)."
65
+ ),
66
+ },
67
+ },
68
+ "required": ["note", "subject", "object", "milestone"],
69
+ "additionalProperties": False,
70
+ },
71
+ },
72
+ },
73
+ "required": ["notes"],
74
+ "additionalProperties": False,
75
+ }
76
+
77
+
78
+ def note_tool_definition(format: Format) -> Dict[str, Any]:
79
+ """The note tool as a tool definition for the given API."""
80
+ if format == "responses":
81
+ return {
82
+ "type": "function",
83
+ "name": NOTE_TOOL_NAME,
84
+ "description": NOTE_TOOL_DESCRIPTION,
85
+ "parameters": NOTE_TOOL_PARAMETERS,
86
+ "strict": True,
87
+ }
88
+ return {
89
+ "type": "function",
90
+ "function": {
91
+ "name": NOTE_TOOL_NAME,
92
+ "description": NOTE_TOOL_DESCRIPTION,
93
+ "parameters": NOTE_TOOL_PARAMETERS,
94
+ "strict": True,
95
+ },
96
+ }
97
+
98
+
99
+ class NoteFact(TypedDict):
100
+ text: str
101
+ # Subject then object, as the model resolved them.
102
+ entities: List[str]
103
+ milestone: bool
104
+
105
+
106
+ _WHITESPACE = re.compile(r"\s+")
107
+ _BULLET = re.compile(r"^(?:[-*•]|[0-9]+[.)])\s+")
108
+
109
+
110
+ def read_note_facts(args: Any) -> Optional[List[NoteFact]]:
111
+ """The facts one note call carries. Malformed entries are left out; None when it
112
+ carries no notes list."""
113
+ parsed = args
114
+ if isinstance(parsed, str):
115
+ try:
116
+ parsed = json.loads(parsed or "{}")
117
+ except ValueError:
118
+ return None
119
+ notes = parsed.get("notes") if isinstance(parsed, dict) else None
120
+ if not isinstance(notes, list):
121
+ return None
122
+ facts: List[NoteFact] = []
123
+ for entry in notes:
124
+ if not isinstance(entry, dict):
125
+ continue
126
+ note = entry.get("note")
127
+ if not isinstance(note, str):
128
+ continue
129
+ # The fact's two entities, subject then object; a note of several lines is several
130
+ # facts that share them.
131
+ cleaned = (
132
+ _WHITESPACE.sub(" ", entity).strip()
133
+ for entity in (entry.get("subject"), entry.get("object"))
134
+ if isinstance(entity, str)
135
+ )
136
+ entities: List[str] = list(dict.fromkeys(entity for entity in cleaned if entity))
137
+ milestone = entry.get("milestone") is True
138
+ for line in note.split("\n"):
139
+ text = _WHITESPACE.sub(" ", _BULLET.sub("", line.strip())).strip()
140
+ if text:
141
+ facts.append({"text": text, "entities": list(entities), "milestone": milestone})
142
+ return facts
143
+
144
+
145
+ def is_note_call(name: Optional[str]) -> bool:
146
+ """Whether a tool call is to the note tool. Answer it with NOTE_RESULT."""
147
+ return name == NOTE_TOOL_NAME
File without changes