memgres 0.12.0__tar.gz → 0.12.2__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.
- {memgres-0.12.0 → memgres-0.12.2}/PKG-INFO +1 -1
- {memgres-0.12.0 → memgres-0.12.2}/memgres/_version.py +1 -1
- {memgres-0.12.0 → memgres-0.12.2}/memgres/links.py +5 -13
- {memgres-0.12.0 → memgres-0.12.2}/memgres/mcp_server.py +95 -45
- memgres-0.12.2/memgres/paths.py +49 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/store.py +24 -7
- {memgres-0.12.0 → memgres-0.12.2}/memgres/vector/base.py +17 -3
- {memgres-0.12.0 → memgres-0.12.2}/memgres.egg-info/PKG-INFO +1 -1
- {memgres-0.12.0 → memgres-0.12.2}/memgres.egg-info/SOURCES.txt +2 -0
- memgres-0.12.2/tests/test_mcp_error_messages.py +207 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_required_fields.py +20 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_store_integration.py +11 -0
- {memgres-0.12.0 → memgres-0.12.2}/LICENSE +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/README.md +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/__init__.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/admin.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/admin_cli.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/blame.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/bootstrap.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/config.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/delimiters.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/diffing.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/embed_worker.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/embeddings.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/healthcheck.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/identity.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/indexing.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/info.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/lines.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0001_core.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0002_identity.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0003_history_author.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0004_title.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0005_chunk_index.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0006_reader_floor.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0007_embed_retry.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0008_service_roles.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0009_create_namespace_right.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0010_namespace_alias.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0012_user_profile.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0013_hash_version.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0015_normalize_tags.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0016_valid_at.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0017_memory_link.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0018_links_built.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0019_memory_usage.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0021_enrollment_key.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0022_user_disabled.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/migrations/0023_relink_after_parser_fix.sql +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/periodic.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/reembed.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/relink.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/schema.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/search.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/segments.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/server.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/tags.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/token_cli.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/vector/__init__.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/vector/pgvector.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/vector/qdrant.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres/worker.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres.egg-info/dependency_links.txt +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres.egg-info/entry_points.txt +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres.egg-info/requires.txt +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/memgres.egg-info/top_level.txt +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/pyproject.toml +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/setup.cfg +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_admin_two_way.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_blame_integration.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_chunk_index.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_claim_and_reembed.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_config.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_diffing.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_embed_worker.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_embeddings.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_enrollment.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_healthcheck.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_identity_integration.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_lexical_match.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_limits.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_links.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_list.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_mcp_admin_tools.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_mcp_http_transport.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_mcp_instructions.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_mcp_recall_schema.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_mcp_tool_visibility.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_mcp_tool_visibility_http.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_migration_upgrade.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_multi_space_search.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_path_addressing.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_qdrant_ca.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_qdrant_integration.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_replace_build.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_require_title.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_retention.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_roles_bootstrap.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_search_integration.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_security_followups.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_security_integration.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_segments.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_segments_store.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_server_info.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_server_integration.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_snippets.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_tags.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_token_sink.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_usage.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_valid_at.py +0 -0
- {memgres-0.12.0 → memgres-0.12.2}/tests/test_write_ergonomics.py +0 -0
|
@@ -52,19 +52,11 @@ from typing import Dict, List, Optional
|
|
|
52
52
|
# edges so they are visible, never resolved — we do not own the address space.
|
|
53
53
|
KNOWN_SCHEMES = ("idea", "file")
|
|
54
54
|
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
|
|
60
|
-
# ltree labels since 13 (the minimum this project supports), `write` accepts such
|
|
61
|
-
# a path without complaint, and real corpora are full of them —
|
|
62
|
-
# `infra.servers.video-production`. While this pattern rejected the hyphen the
|
|
63
|
-
# two halves of the product disagreed about what a path is: the link was stored
|
|
64
|
-
# as prose, `_classify` returned "ignore", and the edge did not even become a
|
|
65
|
-
# DANGLING one. It vanished, and `memory_links` answered "nothing points here",
|
|
66
|
-
# which reads as a fact about the corpus rather than as a parser that quit.
|
|
67
|
-
_PATH = re.compile(r"^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$")
|
|
55
|
+
# What a path is lives in `paths.py`, so the parser and the store cannot drift
|
|
56
|
+
# apart again — that drift is exactly what dropped every hyphenated link before
|
|
57
|
+
# 0.12.0. Kept under the old name because the rest of this module reads better
|
|
58
|
+
# with it.
|
|
59
|
+
from .paths import PATH_RE as _PATH
|
|
68
60
|
|
|
69
61
|
_LINK = re.compile(r"\[\[([^\[\]\n]+)\]\]")
|
|
70
62
|
_FENCE = re.compile(r"```.*?```|~~~.*?~~~", re.S)
|
|
@@ -32,8 +32,10 @@ from typing_extensions import Annotated
|
|
|
32
32
|
|
|
33
33
|
try: # mcp SDK >= 2.0 renamed the module fastmcp -> mcpserver
|
|
34
34
|
from mcp.server.mcpserver import Context
|
|
35
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
35
36
|
except ImportError: # mcp SDK 1.x
|
|
36
37
|
from mcp.server.fastmcp import Context
|
|
38
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
37
39
|
|
|
38
40
|
from . import admin, identity
|
|
39
41
|
from .config import Config, load
|
|
@@ -59,7 +61,11 @@ SourceArg = Annotated[Optional[str], Field(
|
|
|
59
61
|
"sender -> recipient, date, subject; messenger, who with whom, date; "
|
|
60
62
|
"machine + project + session/transcript for an agent run; full URL + "
|
|
61
63
|
"date read. 'email', 'the meeting', 'the user said' is not one — "
|
|
62
|
-
"nothing can be reached through it, so the fact can only be believed."
|
|
64
|
+
"nothing can be reached through it, so the fact can only be believed. "
|
|
65
|
+
"It is recorded on THIS revision, not on the memory: a memory has no "
|
|
66
|
+
"single origin, its edits do. So a later read does not carry it — "
|
|
67
|
+
"`memory_blame` says where a given line came from, `memory_history` "
|
|
68
|
+
"where each revision did.")]
|
|
63
69
|
ReasonArg = Annotated[Optional[str], Field(
|
|
64
70
|
default=None,
|
|
65
71
|
description="Why this write happened — what changed and why, in one line. Kept "
|
|
@@ -269,6 +275,40 @@ def _mcp(name: str, instructions: Optional[str] = None):
|
|
|
269
275
|
return server
|
|
270
276
|
|
|
271
277
|
|
|
278
|
+
# Refusals whose text is written FOR the caller and safe to show them: what is
|
|
279
|
+
# missing, which namespace to name, why the diff did not apply, what they may
|
|
280
|
+
# not do. Everything else — a psycopg error, a bug — stays masked, because those
|
|
281
|
+
# messages carry schema and internals rather than instructions.
|
|
282
|
+
_SPEAKABLE = (ValueError, PermissionError, LookupError)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _speaking(fn):
|
|
286
|
+
"""Re-raise a domain refusal as the SDK's own error type, which is the only
|
|
287
|
+
kind whose message reaches the client.
|
|
288
|
+
|
|
289
|
+
Everything else the SDK catches becomes `Error executing tool <name>` with no
|
|
290
|
+
detail — and that is what the caller sees. The refusals here are written to
|
|
291
|
+
be read: "this deployment requires `source` … as an ADDRESS", "you can reach
|
|
292
|
+
2 namespaces, name the one you mean". Masking them turns a fixable mistake
|
|
293
|
+
into a mystery, and an agent that cannot see the reason repeats the same call
|
|
294
|
+
— which is exactly what happened: five identical edits in a row, then the
|
|
295
|
+
same on reads, against a server that was explaining itself into the void.
|
|
296
|
+
"""
|
|
297
|
+
import functools
|
|
298
|
+
|
|
299
|
+
@functools.wraps(fn)
|
|
300
|
+
def wrapper(*args, **kwargs):
|
|
301
|
+
try:
|
|
302
|
+
return fn(*args, **kwargs)
|
|
303
|
+
except ToolError:
|
|
304
|
+
raise
|
|
305
|
+
except _SPEAKABLE as e:
|
|
306
|
+
# `from e` keeps the original in the server log, where the traceback
|
|
307
|
+
# belongs; the client gets the sentence, not the stack.
|
|
308
|
+
raise ToolError(str(e)) from e
|
|
309
|
+
return wrapper
|
|
310
|
+
|
|
311
|
+
|
|
272
312
|
def _mem(m) -> dict:
|
|
273
313
|
return m.to_dict(stringify_dates=True) # MCP layer needs plain strings
|
|
274
314
|
|
|
@@ -292,6 +332,12 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
292
332
|
_worker, cfg, backend = wire_server(cfg, embedder)
|
|
293
333
|
mcp = _mcp("memgres", instructions=_instruction_text())
|
|
294
334
|
|
|
335
|
+
def tool(*d_args, **d_kwargs):
|
|
336
|
+
"""`mcp.tool()`, but the refusal text survives the trip to the client."""
|
|
337
|
+
def deco(fn):
|
|
338
|
+
return mcp.tool(*d_args, **d_kwargs)(_speaking(fn))
|
|
339
|
+
return deco
|
|
340
|
+
|
|
295
341
|
import os as _os
|
|
296
342
|
|
|
297
343
|
# The control-plane tools. `auto` registers them wherever there are
|
|
@@ -389,7 +435,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
389
435
|
"""
|
|
390
436
|
return identity.resolve(conn, cfg, token)
|
|
391
437
|
|
|
392
|
-
@
|
|
438
|
+
@tool()
|
|
393
439
|
def memory_write(body: Optional[str] = None, id: Optional[str] = None,
|
|
394
440
|
at: Optional[str] = None,
|
|
395
441
|
if_moved: IfMovedArg = "error",
|
|
@@ -445,7 +491,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
445
491
|
reason=reason, valid_at=valid_at, space=space,
|
|
446
492
|
space_id=space_id))
|
|
447
493
|
|
|
448
|
-
@
|
|
494
|
+
@tool()
|
|
449
495
|
def memory_get(id: Optional[str] = None, at: Optional[str] = None,
|
|
450
496
|
if_moved: Literal["follow", "error"] = "follow",
|
|
451
497
|
lines: Optional[str] = None,
|
|
@@ -461,6 +507,10 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
461
507
|
The answer carries `usage`: how often this memory has surfaced in search
|
|
462
508
|
(`recalled`) and been fetched (`gets`), and when each last happened.
|
|
463
509
|
|
|
510
|
+
It carries NO `source`/`valid_at`, and that is deliberate: provenance
|
|
511
|
+
belongs to a revision, not to the document. Ask `memory_blame` where a
|
|
512
|
+
particular line came from, or `memory_history` where each revision did.
|
|
513
|
+
|
|
464
514
|
`lines` ("40-80", "5", "1,10-12") returns only part of a long body. The
|
|
465
515
|
answer is then marked `partial`, carries `total_lines`, and has NO
|
|
466
516
|
`content_hash` — do not send a slice back as a whole `body`, or
|
|
@@ -471,7 +521,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
471
521
|
if_moved=if_moved, lines=lines,
|
|
472
522
|
space=space, space_id=space_id))
|
|
473
523
|
|
|
474
|
-
@
|
|
524
|
+
@tool()
|
|
475
525
|
def memory_recall(query: str, k: int = 10,
|
|
476
526
|
mode: Literal["lexical", "semantic", "hybrid", "auto"] = "auto",
|
|
477
527
|
match: Optional[Literal["any", "all"]] = None,
|
|
@@ -515,7 +565,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
515
565
|
snippet=snippet, full_body=full_body, bodies=bodies,
|
|
516
566
|
match_tags=match_tags, space=space, space_id=space_id)]
|
|
517
567
|
|
|
518
|
-
@
|
|
568
|
+
@tool()
|
|
519
569
|
def memory_list(path_prefix: Optional[str] = None,
|
|
520
570
|
tags: Optional[List[str]] = None, limit: int = 50,
|
|
521
571
|
offset: int = 0, bodies: bool = False,
|
|
@@ -543,7 +593,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
543
593
|
limit=limit, offset=offset, bodies=bodies,
|
|
544
594
|
match_tags=match_tags, space=space, space_id=space_id)
|
|
545
595
|
|
|
546
|
-
@
|
|
596
|
+
@tool()
|
|
547
597
|
def memory_tags(prefix: Optional[str] = None, k: int = 50,
|
|
548
598
|
space: Spaces = None, space_id: Spaces = None,
|
|
549
599
|
ctx: Context = None) -> List[dict]:
|
|
@@ -558,7 +608,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
558
608
|
return _store(conn).tags(_token(ctx), prefix=prefix, k=k,
|
|
559
609
|
space=space, space_id=space_id)
|
|
560
610
|
|
|
561
|
-
@
|
|
611
|
+
@tool()
|
|
562
612
|
def memory_links(id: Optional[str] = None, at: Optional[str] = None,
|
|
563
613
|
direction: Literal["in", "out", "both"] = "both",
|
|
564
614
|
space: Optional[str] = None, space_id: Optional[str] = None,
|
|
@@ -579,7 +629,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
579
629
|
at=at or None, direction=direction,
|
|
580
630
|
space=space, space_id=space_id)
|
|
581
631
|
|
|
582
|
-
@
|
|
632
|
+
@tool()
|
|
583
633
|
def memory_server_info(ctx: Context = None) -> dict:
|
|
584
634
|
"""The server's version + schema_version and its effective limits and
|
|
585
635
|
capabilities (write ceilings, embed provider/model/dim, available recall
|
|
@@ -589,7 +639,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
589
639
|
dim = embedder.dim if embedder is not None else None
|
|
590
640
|
return server_info(cfg, embed_dim=dim)
|
|
591
641
|
|
|
592
|
-
@
|
|
642
|
+
@tool()
|
|
593
643
|
def memory_blame(id: Optional[str] = None, at: Optional[str] = None,
|
|
594
644
|
grouped: bool = True, lines: Optional[str] = None,
|
|
595
645
|
space: Optional[str] = None, space_id: Optional[str] = None,
|
|
@@ -609,7 +659,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
609
659
|
return s.annotate(tok, id, lines=want, at=at, space=space,
|
|
610
660
|
space_id=space_id)
|
|
611
661
|
|
|
612
|
-
@
|
|
662
|
+
@tool()
|
|
613
663
|
def memory_history(id: Optional[str] = None, at: Optional[str] = None,
|
|
614
664
|
space: Optional[str] = None,
|
|
615
665
|
space_id: Optional[str] = None,
|
|
@@ -622,7 +672,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
622
672
|
return _store(conn).history(_token(ctx), id, at=at,
|
|
623
673
|
space=space, space_id=space_id)
|
|
624
674
|
|
|
625
|
-
@
|
|
675
|
+
@tool()
|
|
626
676
|
def memory_move(new_path: str, id: Optional[str] = None,
|
|
627
677
|
at: Optional[str] = None,
|
|
628
678
|
if_moved: Literal["error", "follow"] = "error",
|
|
@@ -638,7 +688,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
638
688
|
reason=reason, space=space,
|
|
639
689
|
space_id=space_id))
|
|
640
690
|
|
|
641
|
-
@
|
|
691
|
+
@tool()
|
|
642
692
|
def memory_forget(id: Optional[str] = None, at: Optional[str] = None,
|
|
643
693
|
space: Optional[str] = None,
|
|
644
694
|
space_id: Optional[str] = None,
|
|
@@ -652,7 +702,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
652
702
|
_token(ctx), id, at=at, space=space, space_id=space_id)}
|
|
653
703
|
|
|
654
704
|
# ─── identity: spaces & tokens (open/managed modes) ─────────────────────
|
|
655
|
-
@
|
|
705
|
+
@tool()
|
|
656
706
|
def memory_list_spaces(ctx: Context = None) -> List[dict]:
|
|
657
707
|
"""List the namespaces you can reach — your own plus any shared with you —
|
|
658
708
|
with each one's id, name, description, permission, and your `alias` for it
|
|
@@ -661,7 +711,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
661
711
|
with pool.connection() as conn, conn.transaction():
|
|
662
712
|
return identity.list_spaces(conn, _uid(conn, _token(ctx)))
|
|
663
713
|
|
|
664
|
-
@
|
|
714
|
+
@tool()
|
|
665
715
|
def memory_create_space(name: str, description: str = "",
|
|
666
716
|
instruction: str = "",
|
|
667
717
|
ctx: Context = None) -> dict:
|
|
@@ -675,7 +725,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
675
725
|
description=description, instruction=instruction)
|
|
676
726
|
return {"id": nsid, "name": name}
|
|
677
727
|
|
|
678
|
-
@
|
|
728
|
+
@tool()
|
|
679
729
|
def memory_set_alias(alias: str, space_id: str,
|
|
680
730
|
ctx: Context = None) -> dict:
|
|
681
731
|
"""Give a namespace a name of your own, for when a bare name is ambiguous
|
|
@@ -688,7 +738,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
688
738
|
identity.create_alias(conn, uid, alias, space_id)
|
|
689
739
|
return {"alias": alias, "space_id": space_id}
|
|
690
740
|
|
|
691
|
-
@
|
|
741
|
+
@tool()
|
|
692
742
|
def memory_drop_alias(alias: str,
|
|
693
743
|
ctx: Context = None) -> dict:
|
|
694
744
|
"""Remove one of your namespace aliases. The namespace itself is
|
|
@@ -698,7 +748,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
698
748
|
return {"dropped": identity.drop_alias(conn, uid, alias)}
|
|
699
749
|
|
|
700
750
|
if cfg.key_mode == "managed":
|
|
701
|
-
@
|
|
751
|
+
@tool()
|
|
702
752
|
def memory_enroll(key: EnrollKeyArg, ctx: Context = None) -> dict:
|
|
703
753
|
"""Claim an account with a one-time enrollment key.
|
|
704
754
|
|
|
@@ -734,7 +784,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
734
784
|
"tools you can now use are not showing yet.")
|
|
735
785
|
return out
|
|
736
786
|
|
|
737
|
-
@
|
|
787
|
+
@tool()
|
|
738
788
|
def memory_issue_token(permission: str = "write", space: Optional[str] = None,
|
|
739
789
|
space_id: Optional[str] = None, label: str = "",
|
|
740
790
|
expires_days: Optional[int] = None,
|
|
@@ -789,7 +839,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
789
839
|
out.update({"permission": permission, "namespace_id": nsid})
|
|
790
840
|
return out
|
|
791
841
|
|
|
792
|
-
@
|
|
842
|
+
@tool()
|
|
793
843
|
def memory_request_access(space_id: str, permission: str = "read",
|
|
794
844
|
ctx: Context = None) -> dict:
|
|
795
845
|
"""Ask the owner of a namespace to let you in.
|
|
@@ -804,14 +854,14 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
804
854
|
namespace_id=space_id,
|
|
805
855
|
permission=permission)
|
|
806
856
|
|
|
807
|
-
@
|
|
857
|
+
@tool()
|
|
808
858
|
def memory_list_tokens(ctx: Context = None) -> List[dict]:
|
|
809
859
|
"""List your tokens (metadata only — never the secret)."""
|
|
810
860
|
with pool.connection() as conn, conn.transaction():
|
|
811
861
|
out = identity.list_tokens(conn, _admin_uid(conn, _token(ctx)))
|
|
812
862
|
return _iso(out, *_TOKEN_TIMES)
|
|
813
863
|
|
|
814
|
-
@
|
|
864
|
+
@tool()
|
|
815
865
|
def memory_revoke_token(token_id: str,
|
|
816
866
|
ctx: Context = None) -> dict:
|
|
817
867
|
"""Revoke one of your tokens by id (kills it immediately)."""
|
|
@@ -822,7 +872,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
822
872
|
raise identity.AuthError("not your token")
|
|
823
873
|
return {"revoked": identity.revoke_token(conn, token_id)}
|
|
824
874
|
|
|
825
|
-
@
|
|
875
|
+
@tool()
|
|
826
876
|
def memory_whoami(ctx: Context = None) -> dict:
|
|
827
877
|
"""Who you are and what you may do: user id, service role, this
|
|
828
878
|
credential's permission ceiling and namespace scope, plus the
|
|
@@ -834,7 +884,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
834
884
|
# ─── control plane: provisioning (authorized in `admin`, not here) ───────
|
|
835
885
|
if admin_surface:
|
|
836
886
|
|
|
837
|
-
@
|
|
887
|
+
@tool()
|
|
838
888
|
def memory_admin_list_users(role: Optional[str] = None, limit: int = 50,
|
|
839
889
|
offset: int = 0,
|
|
840
890
|
ctx: Context = None) -> List[dict]:
|
|
@@ -846,7 +896,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
846
896
|
role=role, limit=limit, offset=offset)
|
|
847
897
|
return _iso(out, "created_at")
|
|
848
898
|
|
|
849
|
-
@
|
|
899
|
+
@tool()
|
|
850
900
|
def memory_admin_create_user(name: str = "", description: str = "",
|
|
851
901
|
role: str = "user",
|
|
852
902
|
can_create_namespace: bool = False,
|
|
@@ -872,7 +922,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
872
922
|
department=department, position=position)
|
|
873
923
|
return {"id": uid}
|
|
874
924
|
|
|
875
|
-
@
|
|
925
|
+
@tool()
|
|
876
926
|
def memory_admin_edit_user(user_id: str,
|
|
877
927
|
name: Optional[str] = None,
|
|
878
928
|
description: Optional[str] = None,
|
|
@@ -891,7 +941,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
891
941
|
conn, _principal(conn, _token(ctx)), user_id=user_id,
|
|
892
942
|
**{k: v for k, v in fields.items() if v is not None})
|
|
893
943
|
|
|
894
|
-
@
|
|
944
|
+
@tool()
|
|
895
945
|
def memory_admin_set_can_create_namespace(user_id: str, allowed: bool,
|
|
896
946
|
ctx: Context = None) -> dict:
|
|
897
947
|
"""Grant or withdraw a user's right to create namespaces. Without it
|
|
@@ -902,7 +952,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
902
952
|
conn, _principal(conn, _token(ctx)),
|
|
903
953
|
user_id=user_id, allowed=allowed)
|
|
904
954
|
|
|
905
|
-
@
|
|
955
|
+
@tool()
|
|
906
956
|
def memory_admin_set_role(user_id: str, role: str,
|
|
907
957
|
ctx: Context = None) -> dict:
|
|
908
958
|
"""Set a user's service role (user | user_manager | superadmin).
|
|
@@ -912,7 +962,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
912
962
|
return admin.set_role(conn, _principal(conn, _token(ctx)),
|
|
913
963
|
user_id=user_id, role=role)
|
|
914
964
|
|
|
915
|
-
@
|
|
965
|
+
@tool()
|
|
916
966
|
def memory_admin_list_namespaces(owner_user_id: Optional[str] = None,
|
|
917
967
|
limit: int = 50, offset: int = 0,
|
|
918
968
|
ctx: Context = None) -> List[dict]:
|
|
@@ -925,7 +975,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
925
975
|
owner_user_id=owner_user_id, limit=limit, offset=offset)
|
|
926
976
|
return _iso(out, "created_at")
|
|
927
977
|
|
|
928
|
-
@
|
|
978
|
+
@tool()
|
|
929
979
|
def memory_admin_create_namespace(name: str, owner_user_id: str,
|
|
930
980
|
description: str = "",
|
|
931
981
|
instruction: str = "",
|
|
@@ -940,7 +990,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
940
990
|
description=description, instruction=instruction)
|
|
941
991
|
return {"id": nsid}
|
|
942
992
|
|
|
943
|
-
@
|
|
993
|
+
@tool()
|
|
944
994
|
def memory_admin_edit_namespace(space_id: str,
|
|
945
995
|
description: Optional[str] = None,
|
|
946
996
|
instruction: Optional[str] = None,
|
|
@@ -959,7 +1009,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
959
1009
|
namespace_id=space_id, description=description,
|
|
960
1010
|
instruction=instruction, name=name)
|
|
961
1011
|
|
|
962
|
-
@
|
|
1012
|
+
@tool()
|
|
963
1013
|
def memory_admin_count_orphans(ctx: Context = None) -> dict:
|
|
964
1014
|
"""How many memories are stranded in the pre-identity namespace.
|
|
965
1015
|
`single` mode stores everything under one nameless namespace; after a
|
|
@@ -969,7 +1019,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
969
1019
|
with pool.connection() as conn:
|
|
970
1020
|
return admin.count_orphans(conn, _principal(conn, _token(ctx)))
|
|
971
1021
|
|
|
972
|
-
@
|
|
1022
|
+
@tool()
|
|
973
1023
|
def memory_admin_adopt_orphans(space_id: str,
|
|
974
1024
|
ctx: Context = None) -> dict:
|
|
975
1025
|
"""Move every stranded `single`-mode memory into a real namespace.
|
|
@@ -981,7 +1031,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
981
1031
|
conn, _principal(conn, _token(ctx)),
|
|
982
1032
|
namespace_id=space_id, vectors=backend)
|
|
983
1033
|
|
|
984
|
-
@
|
|
1034
|
+
@tool()
|
|
985
1035
|
def memory_admin_add_member(space_id: str, user_id: str,
|
|
986
1036
|
permission: str = "read",
|
|
987
1037
|
ctx: Context = None) -> dict:
|
|
@@ -994,7 +1044,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
994
1044
|
namespace_id=space_id, user_id=user_id,
|
|
995
1045
|
permission=permission)
|
|
996
1046
|
|
|
997
|
-
@
|
|
1047
|
+
@tool()
|
|
998
1048
|
def memory_admin_remove_member(space_id: str, user_id: str,
|
|
999
1049
|
ctx: Context = None) -> dict:
|
|
1000
1050
|
"""Take a shared namespace away again. `removed: false` means they
|
|
@@ -1009,7 +1059,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
1009
1059
|
return admin.remove_member(conn, _principal(conn, _token(ctx)),
|
|
1010
1060
|
namespace_id=space_id, user_id=user_id)
|
|
1011
1061
|
|
|
1012
|
-
@
|
|
1062
|
+
@tool()
|
|
1013
1063
|
def memory_admin_transfer_namespace(
|
|
1014
1064
|
space_id: str, new_owner_user_id: str,
|
|
1015
1065
|
keep_previous_owner: Optional[str] = "admin",
|
|
@@ -1028,7 +1078,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
1028
1078
|
new_owner_user_id=new_owner_user_id,
|
|
1029
1079
|
keep_previous_owner=keep_previous_owner)
|
|
1030
1080
|
|
|
1031
|
-
@
|
|
1081
|
+
@tool()
|
|
1032
1082
|
def memory_admin_list_requests(space_id: str,
|
|
1033
1083
|
ctx: Context = None) -> List[dict]:
|
|
1034
1084
|
"""Pending requests to join a namespace you administer — who asked,
|
|
@@ -1038,7 +1088,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
1038
1088
|
namespace_id=space_id)
|
|
1039
1089
|
return _iso(out, "created_at")
|
|
1040
1090
|
|
|
1041
|
-
@
|
|
1091
|
+
@tool()
|
|
1042
1092
|
def memory_admin_decide_access(request_id: str, approve: bool = True,
|
|
1043
1093
|
expect_permission: Optional[str] = None,
|
|
1044
1094
|
ctx: Context = None) -> dict:
|
|
@@ -1056,7 +1106,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
1056
1106
|
expect_permission=expect_permission)
|
|
1057
1107
|
return {"request_id": request_id, "approved": approve}
|
|
1058
1108
|
|
|
1059
|
-
@
|
|
1109
|
+
@tool()
|
|
1060
1110
|
def memory_admin_set_disabled(user_id: str, disabled: bool = True,
|
|
1061
1111
|
ctx: Context = None) -> dict:
|
|
1062
1112
|
"""Switch an account off, or back on.
|
|
@@ -1070,7 +1120,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
1070
1120
|
return admin.set_disabled(conn, _principal(conn, _token(ctx)),
|
|
1071
1121
|
user_id=user_id, disabled=disabled)
|
|
1072
1122
|
|
|
1073
|
-
@
|
|
1123
|
+
@tool()
|
|
1074
1124
|
def memory_admin_list_members(space_id: str,
|
|
1075
1125
|
ctx: Context = None) -> List[dict]:
|
|
1076
1126
|
"""Who can reach a namespace — the owner first, then everyone shared
|
|
@@ -1080,7 +1130,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
1080
1130
|
namespace_id=space_id)
|
|
1081
1131
|
return _iso(out, "created_at")
|
|
1082
1132
|
|
|
1083
|
-
@
|
|
1133
|
+
@tool()
|
|
1084
1134
|
def memory_admin_issue_token(user_id: str, permission: str = "write",
|
|
1085
1135
|
space_id: Optional[str] = None,
|
|
1086
1136
|
label: str = "",
|
|
@@ -1112,7 +1162,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
1112
1162
|
out["warning"] = minted["warning"]
|
|
1113
1163
|
return out
|
|
1114
1164
|
|
|
1115
|
-
@
|
|
1165
|
+
@tool()
|
|
1116
1166
|
def memory_admin_create_enrollment(
|
|
1117
1167
|
user_id: str, permission: str = "write",
|
|
1118
1168
|
space_id: Optional[str] = None, label: str = "",
|
|
@@ -1146,7 +1196,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
1146
1196
|
out["expires_at"] = out["expires_at"].isoformat()
|
|
1147
1197
|
return admin.deliver_key(out, cfg.token_sink)
|
|
1148
1198
|
|
|
1149
|
-
@
|
|
1199
|
+
@tool()
|
|
1150
1200
|
def memory_admin_list_enrollments(user_id: Optional[str] = None,
|
|
1151
1201
|
ctx: Context = None) -> List[dict]:
|
|
1152
1202
|
"""Enrollment keys and what became of them — `state` is pending,
|
|
@@ -1158,7 +1208,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
1158
1208
|
user_id=user_id)
|
|
1159
1209
|
return _iso(out, "created_at", "expires_at", "used_at", "revoked_at")
|
|
1160
1210
|
|
|
1161
|
-
@
|
|
1211
|
+
@tool()
|
|
1162
1212
|
def memory_admin_revoke_enrollment(enrollment_id: str,
|
|
1163
1213
|
ctx: Context = None) -> dict:
|
|
1164
1214
|
"""Kill an unredeemed enrollment key. False means it was already
|
|
@@ -1169,7 +1219,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
1169
1219
|
conn, _principal(conn, _token(ctx)),
|
|
1170
1220
|
enrollment_id=enrollment_id)}
|
|
1171
1221
|
|
|
1172
|
-
@
|
|
1222
|
+
@tool()
|
|
1173
1223
|
def memory_admin_list_tokens(user_id: str,
|
|
1174
1224
|
ctx: Context = None) -> List[dict]:
|
|
1175
1225
|
"""A user's tokens — metadata only, never the secret."""
|
|
@@ -1178,7 +1228,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
1178
1228
|
user_id=user_id)
|
|
1179
1229
|
return _iso(out, *_TOKEN_TIMES)
|
|
1180
1230
|
|
|
1181
|
-
@
|
|
1231
|
+
@tool()
|
|
1182
1232
|
def memory_admin_revoke_token(token_id: str,
|
|
1183
1233
|
ctx: Context = None) -> dict:
|
|
1184
1234
|
"""Kill any user's token immediately. False means it was already
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""What a tree path is — in ONE place.
|
|
2
|
+
|
|
3
|
+
The shape was written down twice before, in the link parser and in whatever SQL
|
|
4
|
+
happened to cast a string to `ltree`, and the two disagreed: a hyphen was legal
|
|
5
|
+
to store and illegal to link, so half the links in a real corpus silently
|
|
6
|
+
vanished (fixed in 0.12.0). This module exists so a third definition does not
|
|
7
|
+
appear.
|
|
8
|
+
|
|
9
|
+
It also gives the check a place to happen BEFORE the value reaches Postgres. An
|
|
10
|
+
unvalidated prefix arrived at the database as `%s::ltree` and came back as
|
|
11
|
+
`psycopg.errors.SyntaxError: ltree syntax error at character 1` — a message
|
|
12
|
+
about our schema, not about their input, and one the caller can do nothing with.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
# Labels of word characters (letters in ANY script, digits, underscore) or
|
|
21
|
+
# hyphens, joined by dots.
|
|
22
|
+
#
|
|
23
|
+
# Two things had to be widened here, each because a real path was being called
|
|
24
|
+
# invalid. The hyphen: Postgres has allowed it in ltree labels since 13, the
|
|
25
|
+
# minimum this project supports. Non-ASCII letters: `ops.тариф` stores and reads
|
|
26
|
+
# perfectly well, and an existing test says so — a check that rejected it would
|
|
27
|
+
# have made a working path unwritable, which is worse than the error it prevents.
|
|
28
|
+
#
|
|
29
|
+
# The parser for `[[links]]` uses the SAME alphabet on purpose. When it was
|
|
30
|
+
# narrower, every hyphenated link was dropped as prose; a narrower rule here
|
|
31
|
+
# would recreate that, only for Cyrillic.
|
|
32
|
+
PATH_RE = re.compile(r"^[\w-]+(\.[\w-]+)*$", re.UNICODE)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_path(value: Optional[str]) -> bool:
|
|
36
|
+
return bool(value) and bool(PATH_RE.match(value))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def check_path(value: Optional[str], field: str = "path") -> Optional[str]:
|
|
40
|
+
"""Return `value` unchanged, or raise ValueError naming the field and the
|
|
41
|
+
shape. Empty/None passes — "no path given" is not a malformed one."""
|
|
42
|
+
if value is None or value == "":
|
|
43
|
+
return value
|
|
44
|
+
if not PATH_RE.match(value):
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"`{field}` is not a tree path: {value!r}. A path is labels of "
|
|
47
|
+
f"letters (any script), digits, `_` or `-` joined by dots, like "
|
|
48
|
+
f"`ops.memory.onboarding` — no spaces, slashes or leading dots.")
|
|
49
|
+
return value
|
|
@@ -35,6 +35,7 @@ from . import identity
|
|
|
35
35
|
from .config import Config
|
|
36
36
|
from .delimiters import write_warnings
|
|
37
37
|
from .lines import parse_line_spec
|
|
38
|
+
from .paths import check_path
|
|
38
39
|
from .diffing import DiffConflict, apply_diff, byte_len, content_hash, make_diff
|
|
39
40
|
from .embeddings import Embedder, get_embedder
|
|
40
41
|
from .links import parse_links, rewrite_targets
|
|
@@ -250,12 +251,21 @@ class Memory:
|
|
|
250
251
|
# Provenance of the REVISION this call just wrote — never of the memory,
|
|
251
252
|
# which has no single source: `source`/`reason`/`valid_at` live on the
|
|
252
253
|
# history row because different edits speak to different origins and dates.
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
#
|
|
254
|
+
# Echoed back on a write because a required field the answer does not confirm
|
|
255
|
+
# is a field whose absence nobody notices: four edits in a row went out with
|
|
256
|
+
# an empty `source` and every reply looked fine.
|
|
257
|
+
#
|
|
258
|
+
# `is_write` decides whether they are SERIALIZED at all, and that is the
|
|
259
|
+
# point: a read used to emit `"source": null`, which does not read as "this
|
|
260
|
+
# memory has no such field" but as "the value was there and is now gone".
|
|
261
|
+
# Two readers in a row concluded the field was being silently dropped and
|
|
262
|
+
# went looking for the bug — one of them spent a day on it and wrote the
|
|
263
|
+
# workaround into memory. So on a read the keys are ABSENT, and the question
|
|
264
|
+
# they answer belongs to `history`/`blame`, where the answer is per-revision
|
|
265
|
+
# and per-line rather than one value pretending to describe the document.
|
|
257
266
|
source: Optional[str] = None
|
|
258
267
|
valid_at: object = None
|
|
268
|
+
is_write: bool = False
|
|
259
269
|
|
|
260
270
|
def to_dict(self, *, stringify_dates: bool = False) -> dict:
|
|
261
271
|
"""Serialize for an API layer. ``stringify_dates`` str()-coerces the
|
|
@@ -263,9 +273,8 @@ class Memory:
|
|
|
263
273
|
datetimes itself, so the HTTP layer passes them through raw)."""
|
|
264
274
|
def d(v):
|
|
265
275
|
return (str(v) if v is not None else None) if stringify_dates else v
|
|
266
|
-
|
|
276
|
+
out = {"id": self.id, "content_hash": self.content_hash, "body": self.body,
|
|
267
277
|
"title": self.title, "tags": self.tags, "path": self.path,
|
|
268
|
-
"source": self.source, "valid_at": d(self.valid_at),
|
|
269
278
|
"seq": self.seq, "created_at": d(self.created_at),
|
|
270
279
|
"updated_at": d(self.updated_at), "expires_at": d(self.expires_at),
|
|
271
280
|
"created": self.created, "moved_from": self.moved_from,
|
|
@@ -275,6 +284,12 @@ class Memory:
|
|
|
275
284
|
"last_recall_at": d(self.usage["last_recall_at"]),
|
|
276
285
|
"last_get_at": d(self.usage["last_get_at"])}
|
|
277
286
|
if self.usage else None)}
|
|
287
|
+
if self.is_write:
|
|
288
|
+
# Only here: the provenance of the revision just written, confirmed
|
|
289
|
+
# back to its author. A read carries no such keys (see `is_write`).
|
|
290
|
+
out["source"] = self.source
|
|
291
|
+
out["valid_at"] = d(self.valid_at)
|
|
292
|
+
return out
|
|
278
293
|
|
|
279
294
|
|
|
280
295
|
def _sha(text: str) -> str:
|
|
@@ -625,6 +640,7 @@ class Store:
|
|
|
625
640
|
# stored one way and filtered another.
|
|
626
641
|
tags = normalize_tags(tags)
|
|
627
642
|
valid_at = _as_date(valid_at)
|
|
643
|
+
check_path(path, "path")
|
|
628
644
|
if id is None and at is None:
|
|
629
645
|
self._check_path_free(ns, path, if_moved)
|
|
630
646
|
m = self._create(ns, author, body, path, tags, source, reason,
|
|
@@ -641,7 +657,8 @@ class Store:
|
|
|
641
657
|
# diff can introduce the stray tag just as a whole body can.
|
|
642
658
|
m.warnings = write_warnings(m.body)
|
|
643
659
|
# The revision's own provenance, straight back to whoever wrote it.
|
|
644
|
-
|
|
660
|
+
# `is_write` is what puts these on the wire; a read leaves them off.
|
|
661
|
+
m.source, m.valid_at, m.is_write = source, valid_at, True
|
|
645
662
|
return m
|
|
646
663
|
|
|
647
664
|
def _check_path_free(self, ns: str, path: Optional[str],
|