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.
@@ -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()