curu-memory 0.2.0__py3-none-any.whl
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.
- curu_memory/__init__.py +3 -0
- curu_memory/__main__.py +1032 -0
- curu_memory/access.py +562 -0
- curu_memory/audit.py +63 -0
- curu_memory/auth.py +41 -0
- curu_memory/browser.py +396 -0
- curu_memory/config.py +308 -0
- curu_memory/embedders.py +118 -0
- curu_memory/errors.py +96 -0
- curu_memory/indexing.py +440 -0
- curu_memory/media.py +199 -0
- curu_memory/oidc.py +437 -0
- curu_memory/okf.py +271 -0
- curu_memory/ports.py +344 -0
- curu_memory/render.py +61 -0
- curu_memory/retrieval.py +570 -0
- curu_memory/server.py +272 -0
- curu_memory/store_sqlite.py +551 -0
- curu_memory-0.2.0.dist-info/METADATA +723 -0
- curu_memory-0.2.0.dist-info/RECORD +23 -0
- curu_memory-0.2.0.dist-info/WHEEL +4 -0
- curu_memory-0.2.0.dist-info/entry_points.txt +2 -0
- curu_memory-0.2.0.dist-info/licenses/LICENSE +21 -0
curu_memory/__main__.py
ADDED
|
@@ -0,0 +1,1032 @@
|
|
|
1
|
+
"""The `curu-memory` command. It is the composition root: it selects the adapters.
|
|
2
|
+
|
|
3
|
+
Only this module knows the transport of the MCP server (constitution, article V).
|
|
4
|
+
Each message for a person goes to stderr, because stdout carries the MCP messages
|
|
5
|
+
on stdio and the JSON output of `--json`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, Any, NoReturn
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
from curu_memory.access import (
|
|
21
|
+
ANONYMOUS,
|
|
22
|
+
DEFAULT_GRANT_DAYS,
|
|
23
|
+
MAX_GRANT_DAYS,
|
|
24
|
+
ROLE_NAMES,
|
|
25
|
+
AccessRefused,
|
|
26
|
+
AccessStore,
|
|
27
|
+
Agent,
|
|
28
|
+
Grant,
|
|
29
|
+
Principal,
|
|
30
|
+
check_handle,
|
|
31
|
+
readable_spaces_of,
|
|
32
|
+
role_in,
|
|
33
|
+
role_of_agent,
|
|
34
|
+
)
|
|
35
|
+
from curu_memory.audit import CHANGED, REFUSED, AuditLog
|
|
36
|
+
from curu_memory.auth import MIN_TOKEN_LENGTH
|
|
37
|
+
from curu_memory.config import ConfigError, Settings
|
|
38
|
+
from curu_memory.embedders import OpenAICompatibleEmbedder
|
|
39
|
+
from curu_memory.errors import CuruMemoryError, IndexMissing, InvalidArgument
|
|
40
|
+
from curu_memory.indexing import build_index
|
|
41
|
+
from curu_memory.media import MAX_BYTES, read_query_media
|
|
42
|
+
from curu_memory.ports import GLOBAL, Embedder, IndexLocked, parse_space, space_file_name
|
|
43
|
+
from curu_memory.retrieval import DEFAULT_LIMIT, MAX_LIMIT, SearchResult, retriever_for
|
|
44
|
+
from curu_memory.store_sqlite import SqliteEmbeddingCache, SqliteIndexStore
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from mcp.server.mcpserver import MCPServer
|
|
48
|
+
from starlette.applications import Starlette
|
|
49
|
+
|
|
50
|
+
# EX_CONFIG of sysexits.h: a configuration error. Exit 2 belongs to usage errors.
|
|
51
|
+
EXIT_CONFIG = 78
|
|
52
|
+
EXIT_PROBLEM = 1
|
|
53
|
+
EXIT_USAGE = 2
|
|
54
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
55
|
+
#: Ports in the 6000 series, away from the common 8000 and 8001 (the owner, 2026-11-01).
|
|
56
|
+
DEFAULT_PORT = 6474
|
|
57
|
+
DEFAULT_BROWSE_PORT = 6475
|
|
58
|
+
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
|
|
59
|
+
MAKE_TOKEN = "python -c 'import secrets; print(secrets.token_urlsafe(32))'"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
63
|
+
parser = argparse.ArgumentParser(
|
|
64
|
+
prog="curu-memory",
|
|
65
|
+
description=(
|
|
66
|
+
"Index OKF (Open Knowledge Format) concept files, and search them from the command "
|
|
67
|
+
"line or through MCP (the Model Context Protocol)."
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
commands = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
|
71
|
+
|
|
72
|
+
index = commands.add_parser(
|
|
73
|
+
"index",
|
|
74
|
+
help="Build the index of the knowledge from the concept files.",
|
|
75
|
+
description=(
|
|
76
|
+
"Build the index of the knowledge from the concept files. "
|
|
77
|
+
"The command reports each skipped file, each warning and each broken link."
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
index.add_argument(
|
|
81
|
+
"--concepts",
|
|
82
|
+
type=Path,
|
|
83
|
+
metavar="DIR",
|
|
84
|
+
help=(
|
|
85
|
+
"The directory of the concept files. "
|
|
86
|
+
"When you give this option, the command ignores CURU_MEMORY_CONCEPTS_DIR."
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
index.add_argument(
|
|
90
|
+
"--space",
|
|
91
|
+
type=_space,
|
|
92
|
+
default=GLOBAL,
|
|
93
|
+
metavar="SPACE",
|
|
94
|
+
help=(
|
|
95
|
+
"The space of the concepts: global, team/<slug> or personal/<handle>. "
|
|
96
|
+
"A slug or a handle has 1 to 40 characters from a to z, 0 to 9 and -. "
|
|
97
|
+
"Each space has its own index. The default is global."
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
index.add_argument("--json", action="store_true", help="Print the report as JSON.")
|
|
101
|
+
|
|
102
|
+
search = commands.add_parser(
|
|
103
|
+
"search",
|
|
104
|
+
help="Search the index and print the matching concepts.",
|
|
105
|
+
description=(
|
|
106
|
+
"Search the index and print the matching concepts, best first. "
|
|
107
|
+
"Give a question in words, or an image, an audio clip or a video with --media. "
|
|
108
|
+
"The command gives the same results as the search_knowledge tool, "
|
|
109
|
+
"or as the search_knowledge_by_media tool for media."
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
search.add_argument("question", nargs="?", help="The question, in words.")
|
|
113
|
+
search.add_argument(
|
|
114
|
+
"--media",
|
|
115
|
+
type=Path,
|
|
116
|
+
metavar="FILE",
|
|
117
|
+
help=(
|
|
118
|
+
"Search with this image, audio clip or video in place of a question. "
|
|
119
|
+
"Give a PNG, JPEG, WebP, WAV, FLAC, MP3, Ogg, MP4, MOV, WebM or MKV file "
|
|
120
|
+
"of 10 MB or less. "
|
|
121
|
+
"It needs an embedder."
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
search.add_argument(
|
|
125
|
+
"--limit",
|
|
126
|
+
type=_limit,
|
|
127
|
+
default=DEFAULT_LIMIT,
|
|
128
|
+
help=f"The maximum number of results, from 1 to {MAX_LIMIT}. The default is 10.",
|
|
129
|
+
)
|
|
130
|
+
search.add_argument(
|
|
131
|
+
"--type",
|
|
132
|
+
action="append",
|
|
133
|
+
default=[],
|
|
134
|
+
dest="types",
|
|
135
|
+
metavar="TYPE",
|
|
136
|
+
help="Return only concepts of this OKF type. Repeat the option for more types.",
|
|
137
|
+
)
|
|
138
|
+
search.add_argument(
|
|
139
|
+
"--tag",
|
|
140
|
+
action="append",
|
|
141
|
+
default=[],
|
|
142
|
+
dest="tags",
|
|
143
|
+
metavar="TAG",
|
|
144
|
+
help=(
|
|
145
|
+
"Return only concepts with this tag. "
|
|
146
|
+
"Repeat the option, and each concept must have all the tags."
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
search.add_argument(
|
|
150
|
+
"--json", action="store_true", help="Print the same JSON object as the tool result."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
serve = commands.add_parser(
|
|
154
|
+
"serve",
|
|
155
|
+
help="Serve the MCP tools on stdio or on streamable HTTP.",
|
|
156
|
+
description=(
|
|
157
|
+
"Serve the MCP tools on stdio or on streamable HTTP. "
|
|
158
|
+
"HTTP needs CURU_MEMORY_OIDC_ISSUER, or a token in CURU_MEMORY_HTTP_TOKEN."
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
serve.add_argument(
|
|
162
|
+
"--transport",
|
|
163
|
+
choices=["stdio", "http"],
|
|
164
|
+
default="stdio",
|
|
165
|
+
help=(
|
|
166
|
+
"The MCP transport. Use stdio when the MCP client starts curu-memory. "
|
|
167
|
+
"Use http for remote clients."
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
serve.add_argument(
|
|
171
|
+
"--host",
|
|
172
|
+
default=DEFAULT_HOST,
|
|
173
|
+
help=(
|
|
174
|
+
f"The address that the HTTP server listens on. The default is {DEFAULT_HOST}. "
|
|
175
|
+
"Use 0.0.0.0 for all network interfaces."
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
serve.add_argument(
|
|
179
|
+
"--port",
|
|
180
|
+
type=_port,
|
|
181
|
+
default=DEFAULT_PORT,
|
|
182
|
+
help=f"The port of the HTTP server, from 1 to 65535. The default is {DEFAULT_PORT}.",
|
|
183
|
+
)
|
|
184
|
+
serve.add_argument(
|
|
185
|
+
"--grant",
|
|
186
|
+
type=int,
|
|
187
|
+
metavar="ID",
|
|
188
|
+
help=(
|
|
189
|
+
"On stdio, act as the agent of this grant and read only its space. "
|
|
190
|
+
"The limit is voluntary: a process can leave the option out."
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
browse = commands.add_parser(
|
|
195
|
+
"browse",
|
|
196
|
+
help="Serve a read-only web browser of the knowledge.",
|
|
197
|
+
description=(
|
|
198
|
+
"Serve a read-only web browser of the knowledge. A person can search the "
|
|
199
|
+
"knowledge and follow the links of a concept. On an address that other computers "
|
|
200
|
+
"can reach, the browser needs the token in CURU_MEMORY_HTTP_TOKEN."
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
browse.add_argument(
|
|
204
|
+
"--host",
|
|
205
|
+
default=DEFAULT_HOST,
|
|
206
|
+
help=(
|
|
207
|
+
f"The address that the browser listens on. The default is {DEFAULT_HOST}. "
|
|
208
|
+
"An address other than 127.0.0.1, ::1 or localhost needs the token "
|
|
209
|
+
"in CURU_MEMORY_HTTP_TOKEN."
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
browse.add_argument(
|
|
213
|
+
"--port",
|
|
214
|
+
type=_port,
|
|
215
|
+
default=DEFAULT_BROWSE_PORT,
|
|
216
|
+
help=f"The port of the browser, from 1 to 65535. The default is {DEFAULT_BROWSE_PORT}.",
|
|
217
|
+
)
|
|
218
|
+
_access_parser(commands)
|
|
219
|
+
return parser
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _access_parser(commands: argparse._SubParsersAction) -> None:
|
|
223
|
+
access = commands.add_parser(
|
|
224
|
+
"access",
|
|
225
|
+
help="Register persons, teams and agents, give and revoke grants, and show who reads.",
|
|
226
|
+
description=(
|
|
227
|
+
"Register persons, teams and agents, give and revoke grants, and show who reads "
|
|
228
|
+
"which space. The command changes "
|
|
229
|
+
"access.sqlite in the data directory, and writes each change to audit.jsonl."
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
parts = access.add_subparsers(dest="part", required=True, metavar="PART")
|
|
233
|
+
|
|
234
|
+
person = parts.add_parser(
|
|
235
|
+
"person",
|
|
236
|
+
help="Add, link or remove a person.",
|
|
237
|
+
description=(
|
|
238
|
+
"Add, link or remove a person. "
|
|
239
|
+
"The first person is an instance owner: admin on global and on each team space."
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
person_actions = person.add_subparsers(dest="action", required=True, metavar="ACTION")
|
|
243
|
+
add = person_actions.add_parser(
|
|
244
|
+
"add",
|
|
245
|
+
help="Register a person.",
|
|
246
|
+
description=("Register a person. The person reads global and their own personal space."),
|
|
247
|
+
)
|
|
248
|
+
add.add_argument("handle", type=_handle, help=HANDLE_HELP)
|
|
249
|
+
add.add_argument(
|
|
250
|
+
"--owner",
|
|
251
|
+
action="store_true",
|
|
252
|
+
help="Make the person an instance owner: admin on global and on each team space.",
|
|
253
|
+
)
|
|
254
|
+
_subject_options(add, required=False)
|
|
255
|
+
link = person_actions.add_parser(
|
|
256
|
+
"link",
|
|
257
|
+
help="Link a person to the subject of their OIDC tokens.",
|
|
258
|
+
description=(
|
|
259
|
+
"Link a person to the issuer and the subject (sub) of their OIDC tokens. "
|
|
260
|
+
"The link replaces an old link of the person."
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
link.add_argument("handle", type=_handle, help=HANDLE_HELP)
|
|
264
|
+
_subject_options(link, required=True)
|
|
265
|
+
remove = person_actions.add_parser(
|
|
266
|
+
"remove",
|
|
267
|
+
help="Remove a person.",
|
|
268
|
+
description=(
|
|
269
|
+
"Remove a person and their team memberships. "
|
|
270
|
+
"The command refuses to remove the last instance owner."
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
remove.add_argument("handle", type=_handle, help=HANDLE_HELP)
|
|
274
|
+
|
|
275
|
+
team = parts.add_parser(
|
|
276
|
+
"team",
|
|
277
|
+
help="Add or remove a team.",
|
|
278
|
+
description=(
|
|
279
|
+
"Add or remove a team. Each member of a team has the role write on its team space."
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
team_actions = team.add_subparsers(dest="action", required=True, metavar="ACTION")
|
|
283
|
+
for name, text in (
|
|
284
|
+
("add", "Register a team."),
|
|
285
|
+
("remove", "Remove a team and its memberships. The persons stay."),
|
|
286
|
+
):
|
|
287
|
+
action = team_actions.add_parser(name, help=text, description=text)
|
|
288
|
+
action.add_argument("slug", type=_slug, help=SLUG_HELP)
|
|
289
|
+
|
|
290
|
+
member = parts.add_parser(
|
|
291
|
+
"member",
|
|
292
|
+
help="Add or remove a team member.",
|
|
293
|
+
description=("Add a registered person to a team, or remove the person from the team."),
|
|
294
|
+
)
|
|
295
|
+
member_actions = member.add_subparsers(dest="action", required=True, metavar="ACTION")
|
|
296
|
+
for name, text in (
|
|
297
|
+
("add", "Add a person to a team."),
|
|
298
|
+
("remove", "Remove a person from a team."),
|
|
299
|
+
):
|
|
300
|
+
action = member_actions.add_parser(name, help=text, description=text)
|
|
301
|
+
action.add_argument("slug", type=_slug, help=SLUG_HELP)
|
|
302
|
+
action.add_argument("handle", type=_handle, help=HANDLE_HELP)
|
|
303
|
+
|
|
304
|
+
agent = parts.add_parser(
|
|
305
|
+
"agent",
|
|
306
|
+
help="Add or remove an agent that acts for a person.",
|
|
307
|
+
description=(
|
|
308
|
+
"Add or remove an agent that acts for a person. An Authentik service account is "
|
|
309
|
+
"found by its subject (sub), and a rauthy client by its client identifier."
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
agent_actions = agent.add_subparsers(dest="action", required=True, metavar="ACTION")
|
|
313
|
+
agent_add = agent_actions.add_parser(
|
|
314
|
+
"add",
|
|
315
|
+
help="Register an agent.",
|
|
316
|
+
description="Register an agent. It reads nothing until it has a grant.",
|
|
317
|
+
)
|
|
318
|
+
agent_add.add_argument("name", type=_agent_name, help=AGENT_HELP)
|
|
319
|
+
agent_add.add_argument(
|
|
320
|
+
"--person",
|
|
321
|
+
type=_handle,
|
|
322
|
+
required=True,
|
|
323
|
+
metavar="HANDLE",
|
|
324
|
+
help="The person that the agent acts for.",
|
|
325
|
+
)
|
|
326
|
+
agent_add.add_argument(
|
|
327
|
+
"--issuer",
|
|
328
|
+
type=_issuer_url,
|
|
329
|
+
required=True,
|
|
330
|
+
metavar="URL",
|
|
331
|
+
help="The issuer URL of the OIDC provider. Give the same text as CURU_MEMORY_OIDC_ISSUER.",
|
|
332
|
+
)
|
|
333
|
+
identity = agent_add.add_mutually_exclusive_group(required=True)
|
|
334
|
+
identity.add_argument(
|
|
335
|
+
"--subject", metavar="SUB", help="The subject identifier (sub) in the tokens of the agent."
|
|
336
|
+
)
|
|
337
|
+
identity.add_argument(
|
|
338
|
+
"--client",
|
|
339
|
+
metavar="ID",
|
|
340
|
+
help=(
|
|
341
|
+
"The client identifier of the agent, for a token with no sub or with this "
|
|
342
|
+
"identifier as sub."
|
|
343
|
+
),
|
|
344
|
+
)
|
|
345
|
+
agent_remove = agent_actions.add_parser(
|
|
346
|
+
"remove",
|
|
347
|
+
help="Remove an agent and its grants.",
|
|
348
|
+
description="Remove an agent and its grants.",
|
|
349
|
+
)
|
|
350
|
+
agent_remove.add_argument("name", type=_agent_name, help=AGENT_HELP)
|
|
351
|
+
|
|
352
|
+
grant = parts.add_parser(
|
|
353
|
+
"grant",
|
|
354
|
+
help="Give an agent a role in one space.",
|
|
355
|
+
description=(
|
|
356
|
+
"Give an agent a role in one space for some days. The grant cannot give more "
|
|
357
|
+
"than the person of the agent has, now or later."
|
|
358
|
+
),
|
|
359
|
+
)
|
|
360
|
+
grant.add_argument("agent", type=_agent_name, help=AGENT_HELP)
|
|
361
|
+
grant.add_argument(
|
|
362
|
+
"--space",
|
|
363
|
+
type=_space,
|
|
364
|
+
required=True,
|
|
365
|
+
metavar="SPACE",
|
|
366
|
+
help="The space: global, team/<slug> or personal/<handle>.",
|
|
367
|
+
)
|
|
368
|
+
grant.add_argument(
|
|
369
|
+
"--role",
|
|
370
|
+
choices=sorted(ROLE_NAMES.values()),
|
|
371
|
+
required=True,
|
|
372
|
+
help="The role in the space: read, write or admin.",
|
|
373
|
+
)
|
|
374
|
+
grant.add_argument(
|
|
375
|
+
"--days",
|
|
376
|
+
type=_days,
|
|
377
|
+
default=DEFAULT_GRANT_DAYS,
|
|
378
|
+
metavar="N",
|
|
379
|
+
help=(
|
|
380
|
+
f"The number of days of the grant, from 1 to {MAX_GRANT_DAYS}. "
|
|
381
|
+
f"The default is {DEFAULT_GRANT_DAYS}."
|
|
382
|
+
),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
revoke = parts.add_parser(
|
|
386
|
+
"revoke", help="End a grant.", description="End a grant from the next request."
|
|
387
|
+
)
|
|
388
|
+
revoke.add_argument("id", type=int, metavar="ID", help="The identifier of the grant.")
|
|
389
|
+
|
|
390
|
+
show = parts.add_parser(
|
|
391
|
+
"show",
|
|
392
|
+
help="Show the persons, the teams, the roles, and the agents with their live grants.",
|
|
393
|
+
description=(
|
|
394
|
+
"Show the persons, the teams, the members and the role of each person in each space. "
|
|
395
|
+
"It also lists each agent and its live grants."
|
|
396
|
+
),
|
|
397
|
+
)
|
|
398
|
+
show.add_argument("--json", action="store_true", help="Print the access as one JSON object.")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _port(value: str) -> int:
|
|
402
|
+
try:
|
|
403
|
+
port = int(value)
|
|
404
|
+
except ValueError:
|
|
405
|
+
port = 0
|
|
406
|
+
if not 1 <= port <= 65535:
|
|
407
|
+
raise argparse.ArgumentTypeError(f"must be a whole number from 1 to 65535, not {value!r}")
|
|
408
|
+
return port
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _limit(value: str) -> int:
|
|
412
|
+
try:
|
|
413
|
+
limit = int(value)
|
|
414
|
+
except ValueError:
|
|
415
|
+
limit = 0
|
|
416
|
+
if not 1 <= limit <= MAX_LIMIT:
|
|
417
|
+
raise argparse.ArgumentTypeError(
|
|
418
|
+
f"must be a whole number from 1 to {MAX_LIMIT}, not {value!r}"
|
|
419
|
+
)
|
|
420
|
+
return limit
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _subject_options(parser: argparse.ArgumentParser, *, required: bool) -> None:
|
|
424
|
+
parser.add_argument(
|
|
425
|
+
"--issuer",
|
|
426
|
+
type=_issuer_url,
|
|
427
|
+
required=required,
|
|
428
|
+
metavar="URL",
|
|
429
|
+
help=(
|
|
430
|
+
"The issuer URL of the OIDC provider. Give the same text as CURU_MEMORY_OIDC_ISSUER."
|
|
431
|
+
),
|
|
432
|
+
)
|
|
433
|
+
parser.add_argument(
|
|
434
|
+
"--subject",
|
|
435
|
+
required=required,
|
|
436
|
+
metavar="SUB",
|
|
437
|
+
help="The subject identifier (sub) in the tokens of the person. Give it with --issuer.",
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _issuer_url(value: str) -> str:
|
|
442
|
+
parts = urlsplit(value)
|
|
443
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
444
|
+
raise argparse.ArgumentTypeError(f"the issuer must be an http or https URL, not {value!r}")
|
|
445
|
+
return value
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
HANDLE_HELP = (
|
|
449
|
+
"The short name of the person: 1 to 40 characters from a to z, 0 to 9 and -, first a letter."
|
|
450
|
+
)
|
|
451
|
+
SLUG_HELP = (
|
|
452
|
+
"The short name of the team: 1 to 40 characters from a to z, 0 to 9 and -, first a letter."
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _handle(value: str) -> str:
|
|
457
|
+
try:
|
|
458
|
+
return check_handle(value)
|
|
459
|
+
except ValueError as error:
|
|
460
|
+
raise argparse.ArgumentTypeError(str(error)) from None
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _slug(value: str) -> str:
|
|
464
|
+
try:
|
|
465
|
+
return check_handle(value, "slug")
|
|
466
|
+
except ValueError as error:
|
|
467
|
+
raise argparse.ArgumentTypeError(str(error)) from None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
AGENT_HELP = "The name of the agent: 1 to 40 characters from a to z, 0 to 9 and -, first a letter."
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _agent_name(value: str) -> str:
|
|
474
|
+
try:
|
|
475
|
+
return check_handle(value, "agent name")
|
|
476
|
+
except ValueError as error:
|
|
477
|
+
raise argparse.ArgumentTypeError(str(error)) from None
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _days(value: str) -> int:
|
|
481
|
+
try:
|
|
482
|
+
days = int(value)
|
|
483
|
+
except ValueError:
|
|
484
|
+
days = 0
|
|
485
|
+
if not 1 <= days <= MAX_GRANT_DAYS:
|
|
486
|
+
raise argparse.ArgumentTypeError(
|
|
487
|
+
f"must be a whole number from 1 to {MAX_GRANT_DAYS}, not {value!r}"
|
|
488
|
+
)
|
|
489
|
+
return days
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _space(value: str) -> str:
|
|
493
|
+
try:
|
|
494
|
+
return parse_space(value)
|
|
495
|
+
except ValueError as error:
|
|
496
|
+
raise argparse.ArgumentTypeError(str(error)) from None
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def main(argv: list[str] | None = None) -> NoReturn:
|
|
500
|
+
args = build_parser().parse_args(argv)
|
|
501
|
+
try:
|
|
502
|
+
settings = Settings.from_env()
|
|
503
|
+
except ConfigError as error:
|
|
504
|
+
_stop(str(error))
|
|
505
|
+
if args.command == "access":
|
|
506
|
+
sys.exit(_access(settings, args))
|
|
507
|
+
if args.command == "index":
|
|
508
|
+
sys.exit(_index(settings, args))
|
|
509
|
+
# The other commands read as the person of CURU_MEMORY_PRINCIPAL (ADR-016).
|
|
510
|
+
_check_principal(settings)
|
|
511
|
+
if args.command == "search":
|
|
512
|
+
sys.exit(_search(settings, args))
|
|
513
|
+
if args.command == "serve":
|
|
514
|
+
_serve(settings, args)
|
|
515
|
+
sys.exit(0)
|
|
516
|
+
if args.command == "browse":
|
|
517
|
+
_browse(settings, args)
|
|
518
|
+
sys.exit(0)
|
|
519
|
+
sys.exit(EXIT_USAGE)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def make_embedder(settings: Settings) -> Embedder | None:
|
|
523
|
+
"""The embedder of the settings, or None for a search of words only."""
|
|
524
|
+
if settings.embedder_url is None or settings.embedder_model is None:
|
|
525
|
+
return None
|
|
526
|
+
return OpenAICompatibleEmbedder(
|
|
527
|
+
settings.embedder_url,
|
|
528
|
+
settings.embedder_model,
|
|
529
|
+
token=settings.embedder_token,
|
|
530
|
+
timeout=settings.embedder_timeout,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _index(settings: Settings, args: argparse.Namespace) -> int:
|
|
535
|
+
concepts = args.concepts or settings.concepts_dir
|
|
536
|
+
if concepts is None:
|
|
537
|
+
_stop("Give the concept directory with --concepts or CURU_MEMORY_CONCEPTS_DIR.")
|
|
538
|
+
if not concepts.is_dir():
|
|
539
|
+
_stop(f"The concept directory {concepts} does not exist.")
|
|
540
|
+
store = SqliteIndexStore(settings.data_dir)
|
|
541
|
+
embedder = make_embedder(settings)
|
|
542
|
+
cache = SqliteEmbeddingCache(settings.data_dir / "embedding-cache.sqlite") if embedder else None
|
|
543
|
+
try:
|
|
544
|
+
report = build_index(
|
|
545
|
+
concepts,
|
|
546
|
+
store,
|
|
547
|
+
embedder,
|
|
548
|
+
cache=cache,
|
|
549
|
+
tier_space=args.space,
|
|
550
|
+
prompts=settings.prompts(),
|
|
551
|
+
)
|
|
552
|
+
except CuruMemoryError as error:
|
|
553
|
+
print(f"curu-memory: {error.message}", file=sys.stderr)
|
|
554
|
+
return EXIT_PROBLEM
|
|
555
|
+
except IndexLocked as error:
|
|
556
|
+
print(f"curu-memory: {error}", file=sys.stderr)
|
|
557
|
+
return EXIT_PROBLEM
|
|
558
|
+
finally:
|
|
559
|
+
if cache is not None:
|
|
560
|
+
cache.close()
|
|
561
|
+
index_path = store.path(args.space)
|
|
562
|
+
if args.json:
|
|
563
|
+
print(json.dumps({**report.to_dict(), "index": str(index_path)}, indent=2))
|
|
564
|
+
else:
|
|
565
|
+
print(report.render())
|
|
566
|
+
print(f"\nindex: {index_path}")
|
|
567
|
+
return EXIT_PROBLEM if report.skipped else 0
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def principal_of(settings: Settings) -> Principal:
|
|
571
|
+
return Principal.person(settings.principal) if settings.principal else ANONYMOUS
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _check_principal(settings: Settings) -> None:
|
|
575
|
+
if settings.principal and AccessStore(settings.data_dir).person(settings.principal) is None:
|
|
576
|
+
_stop(
|
|
577
|
+
f"CURU_MEMORY_PRINCIPAL names {settings.principal!r}, but no person has that "
|
|
578
|
+
f"handle. Register the person with: curu-memory access person add "
|
|
579
|
+
f"{settings.principal}"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def readable_for(settings: Settings) -> Callable[[], tuple[str, ...]]:
|
|
584
|
+
"""The readable spaces of the principal, read again at each request (FR-013)."""
|
|
585
|
+
principal = principal_of(settings)
|
|
586
|
+
access = AccessStore(settings.data_dir)
|
|
587
|
+
index = SqliteIndexStore(settings.data_dir)
|
|
588
|
+
return lambda: readable_spaces_of(principal, access, index)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def server_for(
|
|
592
|
+
settings: Settings,
|
|
593
|
+
inbound_token: str | None = None,
|
|
594
|
+
*,
|
|
595
|
+
stdio: bool = False,
|
|
596
|
+
resource: str | None = None,
|
|
597
|
+
grant: int | None = None,
|
|
598
|
+
) -> MCPServer:
|
|
599
|
+
"""The MCP server of `serve`, with the readable spaces of the principal.
|
|
600
|
+
|
|
601
|
+
With `CURU_MEMORY_OIDC_ISSUER` and HTTP, the person of each request comes from its
|
|
602
|
+
token (spec 012). Else the person is `CURU_MEMORY_PRINCIPAL`.
|
|
603
|
+
"""
|
|
604
|
+
from curu_memory.server import build_server
|
|
605
|
+
|
|
606
|
+
if settings.oidc_issuer and not stdio:
|
|
607
|
+
return _oidc_server(settings, resource)
|
|
608
|
+
if grant is not None:
|
|
609
|
+
return _grant_server(settings, grant)
|
|
610
|
+
return build_server(
|
|
611
|
+
settings,
|
|
612
|
+
make_embedder(settings),
|
|
613
|
+
inbound_token,
|
|
614
|
+
readable=readable_for(settings),
|
|
615
|
+
audit=AuditLog(settings.data_dir),
|
|
616
|
+
principal=principal_of(settings).label,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _oidc_server(settings: Settings, resource: str | None) -> MCPServer:
|
|
621
|
+
from mcp.server.auth.settings import AuthSettings
|
|
622
|
+
|
|
623
|
+
from curu_memory.oidc import BasicExchange, OidcVerifier, principal_of_request
|
|
624
|
+
from curu_memory.server import build_server, use_basic_exchange
|
|
625
|
+
|
|
626
|
+
assert settings.oidc_issuer is not None
|
|
627
|
+
resource = settings.oidc_resource or resource or f"http://{DEFAULT_HOST}:{DEFAULT_PORT}/mcp"
|
|
628
|
+
access = AccessStore(settings.data_dir)
|
|
629
|
+
index = SqliteIndexStore(settings.data_dir)
|
|
630
|
+
audit = AuditLog(settings.data_dir)
|
|
631
|
+
|
|
632
|
+
verifier = OidcVerifier(
|
|
633
|
+
issuer=settings.oidc_issuer,
|
|
634
|
+
audience=settings.oidc_audience or resource,
|
|
635
|
+
token_types=settings.oidc_token_types,
|
|
636
|
+
access=access,
|
|
637
|
+
audit=audit,
|
|
638
|
+
)
|
|
639
|
+
server = build_server(
|
|
640
|
+
settings,
|
|
641
|
+
make_embedder(settings),
|
|
642
|
+
readable=lambda: _person_spaces(principal_of_request(), access, index),
|
|
643
|
+
audit=audit,
|
|
644
|
+
principal=lambda: principal_of_request().label,
|
|
645
|
+
acts_for=lambda: principal_of_request().acts_for,
|
|
646
|
+
token_verifier=verifier,
|
|
647
|
+
# curu-memory checks `aud` itself, from the audience setting (ADR-017).
|
|
648
|
+
auth=AuthSettings(
|
|
649
|
+
issuer_url=settings.oidc_issuer,
|
|
650
|
+
resource_server_url=resource,
|
|
651
|
+
validate_token_resource=False,
|
|
652
|
+
),
|
|
653
|
+
)
|
|
654
|
+
if settings.oidc_basic_client_id:
|
|
655
|
+
# ADR-018: an agent may send its app password; it gets the same token checks.
|
|
656
|
+
exchange = BasicExchange(
|
|
657
|
+
client_id=settings.oidc_basic_client_id, keys=verifier.keys, audit=audit
|
|
658
|
+
)
|
|
659
|
+
use_basic_exchange(server, exchange)
|
|
660
|
+
return server
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def live_grant(settings: Settings, grant_id: int) -> tuple[Grant, Agent] | None:
|
|
664
|
+
"""The grant and its agent, when the grant is live. Else None."""
|
|
665
|
+
access = AccessStore(settings.data_dir)
|
|
666
|
+
found = next((g for g in access.grants() if g.id == grant_id), None)
|
|
667
|
+
agent = access.agent(found.agent) if found is not None else None
|
|
668
|
+
if found is None or agent is None or not found.live(datetime.now(UTC)):
|
|
669
|
+
return None
|
|
670
|
+
return found, agent
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _grant_server(settings: Settings, grant_id: int) -> MCPServer:
|
|
674
|
+
"""A stdio server that acts as the agent of one grant (ADR-018). The limit is voluntary."""
|
|
675
|
+
from curu_memory.server import build_server
|
|
676
|
+
|
|
677
|
+
access = AccessStore(settings.data_dir)
|
|
678
|
+
|
|
679
|
+
def spaces() -> tuple[str, ...]:
|
|
680
|
+
found = live_grant(settings, grant_id)
|
|
681
|
+
if found is None:
|
|
682
|
+
return ()
|
|
683
|
+
grant, agent = found
|
|
684
|
+
# Only the space of this grant, and only while the person still has a role.
|
|
685
|
+
return (grant.space,) if role_of_agent(agent, grant.space, access) else ()
|
|
686
|
+
|
|
687
|
+
found = live_grant(settings, grant_id)
|
|
688
|
+
label = f"agent:{found[1].name}" if found else "anonymous"
|
|
689
|
+
acts_for = found[1].person if found else None
|
|
690
|
+
return build_server(
|
|
691
|
+
settings,
|
|
692
|
+
make_embedder(settings),
|
|
693
|
+
readable=spaces,
|
|
694
|
+
audit=AuditLog(settings.data_dir),
|
|
695
|
+
principal=label,
|
|
696
|
+
acts_for=lambda: acts_for,
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _person_spaces(
|
|
701
|
+
principal: Principal, access: AccessStore, index: SqliteIndexStore
|
|
702
|
+
) -> tuple[str, ...]:
|
|
703
|
+
# A request with no person reads nothing: in OIDC mode, each request has a token.
|
|
704
|
+
return readable_spaces_of(principal, access, index) if principal.handle else ()
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def browser_for(settings: Settings, token: str | None = None) -> Starlette:
|
|
708
|
+
"""The browser app of `browse`, with the readable spaces of the principal."""
|
|
709
|
+
from curu_memory.browser import build_browser
|
|
710
|
+
|
|
711
|
+
return build_browser(
|
|
712
|
+
settings,
|
|
713
|
+
make_embedder(settings),
|
|
714
|
+
token,
|
|
715
|
+
readable=readable_for(settings),
|
|
716
|
+
audit=AuditLog(settings.data_dir),
|
|
717
|
+
principal=principal_of(settings).label,
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _access(settings: Settings, args: argparse.Namespace) -> int:
|
|
722
|
+
pair = (getattr(args, "issuer", None) is None) != (getattr(args, "subject", None) is None)
|
|
723
|
+
if args.part == "person" and pair:
|
|
724
|
+
print("curu-memory: Give --issuer and --subject together.", file=sys.stderr)
|
|
725
|
+
return EXIT_USAGE
|
|
726
|
+
access = AccessStore(settings.data_dir)
|
|
727
|
+
try:
|
|
728
|
+
message = _change_access(access, args)
|
|
729
|
+
except AccessRefused as error:
|
|
730
|
+
print(f"curu-memory: {error}", file=sys.stderr)
|
|
731
|
+
return EXIT_PROBLEM
|
|
732
|
+
if message is not None:
|
|
733
|
+
text, space, subject = message
|
|
734
|
+
if args.part in ("grant", "revoke"):
|
|
735
|
+
action = "grant.add" if args.part == "grant" else "grant.revoke"
|
|
736
|
+
else:
|
|
737
|
+
action = f"{args.part}.{args.action}"
|
|
738
|
+
AuditLog(settings.data_dir).write("operator", action, space, CHANGED, text, subject=subject)
|
|
739
|
+
print(text)
|
|
740
|
+
return 0
|
|
741
|
+
view = access_view(access, SqliteIndexStore(settings.data_dir))
|
|
742
|
+
print(json.dumps(view, indent=2) if args.json else render_access(view))
|
|
743
|
+
return 0
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _change_access(
|
|
747
|
+
access: AccessStore, args: argparse.Namespace
|
|
748
|
+
) -> tuple[str, str | None, str | None] | None:
|
|
749
|
+
"""Make one change. Give its message, its space and its subject. None for `show`."""
|
|
750
|
+
if args.part == "person" and args.action == "add":
|
|
751
|
+
person = access.add_person(
|
|
752
|
+
args.handle, owner=args.owner, issuer=args.issuer, subject=args.subject
|
|
753
|
+
)
|
|
754
|
+
text = f"added the person {person.handle}" + (" (owner)" if person.owner else "")
|
|
755
|
+
return text, None, args.handle
|
|
756
|
+
if args.part == "person" and args.action == "link":
|
|
757
|
+
access.link_person(args.handle, args.issuer, args.subject)
|
|
758
|
+
return f"linked the person {args.handle} to a subject of {args.issuer}", None, args.handle
|
|
759
|
+
if args.part == "person":
|
|
760
|
+
access.remove_person(args.handle)
|
|
761
|
+
# A new person with the same handle would read the old index (review of spec 011).
|
|
762
|
+
text = (
|
|
763
|
+
f"removed the person {args.handle} and its agents. The space "
|
|
764
|
+
f"personal/{args.handle} keeps its index. Delete "
|
|
765
|
+
f"{space_file_name('personal/' + args.handle)} in the data directory, else a new "
|
|
766
|
+
"person with this handle reads it."
|
|
767
|
+
)
|
|
768
|
+
return text, None, args.handle
|
|
769
|
+
if args.part == "team" and args.action == "add":
|
|
770
|
+
access.add_team(args.slug)
|
|
771
|
+
return f"added the team {args.slug}", f"team/{args.slug}", args.slug
|
|
772
|
+
if args.part == "team":
|
|
773
|
+
access.remove_team(args.slug)
|
|
774
|
+
text = (
|
|
775
|
+
f"removed the team {args.slug} and its memberships. The space team/{args.slug} "
|
|
776
|
+
f"keeps its index. Delete {space_file_name('team/' + args.slug)} in the data "
|
|
777
|
+
"directory, else a new team with this slug reads it."
|
|
778
|
+
)
|
|
779
|
+
return text, f"team/{args.slug}", args.slug
|
|
780
|
+
if args.part == "member" and args.action == "add":
|
|
781
|
+
access.add_member(args.slug, args.handle)
|
|
782
|
+
text = f"added {args.handle} to the team {args.slug}"
|
|
783
|
+
return text, f"team/{args.slug}", f"{args.slug}:{args.handle}"
|
|
784
|
+
if args.part == "member":
|
|
785
|
+
access.remove_member(args.slug, args.handle)
|
|
786
|
+
text = f"removed {args.handle} from the team {args.slug}"
|
|
787
|
+
return text, f"team/{args.slug}", f"{args.slug}:{args.handle}"
|
|
788
|
+
if args.part == "agent" and args.action == "add":
|
|
789
|
+
agent = access.add_agent(
|
|
790
|
+
args.name,
|
|
791
|
+
person=args.person,
|
|
792
|
+
issuer=args.issuer,
|
|
793
|
+
subject=args.subject,
|
|
794
|
+
client_id=args.client,
|
|
795
|
+
)
|
|
796
|
+
return f"added the agent {agent.name} for {agent.person}", None, agent.name
|
|
797
|
+
if args.part == "agent":
|
|
798
|
+
access.remove_agent(args.name)
|
|
799
|
+
return f"removed the agent {args.name} and its grants", None, args.name
|
|
800
|
+
if args.part == "grant":
|
|
801
|
+
role = {name: value for value, name in ROLE_NAMES.items()}[args.role]
|
|
802
|
+
granted = access.add_grant(args.agent, space=args.space, role=role, days=args.days)
|
|
803
|
+
text = (
|
|
804
|
+
f"granted {args.role} on {granted.space} to {granted.agent} until "
|
|
805
|
+
f"{granted.ends:%Y-%m-%d %H:%M} UTC (grant {granted.id})"
|
|
806
|
+
)
|
|
807
|
+
return text, granted.space, granted.agent
|
|
808
|
+
if args.part == "revoke":
|
|
809
|
+
revoked = access.revoke_grant(args.id)
|
|
810
|
+
return f"revoked the grant {revoked.id} of {revoked.agent}", revoked.space, revoked.agent
|
|
811
|
+
return None
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def access_view(access: AccessStore, index: SqliteIndexStore) -> dict[str, Any]:
|
|
815
|
+
"""The persons, the teams, the role of each person in each space, and the agents."""
|
|
816
|
+
now = datetime.now(UTC)
|
|
817
|
+
persons = access.persons()
|
|
818
|
+
teams = access.teams()
|
|
819
|
+
spaces = sorted(
|
|
820
|
+
{GLOBAL, *index.spaces(), *(f"team/{s}" for s in teams)}
|
|
821
|
+
| {f"personal/{p.handle}" for p in persons}
|
|
822
|
+
)
|
|
823
|
+
roles: dict[str, dict[str, str]] = {}
|
|
824
|
+
for person in persons:
|
|
825
|
+
mine = access.teams_of(person.handle)
|
|
826
|
+
found = {space: role_in(person, space, mine) for space in spaces}
|
|
827
|
+
roles[person.handle] = {s: ROLE_NAMES[r] for s, r in found.items() if r is not None}
|
|
828
|
+
return {
|
|
829
|
+
"persons": [
|
|
830
|
+
{"handle": p.handle, "owner": p.owner, "issuer": p.issuer, "subject": p.subject}
|
|
831
|
+
for p in persons
|
|
832
|
+
],
|
|
833
|
+
"teams": [{"slug": slug, "members": list(access.members(slug))} for slug in teams],
|
|
834
|
+
"roles": roles,
|
|
835
|
+
"agents": [
|
|
836
|
+
{
|
|
837
|
+
"name": agent.name,
|
|
838
|
+
"person": agent.person,
|
|
839
|
+
"issuer": agent.issuer,
|
|
840
|
+
"subject": agent.subject,
|
|
841
|
+
"client_id": agent.client_id,
|
|
842
|
+
"grants": [
|
|
843
|
+
{
|
|
844
|
+
"id": g.id,
|
|
845
|
+
"space": g.space,
|
|
846
|
+
"role": ROLE_NAMES[g.role],
|
|
847
|
+
"ends": g.ends.isoformat(),
|
|
848
|
+
}
|
|
849
|
+
for g in access.grants(agent=agent.name)
|
|
850
|
+
if g.live(now)
|
|
851
|
+
],
|
|
852
|
+
}
|
|
853
|
+
for agent in access.agents()
|
|
854
|
+
],
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def render_access(view: dict[str, Any]) -> str:
|
|
859
|
+
lines = ["persons:"]
|
|
860
|
+
for person in view["persons"]:
|
|
861
|
+
lines.append(f" {person['handle']}" + (" (owner)" if person["owner"] else ""))
|
|
862
|
+
lines.append("teams:")
|
|
863
|
+
for team in view["teams"]:
|
|
864
|
+
lines.append(f" {team['slug']}: {', '.join(team['members']) or 'no members'}")
|
|
865
|
+
lines.append("roles:")
|
|
866
|
+
for handle, roles in view["roles"].items():
|
|
867
|
+
for space, role in roles.items():
|
|
868
|
+
lines.append(f" {handle} {space}: {role}")
|
|
869
|
+
lines.append("agents:")
|
|
870
|
+
for agent in view["agents"]:
|
|
871
|
+
if not agent["grants"]:
|
|
872
|
+
lines.append(f" {agent['name']} (for {agent['person']}): no live grant")
|
|
873
|
+
for grant in agent["grants"]:
|
|
874
|
+
ends = grant["ends"][:16].replace("T", " ")
|
|
875
|
+
lines.append(
|
|
876
|
+
f" {agent['name']} (for {agent['person']}): {grant['space']} "
|
|
877
|
+
f"{grant['role']} until {ends} UTC (grant {grant['id']})"
|
|
878
|
+
)
|
|
879
|
+
return "\n".join(lines)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _search(settings: Settings, args: argparse.Namespace) -> int:
|
|
883
|
+
store = SqliteIndexStore(settings.data_dir)
|
|
884
|
+
retriever = retriever_for(settings, store, make_embedder(settings))
|
|
885
|
+
spaces = readable_for(settings)()
|
|
886
|
+
if not spaces:
|
|
887
|
+
AuditLog(settings.data_dir).write(
|
|
888
|
+
principal_of(settings).label, "search", None, REFUSED, "no role in any space"
|
|
889
|
+
)
|
|
890
|
+
print("curu-memory: The principal can read no space.", file=sys.stderr)
|
|
891
|
+
return EXIT_PROBLEM
|
|
892
|
+
if (args.question is None) == (args.media is None):
|
|
893
|
+
print("curu-memory: Give a question or --media, but not both.", file=sys.stderr)
|
|
894
|
+
return EXIT_USAGE
|
|
895
|
+
try:
|
|
896
|
+
if args.media is not None:
|
|
897
|
+
question = read_query_media(_read_media_file(args.media))
|
|
898
|
+
result = retriever.search_media(
|
|
899
|
+
question, args.limit, args.types, args.tags, spaces=spaces
|
|
900
|
+
)
|
|
901
|
+
else:
|
|
902
|
+
result = retriever.search(
|
|
903
|
+
args.question, args.limit, args.types, args.tags, spaces=spaces
|
|
904
|
+
)
|
|
905
|
+
except IndexMissing as error:
|
|
906
|
+
print(f"curu-memory: {error.message}", file=sys.stderr)
|
|
907
|
+
return EXIT_PROBLEM
|
|
908
|
+
except InvalidArgument as error:
|
|
909
|
+
print(f"curu-memory: {error.message}", file=sys.stderr)
|
|
910
|
+
return EXIT_USAGE
|
|
911
|
+
if args.json:
|
|
912
|
+
print(result.model_dump_json(indent=2))
|
|
913
|
+
else:
|
|
914
|
+
print(render_search(result))
|
|
915
|
+
return 0
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _read_media_file(path: Path) -> bytes:
|
|
919
|
+
"""The bytes of a media file, read only up to the size limit plus one byte."""
|
|
920
|
+
try:
|
|
921
|
+
with path.open("rb") as file:
|
|
922
|
+
return file.read(MAX_BYTES + 1)
|
|
923
|
+
except OSError as error:
|
|
924
|
+
reason = error.strerror or type(error).__name__
|
|
925
|
+
raise InvalidArgument("media", f"The media file cannot be read: {reason}.") from None
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _serve(settings: Settings, args: argparse.Namespace) -> None:
|
|
929
|
+
# Imported here, so that index and search do not load the MCP server.
|
|
930
|
+
|
|
931
|
+
logging.basicConfig(stream=sys.stderr, level=logging.WARNING)
|
|
932
|
+
if args.grant is not None and args.transport != "stdio":
|
|
933
|
+
# A grant limits one local process. On HTTP, the token of each request decides.
|
|
934
|
+
print(
|
|
935
|
+
"curu-memory: --grant works only with --transport stdio. On HTTP, give the "
|
|
936
|
+
"agent a token of the issuer, and the grants of that agent apply.",
|
|
937
|
+
file=sys.stderr,
|
|
938
|
+
)
|
|
939
|
+
sys.exit(EXIT_USAGE)
|
|
940
|
+
if args.transport == "stdio":
|
|
941
|
+
if args.grant is not None and live_grant(settings, args.grant) is None:
|
|
942
|
+
_stop(f"The grant {args.grant} is not live. It does not exist, ended or was revoked.")
|
|
943
|
+
server_for(settings, stdio=True, grant=args.grant).run("stdio")
|
|
944
|
+
return
|
|
945
|
+
if settings.oidc_issuer:
|
|
946
|
+
if settings.http_token:
|
|
947
|
+
# The static token is the basic mode only with no OIDC provider (the owner).
|
|
948
|
+
print(
|
|
949
|
+
"curu-memory: CURU_MEMORY_OIDC_ISSUER is set, so serve ignores "
|
|
950
|
+
"CURU_MEMORY_HTTP_TOKEN. Each request needs a token of the issuer. "
|
|
951
|
+
"The browser still uses CURU_MEMORY_HTTP_TOKEN.",
|
|
952
|
+
file=sys.stderr,
|
|
953
|
+
)
|
|
954
|
+
_serve_http(server_for(settings, resource=f"http://{args.host}:{args.port}/mcp"), args)
|
|
955
|
+
return
|
|
956
|
+
token = settings.http_token
|
|
957
|
+
if token is None or len(token) < MIN_TOKEN_LENGTH:
|
|
958
|
+
_stop(
|
|
959
|
+
f"HTTP needs a token of {MIN_TOKEN_LENGTH} characters or more in "
|
|
960
|
+
f"CURU_MEMORY_HTTP_TOKEN. Make one with: {MAKE_TOKEN}"
|
|
961
|
+
)
|
|
962
|
+
logging.getLogger("uvicorn.access").addFilter(drop_query_string)
|
|
963
|
+
|
|
964
|
+
_serve_http(server_for(settings, inbound_token=token), args)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _serve_http(server: MCPServer, args: argparse.Namespace) -> None:
|
|
968
|
+
import uvicorn
|
|
969
|
+
|
|
970
|
+
from curu_memory.server import http_app
|
|
971
|
+
|
|
972
|
+
logging.getLogger("uvicorn.access").addFilter(drop_query_string)
|
|
973
|
+
# "info" keeps one access-log line for each request, as the SDK runner did.
|
|
974
|
+
uvicorn.run(http_app(server, args.host), host=args.host, port=args.port, log_level="info")
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _browse(settings: Settings, args: argparse.Namespace) -> None:
|
|
978
|
+
import uvicorn
|
|
979
|
+
|
|
980
|
+
token = None
|
|
981
|
+
if args.host not in LOOPBACK:
|
|
982
|
+
token = settings.http_token
|
|
983
|
+
if token is None or len(token) < MIN_TOKEN_LENGTH:
|
|
984
|
+
_stop(
|
|
985
|
+
f"The browser on {args.host} needs a token of {MIN_TOKEN_LENGTH} characters "
|
|
986
|
+
f"or more in CURU_MEMORY_HTTP_TOKEN. Make one with: {MAKE_TOKEN}"
|
|
987
|
+
)
|
|
988
|
+
logging.getLogger("uvicorn.access").addFilter(drop_query_string)
|
|
989
|
+
app = browser_for(settings, token=token)
|
|
990
|
+
print(f"curu-memory: the browser is at http://{args.host}:{args.port}/", file=sys.stderr)
|
|
991
|
+
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def drop_query_string(record: logging.LogRecord) -> bool:
|
|
995
|
+
"""Remove the query string from an access-log line (constitution, article IV).
|
|
996
|
+
|
|
997
|
+
A client can put the token in the query string by mistake. curu-memory refuses
|
|
998
|
+
that request, but uvicorn would still log the full path with the token.
|
|
999
|
+
"""
|
|
1000
|
+
args = record.args
|
|
1001
|
+
if isinstance(args, tuple) and len(args) >= 3 and isinstance(args[2], str):
|
|
1002
|
+
record.args = (*args[:2], args[2].split("?", 1)[0], *args[3:])
|
|
1003
|
+
return True
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def render_search(result: SearchResult) -> str:
|
|
1007
|
+
lines: list[str] = []
|
|
1008
|
+
for number, hit in enumerate(result.results, start=1):
|
|
1009
|
+
lines.append(f"{number}. {hit.id}: {hit.title} ({hit.type}), score {hit.score:.4f}")
|
|
1010
|
+
lines.append(f" {hit.description}")
|
|
1011
|
+
lines.append(f" space: {hit.space}")
|
|
1012
|
+
if hit.matched_unit:
|
|
1013
|
+
lines.append(f" matched: {hit.matched_unit} ({hit.matched_kind})")
|
|
1014
|
+
if hit.tags:
|
|
1015
|
+
lines.append(f" tags: {', '.join(hit.tags)}")
|
|
1016
|
+
for source in hit.sources:
|
|
1017
|
+
label = source.get("title") or source.get("id") or "source"
|
|
1018
|
+
resource = source.get("resource")
|
|
1019
|
+
lines.append(f" source: {label}" + (f" ({resource})" if resource else ""))
|
|
1020
|
+
lines.append(f"mode: {result.mode}")
|
|
1021
|
+
if result.notice:
|
|
1022
|
+
lines.append(f"notice: {result.notice}")
|
|
1023
|
+
return "\n".join(lines)
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _stop(message: str) -> NoReturn:
|
|
1027
|
+
print(f"curu-memory: {message}", file=sys.stderr)
|
|
1028
|
+
sys.exit(EXIT_CONFIG)
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
if __name__ == "__main__":
|
|
1032
|
+
main()
|