fkf 5.0.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.
- fkf/__init__.py +22 -0
- fkf/__main__.py +6 -0
- fkf/assets.py +117 -0
- fkf/assets_data/demo/fkf-demo-json.sh +20 -0
- fkf/assets_data/presets/bin/agent-memory-body.py +117 -0
- fkf/assets_data/presets/bin/agent-memory-files.py +225 -0
- fkf/assets_data/presets/bin/agent-prompt-body.py +205 -0
- fkf/assets_data/presets/bin/agent-prompts.py +295 -0
- fkf/assets_data/presets/bin/agent-session-trace.py +507 -0
- fkf/assets_data/presets/bin/agent-sessions.py +543 -0
- fkf/assets_data/presets/bin/atuin-history-json.py +186 -0
- fkf/assets_data/presets/bin/chrome-bookmarks.py +166 -0
- fkf/assets_data/presets/bin/chromium-pages.py +336 -0
- fkf/assets_data/presets/bin/fkf-hook.py +248 -0
- fkf/assets_data/presets/bin/gcloud-audit-json.py +134 -0
- fkf/assets_data/presets/bin/gcloud-auth-ready.sh +10 -0
- fkf/assets_data/presets/bin/gcloud-projects-json.sh +22 -0
- fkf/assets_data/presets/bin/gh-runs.py +243 -0
- fkf/assets_data/presets/bin/git-log-json.py +303 -0
- fkf/assets_data/presets/bin/github-commits-json.py +201 -0
- fkf/assets_data/presets/bin/github-events-json.py +190 -0
- fkf/assets_data/presets/bin/github-generic-list-json.py +82 -0
- fkf/assets_data/presets/bin/github-gists-json.sh +23 -0
- fkf/assets_data/presets/bin/github-list-json.py +208 -0
- fkf/assets_data/presets/bin/github-reviews-json.py +240 -0
- fkf/assets_data/presets/bin/github-search-json.py +307 -0
- fkf/assets_data/presets/bin/github-stars-json.sh +25 -0
- fkf/assets_data/presets/bin/gmail-body.py +148 -0
- fkf/assets_data/presets/bin/gmail-json.py +178 -0
- fkf/assets_data/presets/bin/gws-calendar-body.py +69 -0
- fkf/assets_data/presets/bin/gws-calendars-json.py +265 -0
- fkf/assets_data/presets/bin/gws-chat-message-body.py +58 -0
- fkf/assets_data/presets/bin/gws-chat-messages.py +182 -0
- fkf/assets_data/presets/bin/gws-doc-text.sh +20 -0
- fkf/assets_data/presets/bin/gws-meeting-notes-json.py +282 -0
- fkf/assets_data/presets/bin/gws-page-json.py +226 -0
- fkf/assets_data/presets/bin/gws-tasks.py +181 -0
- fkf/assets_data/presets/bin/huggingface-repositories-json.py +84 -0
- fkf/assets_data/presets/bin/jira-issues-json.py +144 -0
- fkf/assets_data/presets/bin/kaggle-competitions-json.sh +16 -0
- fkf/assets_data/presets/bin/kaggle-datasets-json.sh +17 -0
- fkf/assets_data/presets/bin/kaggle-json.py +126 -0
- fkf/assets_data/presets/bin/kaggle-kernels-json.py +141 -0
- fkf/assets_data/presets/bin/kaggle-models-json.py +120 -0
- fkf/assets_data/presets/bin/mise-tools-json.sh +16 -0
- fkf/assets_data/presets/bin/repository-facts.py +244 -0
- fkf/assets_data/presets/bin/rss-json.py +619 -0
- fkf/assets_data/presets/bin/writing-index.py +285 -0
- fkf/assets_data/presets/bin/writing-source-json.sh +11 -0
- fkf/assets_data/presets/evals/queries.yaml +20 -0
- fkf/assets_data/presets/minimal.yaml +17 -0
- fkf/assets_data/presets/personal.yaml +923 -0
- fkf/assets_data/presets/team.yaml +121 -0
- fkf/assets_data/skills/daily-brief/SKILL.md +41 -0
- fkf/assets_data/skills/fkf-learn/SKILL.md +97 -0
- fkf/assets_data/skills/fkf-learn/references/skill-evolution.md +15 -0
- fkf/assets_data/skills/fkf-use/SKILL.md +82 -0
- fkf/assets_data/skills/fkf-use/references/retrieval-feedback.md +22 -0
- fkf/assets_data/skills/fkf-use/references/source-and-graph.md +57 -0
- fkf/auth.py +62 -0
- fkf/base.py +204 -0
- fkf/bodies.py +794 -0
- fkf/build.py +273 -0
- fkf/cli.py +96 -0
- fkf/cli_ask.py +417 -0
- fkf/cli_browse.py +372 -0
- fkf/cli_integrate.py +225 -0
- fkf/cli_learn.py +189 -0
- fkf/cli_mcp.py +97 -0
- fkf/cli_operate.py +420 -0
- fkf/cli_setup.py +148 -0
- fkf/cli_support.py +258 -0
- fkf/cli_temporal.py +183 -0
- fkf/collection.py +84 -0
- fkf/config.py +1114 -0
- fkf/config_view.py +171 -0
- fkf/context.py +2620 -0
- fkf/day.py +1357 -0
- fkf/documents.py +910 -0
- fkf/errors.py +91 -0
- fkf/eval.py +381 -0
- fkf/fields.py +775 -0
- fkf/find.py +1000 -0
- fkf/graph.py +2393 -0
- fkf/harness.py +1091 -0
- fkf/helpers.py +173 -0
- fkf/init.py +1437 -0
- fkf/io.py +109 -0
- fkf/jsoncodec.py +247 -0
- fkf/learn.py +1113 -0
- fkf/learned.py +166 -0
- fkf/lexical.py +3452 -0
- fkf/listings.py +259 -0
- fkf/locking.py +335 -0
- fkf/logging_config.py +64 -0
- fkf/markdown.py +731 -0
- fkf/marked_block.py +132 -0
- fkf/mcp_paging.py +294 -0
- fkf/mcp_server.py +1334 -0
- fkf/new.py +253 -0
- fkf/output.py +127 -0
- fkf/pages.py +349 -0
- fkf/process.py +620 -0
- fkf/py.typed +0 -0
- fkf/query.py +234 -0
- fkf/read.py +484 -0
- fkf/scan.py +21 -0
- fkf/schedule.py +515 -0
- fkf/schema.py +494 -0
- fkf/selector.py +171 -0
- fkf/source_runtime.py +411 -0
- fkf/source_tests.py +142 -0
- fkf/status.py +1335 -0
- fkf/store.py +496 -0
- fkf/sync.py +1063 -0
- fkf/timeutil.py +305 -0
- fkf/trust.py +574 -0
- fkf/trust_service.py +237 -0
- fkf/uri.py +355 -0
- fkf/validation.py +487 -0
- fkf/verify.py +65 -0
- fkf/wiki_index.py +167 -0
- fkf-5.0.0.dist-info/METADATA +210 -0
- fkf-5.0.0.dist-info/RECORD +128 -0
- fkf-5.0.0.dist-info/WHEEL +4 -0
- fkf-5.0.0.dist-info/entry_points.txt +3 -0
- fkf-5.0.0.dist-info/licenses/LICENSE +21 -0
- fkf-5.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +2187 -0
fkf/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Local, offline evidence retrieval for coding agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
from typing import NoReturn
|
|
7
|
+
|
|
8
|
+
__version__ = version("fkf")
|
|
9
|
+
DISPLAY_VERSION = f"v{__version__}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> NoReturn:
|
|
13
|
+
"""Run the ``fkf`` console command."""
|
|
14
|
+
from fkf.cli import app
|
|
15
|
+
from fkf.cli_support import exit_main
|
|
16
|
+
from fkf.logging_config import configure_logging
|
|
17
|
+
|
|
18
|
+
configure_logging()
|
|
19
|
+
exit_main(app)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = ["DISPLAY_VERSION", "__version__", "main"]
|
fkf/__main__.py
ADDED
fkf/assets.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Wheel-safe access to FKF's exact bundled presets, helpers, skills, and evals."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import struct
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from importlib.resources.abc import Traversable
|
|
10
|
+
from pathlib import PurePosixPath
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
PRESETS: Final = ("minimal", "personal", "team")
|
|
14
|
+
BUNDLED_SKILLS: Final = ("fkf-use", "fkf-learn", "daily-brief")
|
|
15
|
+
HOOK_SCRIPT: Final = "fkf-hook.py"
|
|
16
|
+
DEMO_HELPER: Final = "fkf-demo-json.sh"
|
|
17
|
+
_PYTHON_BYTECODE_SUFFIXES: Final = frozenset({".pyc", ".pyo"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def asset_root() -> Traversable:
|
|
21
|
+
"""Return the package resource tree without assuming a filesystem install."""
|
|
22
|
+
return resources.files("fkf").joinpath("assets_data")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parts(relative: str) -> tuple[str, ...]:
|
|
26
|
+
path = PurePosixPath(relative)
|
|
27
|
+
if not relative or path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts):
|
|
28
|
+
raise ValueError(f"invalid bundled asset path {relative!r}")
|
|
29
|
+
return path.parts
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def asset(relative: str) -> Traversable:
|
|
33
|
+
"""Resolve one confined package resource."""
|
|
34
|
+
return asset_root().joinpath(*_parts(relative))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_asset(relative: str) -> bytes:
|
|
38
|
+
"""Read one exact bundled file from a source tree, wheel, or zip import."""
|
|
39
|
+
target = asset(relative)
|
|
40
|
+
if not target.is_file():
|
|
41
|
+
raise FileNotFoundError(f"bundled asset {relative!r} is missing or not a file")
|
|
42
|
+
return target.read_bytes()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _walk_files(root: Traversable, prefix: str = "") -> list[tuple[str, bytes]]:
|
|
46
|
+
files: list[tuple[str, bytes]] = []
|
|
47
|
+
for entry in sorted(root.iterdir(), key=lambda item: item.name):
|
|
48
|
+
relative = f"{prefix}/{entry.name}" if prefix else entry.name
|
|
49
|
+
if entry.is_dir():
|
|
50
|
+
# Editable installs expose ignored interpreter caches that wheels and sdists omit.
|
|
51
|
+
if entry.name == "__pycache__":
|
|
52
|
+
continue
|
|
53
|
+
files.extend(_walk_files(entry, relative))
|
|
54
|
+
elif entry.is_file():
|
|
55
|
+
if PurePosixPath(entry.name).suffix in _PYTHON_BYTECODE_SUFFIXES:
|
|
56
|
+
continue
|
|
57
|
+
files.append((relative, entry.read_bytes()))
|
|
58
|
+
else:
|
|
59
|
+
raise ValueError(f"bundled asset {relative!r} is neither a regular file nor a directory")
|
|
60
|
+
return files
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def asset_files(relative: str) -> tuple[tuple[str, bytes], ...]:
|
|
64
|
+
"""Return all files below one bundled directory in stable relative-path order."""
|
|
65
|
+
root = asset(relative)
|
|
66
|
+
if not root.is_dir():
|
|
67
|
+
raise FileNotFoundError(f"bundled asset directory {relative!r} is missing")
|
|
68
|
+
return tuple(_walk_files(root))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def digest_tree(files: Mapping[str, bytes] | Traversable) -> str:
|
|
72
|
+
"""Hash a tree with FKF's injective path/content framing."""
|
|
73
|
+
entries = _walk_files(files) if isinstance(files, Traversable) else sorted(files.items())
|
|
74
|
+
digest = hashlib.sha256()
|
|
75
|
+
for relative, content in entries:
|
|
76
|
+
normalized = PurePosixPath(relative).as_posix()
|
|
77
|
+
encoded = normalized.encode()
|
|
78
|
+
digest.update(b"P")
|
|
79
|
+
digest.update(struct.pack(">Q", len(encoded)))
|
|
80
|
+
digest.update(encoded)
|
|
81
|
+
digest.update(b"C")
|
|
82
|
+
digest.update(struct.pack(">Q", len(content)))
|
|
83
|
+
digest.update(content)
|
|
84
|
+
return digest.hexdigest()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def skill_digest(name: str) -> str:
|
|
88
|
+
"""Return the exact digest of one closed-vocabulary bundled skill."""
|
|
89
|
+
if name not in BUNDLED_SKILLS:
|
|
90
|
+
raise ValueError(f"unknown bundled skill {name!r}")
|
|
91
|
+
target = asset(f"skills/{name}")
|
|
92
|
+
if not target.is_dir():
|
|
93
|
+
raise FileNotFoundError(f"bundled skill {name!r} is missing")
|
|
94
|
+
return digest_tree(target)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def shipped_helpers() -> dict[str, bytes]:
|
|
98
|
+
"""Load every official helper by basename in deterministic order."""
|
|
99
|
+
helpers = dict(asset_files("presets/bin"))
|
|
100
|
+
if HOOK_SCRIPT not in helpers:
|
|
101
|
+
raise FileNotFoundError(f"bundled helper {HOOK_SCRIPT!r} is missing")
|
|
102
|
+
return helpers
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = [
|
|
106
|
+
"BUNDLED_SKILLS",
|
|
107
|
+
"DEMO_HELPER",
|
|
108
|
+
"HOOK_SCRIPT",
|
|
109
|
+
"PRESETS",
|
|
110
|
+
"asset",
|
|
111
|
+
"asset_files",
|
|
112
|
+
"asset_root",
|
|
113
|
+
"digest_tree",
|
|
114
|
+
"read_asset",
|
|
115
|
+
"shipped_helpers",
|
|
116
|
+
"skill_digest",
|
|
117
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -eu
|
|
3
|
+
|
|
4
|
+
[ "$#" -eq 2 ] || { echo "usage: fkf-demo-json.sh <source> <date>" >&2; exit 2; }
|
|
5
|
+
source=$1
|
|
6
|
+
date=$2
|
|
7
|
+
case "$source" in
|
|
8
|
+
github-pull-requests|google-calendar-events|google-gmail-emails|jira-issues|git-commits|shell-commands) ;;
|
|
9
|
+
*) echo "unknown synthetic source: $source" >&2; exit 2 ;;
|
|
10
|
+
esac
|
|
11
|
+
case "$date" in
|
|
12
|
+
????-??-??) ;;
|
|
13
|
+
*) echo "date must be YYYY-MM-DD" >&2; exit 2 ;;
|
|
14
|
+
esac
|
|
15
|
+
case "$date" in
|
|
16
|
+
*[!0-9-]*) echo "date must be YYYY-MM-DD" >&2; exit 2 ;;
|
|
17
|
+
esac
|
|
18
|
+
|
|
19
|
+
printf '[{"id":"%s-%s-0","time":"%s","title":"Synthetic %s activity","url":"https://example.test/demo/%s/%s","repo":"repo:github.com/fmind/fkf","author":"person:email/demo@example.test","attendees":["person:email/demo@example.test"],"from":"person:email/demo@example.test","to":["person:email/reader@example.test"],"assignee":"person:email/demo@example.test","ticket":"ticket:DEMO-1"}]\n' \
|
|
20
|
+
"$source" "$date" "$date" "$source" "$source" "$date"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Print one bounded file from the reviewed coding-agent memory roots."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
LIMIT = 4 * 1024 * 1024
|
|
12
|
+
READ_BYTES = 64 << 10
|
|
13
|
+
type PathPart = str | bytes | os.PathLike[str] | os.PathLike[bytes]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def reviewed_root(path: Path, home: Path) -> Path | None:
|
|
17
|
+
candidates = (
|
|
18
|
+
(home / ".claude" / "projects", lambda parts: len(parts) == 3 and parts[1] == "memory"),
|
|
19
|
+
(home / ".codex" / "memories", lambda parts: 1 <= len(parts) <= 2),
|
|
20
|
+
(home / ".gemini" / "tmp", lambda parts: len(parts) == 3 and parts[1] == "memory"),
|
|
21
|
+
(home / ".grok" / "memory", lambda parts: 1 <= len(parts) <= 2),
|
|
22
|
+
)
|
|
23
|
+
for root, shape in candidates:
|
|
24
|
+
try:
|
|
25
|
+
relative = path.relative_to(root)
|
|
26
|
+
except ValueError:
|
|
27
|
+
continue
|
|
28
|
+
if path.suffix == ".md" and ".." not in relative.parts and shape(relative.parts):
|
|
29
|
+
return root
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fingerprint(value: os.stat_result) -> tuple[int, int, int, int, int, int]:
|
|
34
|
+
return value.st_dev, value.st_ino, value.st_mode, value.st_size, value.st_mtime_ns, value.st_ctime_ns
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def open_directory(path: PathPart, parent: int | None = None) -> int:
|
|
38
|
+
inspected = os.stat(path, dir_fd=parent, follow_symlinks=False)
|
|
39
|
+
if not stat.S_ISDIR(inspected.st_mode):
|
|
40
|
+
raise RuntimeError("memory root is missing or linked")
|
|
41
|
+
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
42
|
+
descriptor = os.open(path, flags, dir_fd=parent)
|
|
43
|
+
if fingerprint(inspected) != fingerprint(os.fstat(descriptor)):
|
|
44
|
+
os.close(descriptor)
|
|
45
|
+
raise RuntimeError("memory root changed while it was being opened")
|
|
46
|
+
return descriptor
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def open_chain(home: Path, components: tuple[str, ...]) -> int:
|
|
50
|
+
descriptor = open_directory(home)
|
|
51
|
+
try:
|
|
52
|
+
for component in components:
|
|
53
|
+
child = open_directory(component, descriptor)
|
|
54
|
+
os.close(descriptor)
|
|
55
|
+
descriptor = child
|
|
56
|
+
except BaseException:
|
|
57
|
+
os.close(descriptor)
|
|
58
|
+
raise
|
|
59
|
+
return descriptor
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_body(home: Path, path: Path) -> bytes:
|
|
63
|
+
relative = path.relative_to(home)
|
|
64
|
+
parent = open_chain(home, relative.parts[:-1])
|
|
65
|
+
descriptor = -1
|
|
66
|
+
try:
|
|
67
|
+
name = relative.parts[-1]
|
|
68
|
+
inspected = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
69
|
+
if not stat.S_ISREG(inspected.st_mode):
|
|
70
|
+
raise RuntimeError("file is absent, linked, or outside the reviewed roots")
|
|
71
|
+
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
72
|
+
descriptor = os.open(name, flags, dir_fd=parent)
|
|
73
|
+
opened = os.fstat(descriptor)
|
|
74
|
+
if fingerprint(inspected) != fingerprint(opened):
|
|
75
|
+
raise RuntimeError("memory file changed while it was being opened")
|
|
76
|
+
output = bytearray()
|
|
77
|
+
while chunk := os.read(descriptor, min(READ_BYTES, LIMIT + 1 - len(output))):
|
|
78
|
+
output.extend(chunk)
|
|
79
|
+
if len(output) > LIMIT:
|
|
80
|
+
raise RuntimeError(f"file exceeds the {LIMIT}-byte body limit")
|
|
81
|
+
after = os.fstat(descriptor)
|
|
82
|
+
current = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
83
|
+
if fingerprint(opened) != fingerprint(after) or fingerprint(opened) != fingerprint(current):
|
|
84
|
+
raise RuntimeError("memory file changed while it was being read")
|
|
85
|
+
return bytes(output)
|
|
86
|
+
finally:
|
|
87
|
+
if descriptor >= 0:
|
|
88
|
+
os.close(descriptor)
|
|
89
|
+
os.close(parent)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main(arguments: list[str]) -> int:
|
|
93
|
+
if arguments[:1] in (["--version"], ["-v"]):
|
|
94
|
+
sys.stdout.write("agent-memory-body.py (fkf base helper)\n")
|
|
95
|
+
return 0
|
|
96
|
+
if len(arguments) != 1:
|
|
97
|
+
sys.stderr.write("usage: agent-memory-body.py <absolute-memory-file>\n")
|
|
98
|
+
return 2
|
|
99
|
+
path = Path(arguments[0])
|
|
100
|
+
root = reviewed_root(path, Path(os.environ.get("HOME", "")))
|
|
101
|
+
if root is None:
|
|
102
|
+
sys.stderr.write("agent-memory-body.py: path is outside the reviewed harness memory roots\n")
|
|
103
|
+
return 2
|
|
104
|
+
try:
|
|
105
|
+
content = read_body(Path(os.environ.get("HOME", "")), path)
|
|
106
|
+
except RuntimeError as error:
|
|
107
|
+
sys.stderr.write(f"agent-memory-body.py: {error}\n")
|
|
108
|
+
return 1
|
|
109
|
+
except OSError:
|
|
110
|
+
sys.stderr.write("agent-memory-body.py: file is absent, linked, or outside the reviewed roots\n")
|
|
111
|
+
return 1
|
|
112
|
+
sys.stdout.buffer.write(content)
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Project bounded metadata for coding-agent memory files touched in a UTC window."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
PREVIEW_BYTES = 65_536
|
|
17
|
+
PREVIEW_LINES = 256
|
|
18
|
+
READ_BYTES = 64 << 10
|
|
19
|
+
MAX_FILES = 8192
|
|
20
|
+
MAX_RECORDS = 8192
|
|
21
|
+
MAX_OUTPUT_BYTES = 64 << 20
|
|
22
|
+
type PathPart = str | bytes | os.PathLike[str] | os.PathLike[bytes]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class MemoryFile:
|
|
27
|
+
path: Path
|
|
28
|
+
inspected: os.stat_result
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def instant(value: str) -> datetime:
|
|
32
|
+
parsed = datetime.fromisoformat(value)
|
|
33
|
+
if parsed.tzinfo is None:
|
|
34
|
+
raise ValueError("timestamp must include an offset")
|
|
35
|
+
return parsed.astimezone(UTC)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fingerprint(value: os.stat_result) -> tuple[int, int, int, int, int, int]:
|
|
39
|
+
return value.st_dev, value.st_ino, value.st_mode, value.st_size, value.st_mtime_ns, value.st_ctime_ns
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def open_directory(path: PathPart, parent: int | None = None, expected: os.stat_result | None = None) -> int:
|
|
43
|
+
inspected = os.stat(path, dir_fd=parent, follow_symlinks=False)
|
|
44
|
+
if not stat.S_ISDIR(inspected.st_mode):
|
|
45
|
+
raise RuntimeError("memory root is missing or linked")
|
|
46
|
+
if expected is not None and fingerprint(expected) != fingerprint(inspected):
|
|
47
|
+
raise RuntimeError("memory root changed while it was being opened")
|
|
48
|
+
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
49
|
+
descriptor = os.open(path, flags, dir_fd=parent)
|
|
50
|
+
if fingerprint(inspected) != fingerprint(os.fstat(descriptor)):
|
|
51
|
+
os.close(descriptor)
|
|
52
|
+
raise RuntimeError("memory root changed while it was being opened")
|
|
53
|
+
return descriptor
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def open_chain(home: Path, components: tuple[str, ...]) -> int:
|
|
57
|
+
descriptor = open_directory(home)
|
|
58
|
+
try:
|
|
59
|
+
for component in components:
|
|
60
|
+
child = open_directory(component, descriptor)
|
|
61
|
+
os.close(descriptor)
|
|
62
|
+
descriptor = child
|
|
63
|
+
except BaseException:
|
|
64
|
+
os.close(descriptor)
|
|
65
|
+
raise
|
|
66
|
+
return descriptor
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def read_prefix(home: Path, candidate: MemoryFile) -> bytes:
|
|
70
|
+
relative = candidate.path.relative_to(home)
|
|
71
|
+
parent = open_chain(home, relative.parts[:-1])
|
|
72
|
+
descriptor = -1
|
|
73
|
+
try:
|
|
74
|
+
name = relative.parts[-1]
|
|
75
|
+
inspected = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
76
|
+
if not stat.S_ISREG(inspected.st_mode):
|
|
77
|
+
raise RuntimeError("memory file is missing or linked")
|
|
78
|
+
if fingerprint(candidate.inspected) != fingerprint(inspected):
|
|
79
|
+
raise RuntimeError("memory file changed while it was being opened")
|
|
80
|
+
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
81
|
+
descriptor = os.open(name, flags, dir_fd=parent)
|
|
82
|
+
opened = os.fstat(descriptor)
|
|
83
|
+
if fingerprint(inspected) != fingerprint(opened):
|
|
84
|
+
raise RuntimeError("memory file changed while it was being opened")
|
|
85
|
+
output = bytearray()
|
|
86
|
+
while len(output) < PREVIEW_BYTES and (
|
|
87
|
+
chunk := os.read(descriptor, min(READ_BYTES, PREVIEW_BYTES - len(output)))
|
|
88
|
+
):
|
|
89
|
+
output.extend(chunk)
|
|
90
|
+
after = os.fstat(descriptor)
|
|
91
|
+
current = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
92
|
+
if fingerprint(opened) != fingerprint(after) or fingerprint(opened) != fingerprint(current):
|
|
93
|
+
raise RuntimeError("memory file changed while it was being read")
|
|
94
|
+
return bytes(output)
|
|
95
|
+
finally:
|
|
96
|
+
if descriptor >= 0:
|
|
97
|
+
os.close(descriptor)
|
|
98
|
+
os.close(parent)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def title_of(home: Path, candidate: MemoryFile) -> str:
|
|
102
|
+
preview = read_prefix(home, candidate).decode("utf-8", errors="replace").splitlines()[:PREVIEW_LINES]
|
|
103
|
+
front = bool(preview and preview[0] == "---")
|
|
104
|
+
for index, line in enumerate(preview):
|
|
105
|
+
if index == 0 and front:
|
|
106
|
+
continue
|
|
107
|
+
if front and line == "---":
|
|
108
|
+
front = False
|
|
109
|
+
continue
|
|
110
|
+
if front and line.startswith("name: "):
|
|
111
|
+
return line.removeprefix("name: ")
|
|
112
|
+
if not front and line.startswith("# "):
|
|
113
|
+
return line.removeprefix("# ")
|
|
114
|
+
return candidate.path.stem
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def clean_title(value: str) -> str:
|
|
118
|
+
printable = "".join(" " if ord(character) < 32 or ord(character) == 127 else character for character in value)
|
|
119
|
+
return re.sub(r"\s+", " ", printable).strip()[:160]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def allowed_file(parts: tuple[str, ...], memory_component: bool) -> bool:
|
|
123
|
+
if memory_component:
|
|
124
|
+
return len(parts) == 3 and parts[1] == "memory"
|
|
125
|
+
return 1 <= len(parts) <= 2
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def allowed_directory(parts: tuple[str, ...], memory_component: bool) -> bool:
|
|
129
|
+
if memory_component:
|
|
130
|
+
return len(parts) == 1 or (len(parts) == 2 and parts[1] == "memory")
|
|
131
|
+
return len(parts) == 1
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def scan_files(
|
|
135
|
+
descriptor: int,
|
|
136
|
+
root: Path,
|
|
137
|
+
parts: tuple[str, ...],
|
|
138
|
+
memory_component: bool,
|
|
139
|
+
maximum: int,
|
|
140
|
+
results: list[MemoryFile],
|
|
141
|
+
) -> None:
|
|
142
|
+
with os.scandir(descriptor) as entries:
|
|
143
|
+
for entry in entries:
|
|
144
|
+
relative = (*parts, entry.name)
|
|
145
|
+
inspected = entry.stat(follow_symlinks=False)
|
|
146
|
+
if stat.S_ISDIR(inspected.st_mode) and allowed_directory(relative, memory_component):
|
|
147
|
+
child = open_directory(entry.name, descriptor, inspected)
|
|
148
|
+
try:
|
|
149
|
+
scan_files(child, root, relative, memory_component, maximum, results)
|
|
150
|
+
finally:
|
|
151
|
+
os.close(child)
|
|
152
|
+
elif (
|
|
153
|
+
stat.S_ISREG(inspected.st_mode)
|
|
154
|
+
and entry.name.endswith(".md")
|
|
155
|
+
and allowed_file(relative, memory_component)
|
|
156
|
+
):
|
|
157
|
+
if len(results) >= maximum:
|
|
158
|
+
raise RuntimeError(f"more than {MAX_FILES} memory files")
|
|
159
|
+
results.append(MemoryFile(root.joinpath(*relative), inspected))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def files(home: Path, root: Path, *, memory_component: bool, maximum: int) -> list[MemoryFile]:
|
|
163
|
+
try:
|
|
164
|
+
descriptor = open_chain(home, root.relative_to(home).parts)
|
|
165
|
+
except FileNotFoundError:
|
|
166
|
+
return []
|
|
167
|
+
results: list[MemoryFile] = []
|
|
168
|
+
try:
|
|
169
|
+
scan_files(descriptor, root, (), memory_component, maximum, results)
|
|
170
|
+
finally:
|
|
171
|
+
os.close(descriptor)
|
|
172
|
+
return sorted(results, key=lambda item: os.fspath(item.path))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main(arguments: list[str]) -> int:
|
|
176
|
+
if len(arguments) != 2:
|
|
177
|
+
sys.stderr.write("usage: agent-memory-files.py <start> <end>\n")
|
|
178
|
+
return 2
|
|
179
|
+
try:
|
|
180
|
+
start, end = map(instant, arguments)
|
|
181
|
+
home = Path(os.environ.get("HOME", ""))
|
|
182
|
+
roots = (
|
|
183
|
+
(home / ".claude" / "projects", "claude", True),
|
|
184
|
+
(home / ".codex" / "memories", "codex", False),
|
|
185
|
+
(home / ".gemini" / "tmp", "gemini", True),
|
|
186
|
+
(home / ".grok" / "memory", "grok", False),
|
|
187
|
+
)
|
|
188
|
+
candidates: list[tuple[MemoryFile, str]] = []
|
|
189
|
+
for root, agent, memory_component in roots:
|
|
190
|
+
candidates.extend(
|
|
191
|
+
(candidate, agent)
|
|
192
|
+
for candidate in files(
|
|
193
|
+
home,
|
|
194
|
+
root,
|
|
195
|
+
memory_component=memory_component,
|
|
196
|
+
maximum=MAX_FILES - len(candidates),
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
records: list[dict[str, Any]] = []
|
|
200
|
+
for candidate, agent in candidates:
|
|
201
|
+
modified = datetime.fromtimestamp(candidate.inspected.st_mtime, UTC)
|
|
202
|
+
if not start <= modified < end:
|
|
203
|
+
continue
|
|
204
|
+
if len(records) >= MAX_RECORDS:
|
|
205
|
+
raise RuntimeError(f"more than {MAX_RECORDS} memory records")
|
|
206
|
+
records.append(
|
|
207
|
+
{
|
|
208
|
+
"id": os.fspath(candidate.path),
|
|
209
|
+
"time": modified.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
210
|
+
"title": clean_title(title_of(home, candidate)),
|
|
211
|
+
"agent": agent,
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
output = (json.dumps(records, ensure_ascii=False, separators=(",", ":")) + "\n").encode()
|
|
215
|
+
if len(output) > MAX_OUTPUT_BYTES:
|
|
216
|
+
raise RuntimeError("output exceeds 64 MiB")
|
|
217
|
+
except (OSError, RuntimeError, UnicodeError, ValueError) as error:
|
|
218
|
+
sys.stderr.write(f"agent-memory-files.py: {error}\n")
|
|
219
|
+
return 1
|
|
220
|
+
sys.stdout.buffer.write(output)
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
raise SystemExit(main(sys.argv[1:]))
|