@event4u/agent-config 1.9.1 → 1.12.0
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.
- package/.agent-src/commands/agent-handoff.md +15 -0
- package/.agent-src/commands/chat-history-clear.md +98 -0
- package/.agent-src/commands/chat-history-resume.md +178 -0
- package/.agent-src/commands/chat-history.md +102 -0
- package/.agent-src/commands/compress.md +9 -9
- package/.agent-src/commands/copilot-agents-init.md +1 -1
- package/.agent-src/commands/fix-portability.md +2 -2
- package/.agent-src/commands/fix-pr-bot-comments.md +1 -1
- package/.agent-src/commands/fix-pr-developer-comments.md +1 -1
- package/.agent-src/commands/fix-references.md +2 -2
- package/.agent-src/commands/mode.md +5 -5
- package/.agent-src/commands/onboard.md +171 -0
- package/.agent-src/commands/roadmap-create.md +7 -2
- package/.agent-src/commands/roadmap-execute.md +2 -2
- package/.agent-src/commands/set-cost-profile.md +101 -0
- package/.agent-src/commands/sync-agent-settings.md +122 -0
- package/.agent-src/commands/sync-gitignore.md +104 -0
- package/.agent-src/commands/tests-execute.md +6 -6
- package/.agent-src/commands/upstream-contribute.md +5 -4
- package/.agent-src/contexts/augment-infrastructure.md +2 -2
- package/.agent-src/contexts/override-system.md +1 -1
- package/.agent-src/contexts/subagent-configuration.md +3 -3
- package/.agent-src/guidelines/agent-infra/layered-settings.md +48 -5
- package/.agent-src/rules/ask-when-uncertain.md +56 -3
- package/.agent-src/rules/augment-portability.md +52 -1
- package/.agent-src/rules/augment-source-of-truth.md +10 -10
- package/.agent-src/rules/chat-history.md +171 -0
- package/.agent-src/rules/docker-commands.md +5 -7
- package/.agent-src/rules/docs-sync.md +13 -9
- package/.agent-src/rules/improve-before-implement.md +2 -0
- package/.agent-src/rules/onboarding-gate.md +94 -0
- package/.agent-src/rules/package-ci-checks.md +6 -5
- package/.agent-src/rules/roadmap-progress-sync.md +24 -13
- package/.agent-src/rules/size-enforcement.md +1 -1
- package/.agent-src/rules/skill-quality.md +1 -1
- package/.agent-src/rules/think-before-action.md +1 -0
- package/.agent-src/scripts/update_roadmap_progress.py +26 -9
- package/.agent-src/skills/check-refs/SKILL.md +1 -1
- package/.agent-src/skills/command-routing/SKILL.md +1 -1
- package/.agent-src/skills/command-writing/SKILL.md +4 -3
- package/.agent-src/skills/file-editor/SKILL.md +2 -2
- package/.agent-src/skills/guideline-writing/SKILL.md +4 -3
- package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +2 -2
- package/.agent-src/skills/lint-skills/SKILL.md +1 -1
- package/.agent-src/skills/roadmap-management/SKILL.md +13 -10
- package/.agent-src/skills/rtk-output-filtering/SKILL.md +20 -30
- package/.agent-src/skills/rule-writing/SKILL.md +5 -5
- package/.agent-src/skills/terragrunt/SKILL.md +0 -8
- package/.agent-src/skills/upstream-contribute/SKILL.md +5 -4
- package/.agent-src/templates/agent-settings.md +86 -34
- package/.claude-plugin/marketplace.json +1 -1
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +296 -0
- package/CONTRIBUTING.md +89 -40
- package/README.md +3 -3
- package/composer.json +2 -1
- package/config/agent-settings.template.yml +45 -6
- package/config/gitignore-block.txt +24 -0
- package/config/profiles/balanced.ini +5 -0
- package/config/profiles/full.ini +5 -0
- package/config/profiles/minimal.ini +5 -0
- package/docs/customization.md +30 -4
- package/docs/getting-started.md +52 -3
- package/docs/mcp.md +15 -4
- package/package.json +13 -2
- package/scripts/agent-config +155 -0
- package/scripts/chat_history.py +519 -0
- package/scripts/check_portability.py +151 -1
- package/scripts/install.py +55 -3
- package/scripts/install.sh +50 -21
- package/scripts/mcp_render.py +30 -16
- package/scripts/release.py +588 -0
- package/scripts/sync_agent_settings.py +211 -0
- package/scripts/sync_gitignore.py +226 -0
- package/templates/agent-config-wrapper.sh +47 -0
- package/.agent-src/commands/config-agent-settings.md +0 -126
- package/.agent-src/skills/eloquent/evals/last-run.json +0 -99
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# agent-config — consumer-facing CLI for the event4u/agent-config package.
|
|
3
|
+
#
|
|
4
|
+
# This is the MASTER entrypoint shipped inside the package
|
|
5
|
+
# (node_modules/@event4u/agent-config/scripts/agent-config or
|
|
6
|
+
# vendor/event4u/agent-config/scripts/agent-config). A thin wrapper at
|
|
7
|
+
# the consumer's repo root (`./agent-config`) delegates here.
|
|
8
|
+
#
|
|
9
|
+
# Commands are strictly consumer-facing. Maintainer workflows stay in
|
|
10
|
+
# Taskfile.yml and are NOT exposed here.
|
|
11
|
+
#
|
|
12
|
+
# Invariants:
|
|
13
|
+
# * CWD on entry is the consumer's repo root — we keep it that way
|
|
14
|
+
# so underlying scripts resolve paths correctly.
|
|
15
|
+
# * PACKAGE_ROOT is derived from this script's location, used only to
|
|
16
|
+
# locate the package-internal Python scripts (mcp_render.py, …).
|
|
17
|
+
# * Unknown arguments are forwarded verbatim to the underlying script.
|
|
18
|
+
|
|
19
|
+
set -euo pipefail
|
|
20
|
+
|
|
21
|
+
# Resolve symlinks in BASH_SOURCE so PACKAGE_ROOT points at the real
|
|
22
|
+
# package directory even when invoked via a symlink (global npm install,
|
|
23
|
+
# vendor/bin symlink, user-placed symlink on PATH, …).
|
|
24
|
+
SOURCE="${BASH_SOURCE[0]}"
|
|
25
|
+
while [ -L "$SOURCE" ]; do
|
|
26
|
+
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
|
27
|
+
SOURCE="$(readlink "$SOURCE")"
|
|
28
|
+
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
|
|
29
|
+
done
|
|
30
|
+
SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
|
31
|
+
PACKAGE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
32
|
+
CONSUMER_ROOT="$(pwd)"
|
|
33
|
+
|
|
34
|
+
VERSION_FILE="$PACKAGE_ROOT/package.json"
|
|
35
|
+
|
|
36
|
+
usage() {
|
|
37
|
+
cat <<'EOF'
|
|
38
|
+
agent-config — event4u/agent-config CLI
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
./agent-config <command> [options]
|
|
42
|
+
|
|
43
|
+
Commands:
|
|
44
|
+
mcp:render Render mcp.json → .cursor/mcp.json, .windsurf/mcp.json
|
|
45
|
+
(pass --claude-desktop to also write user-scope config)
|
|
46
|
+
mcp:check Dry-run mcp:render; exit non-zero if targets are stale
|
|
47
|
+
roadmap:progress Regenerate agents/roadmaps-progress.md from open roadmaps
|
|
48
|
+
roadmap:progress-check Fail if agents/roadmaps-progress.md is stale (for CI)
|
|
49
|
+
first-run Guided first-run setup — cost profile, settings, tooling
|
|
50
|
+
help Show this help
|
|
51
|
+
--version, -V Print package version
|
|
52
|
+
|
|
53
|
+
Examples:
|
|
54
|
+
./agent-config mcp:render
|
|
55
|
+
./agent-config mcp:render --claude-desktop
|
|
56
|
+
./agent-config mcp:check
|
|
57
|
+
./agent-config roadmap:progress
|
|
58
|
+
./agent-config first-run
|
|
59
|
+
|
|
60
|
+
All commands operate on the CURRENT DIRECTORY (your project root).
|
|
61
|
+
The CLI is strictly consumer-facing. Maintainer tasks live in Taskfile.yml.
|
|
62
|
+
EOF
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
print_version() {
|
|
66
|
+
if [[ -f "$VERSION_FILE" ]] && command -v python3 >/dev/null 2>&1; then
|
|
67
|
+
python3 -c "import json; print(json.load(open('$VERSION_FILE'))['version'])"
|
|
68
|
+
else
|
|
69
|
+
echo "unknown"
|
|
70
|
+
fi
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
require_python3() {
|
|
74
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
75
|
+
echo "❌ agent-config: python3 not found on PATH" >&2
|
|
76
|
+
echo " Install Python 3.10+ and retry." >&2
|
|
77
|
+
exit 127
|
|
78
|
+
fi
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Locate a script. First argument is relative to PACKAGE_ROOT, second is
|
|
82
|
+
# an optional fallback relative to CONSUMER_ROOT (for scripts that ship
|
|
83
|
+
# to the consumer via .augment/, e.g. update_roadmap_progress.py).
|
|
84
|
+
resolve_script() {
|
|
85
|
+
local pkg_rel="$1"
|
|
86
|
+
local consumer_rel="${2-}"
|
|
87
|
+
local pkg_abs="$PACKAGE_ROOT/$pkg_rel"
|
|
88
|
+
if [[ -f "$pkg_abs" ]]; then
|
|
89
|
+
printf '%s' "$pkg_abs"
|
|
90
|
+
return 0
|
|
91
|
+
fi
|
|
92
|
+
if [[ -n "$consumer_rel" && -f "$CONSUMER_ROOT/$consumer_rel" ]]; then
|
|
93
|
+
printf '%s' "$CONSUMER_ROOT/$consumer_rel"
|
|
94
|
+
return 0
|
|
95
|
+
fi
|
|
96
|
+
echo "❌ agent-config: script not found: $pkg_rel" >&2
|
|
97
|
+
[[ -n "$consumer_rel" ]] && echo " (also tried: $consumer_rel in $CONSUMER_ROOT)" >&2
|
|
98
|
+
return 1
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
cmd_mcp_render() {
|
|
102
|
+
require_python3
|
|
103
|
+
local script
|
|
104
|
+
script="$(resolve_script "scripts/mcp_render.py")"
|
|
105
|
+
exec python3 "$script" "$@"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
cmd_mcp_check() {
|
|
109
|
+
require_python3
|
|
110
|
+
local script
|
|
111
|
+
script="$(resolve_script "scripts/mcp_render.py")"
|
|
112
|
+
exec python3 "$script" --check "$@"
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
cmd_roadmap_progress() {
|
|
116
|
+
require_python3
|
|
117
|
+
local script
|
|
118
|
+
script="$(resolve_script ".agent-src/scripts/update_roadmap_progress.py" ".augment/scripts/update_roadmap_progress.py")"
|
|
119
|
+
exec python3 "$script" "$@"
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
cmd_roadmap_progress_check() {
|
|
123
|
+
require_python3
|
|
124
|
+
local script
|
|
125
|
+
script="$(resolve_script ".agent-src/scripts/update_roadmap_progress.py" ".augment/scripts/update_roadmap_progress.py")"
|
|
126
|
+
exec python3 "$script" --check "$@"
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
cmd_first_run() {
|
|
130
|
+
local script
|
|
131
|
+
script="$(resolve_script "scripts/first-run.sh")"
|
|
132
|
+
exec bash "$script" "$@"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
main() {
|
|
136
|
+
local cmd="${1-}"
|
|
137
|
+
[[ $# -gt 0 ]] && shift || true
|
|
138
|
+
|
|
139
|
+
case "$cmd" in
|
|
140
|
+
mcp:render) cmd_mcp_render "$@" ;;
|
|
141
|
+
mcp:check) cmd_mcp_check "$@" ;;
|
|
142
|
+
roadmap:progress) cmd_roadmap_progress "$@" ;;
|
|
143
|
+
roadmap:progress-check) cmd_roadmap_progress_check "$@" ;;
|
|
144
|
+
first-run) cmd_first_run "$@" ;;
|
|
145
|
+
help|--help|-h|"") usage ;;
|
|
146
|
+
--version|-V) print_version ;;
|
|
147
|
+
*)
|
|
148
|
+
echo "❌ agent-config: unknown command: $cmd" >&2
|
|
149
|
+
echo " Run \`./agent-config help\` for the command list." >&2
|
|
150
|
+
exit 2
|
|
151
|
+
;;
|
|
152
|
+
esac
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
main "$@"
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Persistent chat-history log for crash recovery.
|
|
3
|
+
|
|
4
|
+
Maintains `.agent-chat-history` in the project root — a JSONL file whose
|
|
5
|
+
first line is a header (session id, fingerprint, frequency mode) and
|
|
6
|
+
whose remaining lines are append-only entries (user messages, phases,
|
|
7
|
+
tool calls, questions, answers, decisions, commits).
|
|
8
|
+
|
|
9
|
+
Ownership is established via SHA-256 of the first user message in the
|
|
10
|
+
conversation, stored in the header. Agents read this on every turn to
|
|
11
|
+
detect whether the file belongs to the current conversation.
|
|
12
|
+
|
|
13
|
+
File path defaults to `.agent-chat-history` in CWD and can be overridden
|
|
14
|
+
via `$AGENT_CHAT_HISTORY_FILE` (used by tests).
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python3 scripts/chat_history.py init --first-user-msg "..." [--freq per_phase]
|
|
18
|
+
python3 scripts/chat_history.py append --type phase --json '{...}'
|
|
19
|
+
python3 scripts/chat_history.py status
|
|
20
|
+
python3 scripts/chat_history.py check --first-user-msg "..."
|
|
21
|
+
python3 scripts/chat_history.py state --first-user-msg "..."
|
|
22
|
+
python3 scripts/chat_history.py adopt --first-user-msg "..."
|
|
23
|
+
python3 scripts/chat_history.py reset --first-user-msg "..." --entries-json '[...]' [--freq per_phase]
|
|
24
|
+
python3 scripts/chat_history.py prepend --entries-json '[...]'
|
|
25
|
+
python3 scripts/chat_history.py read [--last N | --all]
|
|
26
|
+
python3 scripts/chat_history.py clear
|
|
27
|
+
python3 scripts/chat_history.py rotate --max-kb 256 --mode rotate
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import datetime as dt
|
|
34
|
+
import hashlib
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import sys
|
|
39
|
+
import uuid
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
DEFAULT_FILE = ".agent-chat-history"
|
|
44
|
+
SCHEMA_VERSION = 2
|
|
45
|
+
FORMER_FPS_CAP = 10
|
|
46
|
+
VALID_FREQS = {"per_turn", "per_phase", "per_tool"}
|
|
47
|
+
VALID_OVERFLOW = {"rotate", "compress"}
|
|
48
|
+
_WS_RE = re.compile(r"\s+")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def file_path() -> Path:
|
|
52
|
+
return Path(os.environ.get("AGENT_CHAT_HISTORY_FILE") or DEFAULT_FILE)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _now() -> str:
|
|
56
|
+
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fingerprint(first_user_msg: str) -> str:
|
|
60
|
+
"""SHA-256 of the normalized first user message (whitespace collapsed)."""
|
|
61
|
+
normalized = _WS_RE.sub(" ", first_user_msg or "").strip()
|
|
62
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _preview(msg: str, n: int = 80) -> str:
|
|
66
|
+
flat = _WS_RE.sub(" ", msg or "").strip()
|
|
67
|
+
return flat[:n]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def read_header(path: Path | None = None) -> dict[str, Any] | None:
|
|
71
|
+
"""Read the header. Migrates v1 headers in memory (adds `former_fps: []`).
|
|
72
|
+
|
|
73
|
+
The on-disk file is not rewritten by this read; migration is lazy and
|
|
74
|
+
happens on the next write (init/adopt/reset).
|
|
75
|
+
"""
|
|
76
|
+
p = path or file_path()
|
|
77
|
+
if not p.is_file() or p.stat().st_size == 0:
|
|
78
|
+
return None
|
|
79
|
+
try:
|
|
80
|
+
with p.open(encoding="utf-8") as fh:
|
|
81
|
+
first = fh.readline().strip()
|
|
82
|
+
if not first:
|
|
83
|
+
return None
|
|
84
|
+
obj = json.loads(first)
|
|
85
|
+
if not (isinstance(obj, dict) and obj.get("t") == "header"):
|
|
86
|
+
return None
|
|
87
|
+
obj.setdefault("former_fps", [])
|
|
88
|
+
if not isinstance(obj["former_fps"], list):
|
|
89
|
+
obj["former_fps"] = []
|
|
90
|
+
return obj
|
|
91
|
+
except (json.JSONDecodeError, OSError):
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _build_header(first_user_msg: str, freq: str,
|
|
96
|
+
former_fps: list[str] | None = None) -> dict[str, Any]:
|
|
97
|
+
return {
|
|
98
|
+
"t": "header",
|
|
99
|
+
"v": SCHEMA_VERSION,
|
|
100
|
+
"session": str(uuid.uuid4()),
|
|
101
|
+
"started": _now(),
|
|
102
|
+
"fp": fingerprint(first_user_msg),
|
|
103
|
+
"preview": _preview(first_user_msg),
|
|
104
|
+
"freq": freq,
|
|
105
|
+
"former_fps": list(former_fps or []),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def init(first_user_msg: str, freq: str = "per_phase", *,
|
|
110
|
+
path: Path | None = None) -> dict[str, Any]:
|
|
111
|
+
"""Overwrite the file with a fresh header for a new session."""
|
|
112
|
+
if freq not in VALID_FREQS:
|
|
113
|
+
raise ValueError(f"freq must be one of {sorted(VALID_FREQS)}")
|
|
114
|
+
p = path or file_path()
|
|
115
|
+
header = _build_header(first_user_msg, freq)
|
|
116
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
with p.open("w", encoding="utf-8") as fh:
|
|
118
|
+
fh.write(json.dumps(header, ensure_ascii=False) + "\n")
|
|
119
|
+
return header
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def append(entry: dict[str, Any], *, path: Path | None = None) -> None:
|
|
123
|
+
"""Append one entry. Entry must be a dict; `ts` is auto-filled."""
|
|
124
|
+
if not isinstance(entry, dict) or not entry.get("t"):
|
|
125
|
+
raise ValueError("entry must be a dict with non-empty 't' key")
|
|
126
|
+
if entry["t"] == "header":
|
|
127
|
+
raise ValueError("use init() to write the header, not append()")
|
|
128
|
+
entry.setdefault("ts", _now())
|
|
129
|
+
p = path or file_path()
|
|
130
|
+
with p.open("a", encoding="utf-8") as fh:
|
|
131
|
+
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def check_ownership(first_user_msg: str, *,
|
|
135
|
+
path: Path | None = None) -> str:
|
|
136
|
+
"""Return 'match', 'mismatch', or 'missing' (legacy 3-state).
|
|
137
|
+
|
|
138
|
+
Kept for backward compatibility. Prefer `ownership_state()` for the
|
|
139
|
+
4-state view that distinguishes foreign from returning sessions.
|
|
140
|
+
"""
|
|
141
|
+
header = read_header(path)
|
|
142
|
+
if not header:
|
|
143
|
+
return "missing"
|
|
144
|
+
return "match" if header.get("fp") == fingerprint(first_user_msg) else "mismatch"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def ownership_state(first_user_msg: str, *,
|
|
148
|
+
path: Path | None = None) -> str:
|
|
149
|
+
"""Return 'match', 'returning', 'foreign', or 'missing'.
|
|
150
|
+
|
|
151
|
+
`match` — current fp equals header.fp (silent append)
|
|
152
|
+
`returning` — current fp appears in header.former_fps (this chat once
|
|
153
|
+
owned the file; another session took it over since)
|
|
154
|
+
`foreign` — current fp is neither match nor former (new chat finds
|
|
155
|
+
an existing file from an unknown session)
|
|
156
|
+
`missing` — no file or no valid header
|
|
157
|
+
"""
|
|
158
|
+
header = read_header(path)
|
|
159
|
+
if not header:
|
|
160
|
+
return "missing"
|
|
161
|
+
fp = fingerprint(first_user_msg)
|
|
162
|
+
if header.get("fp") == fp:
|
|
163
|
+
return "match"
|
|
164
|
+
former = header.get("former_fps") or []
|
|
165
|
+
return "returning" if fp in former else "foreign"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _push_former_fp(former_fps: list[str], old_fp: str,
|
|
169
|
+
new_fp: str) -> list[str]:
|
|
170
|
+
"""Move old_fp into former_fps with dedup + cap. Never include new_fp."""
|
|
171
|
+
seen: list[str] = []
|
|
172
|
+
for fp in [old_fp, *former_fps]:
|
|
173
|
+
if fp and fp != new_fp and fp not in seen:
|
|
174
|
+
seen.append(fp)
|
|
175
|
+
return seen[:FORMER_FPS_CAP]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def adopt(first_user_msg: str, *, path: Path | None = None) -> dict[str, Any]:
|
|
179
|
+
"""Rewrite the header's fingerprint to the current conversation's.
|
|
180
|
+
|
|
181
|
+
Preserves all body entries. Pushes the previous `fp` onto
|
|
182
|
+
`former_fps` (dedup, capped at FORMER_FPS_CAP) so this former owner
|
|
183
|
+
can later be detected as 'returning' if the original chat comes back.
|
|
184
|
+
"""
|
|
185
|
+
p = path or file_path()
|
|
186
|
+
header = read_header(p)
|
|
187
|
+
if not header:
|
|
188
|
+
raise FileNotFoundError(f"no header in {p}")
|
|
189
|
+
old_fp = header.get("fp", "")
|
|
190
|
+
new_fp = fingerprint(first_user_msg)
|
|
191
|
+
header["v"] = SCHEMA_VERSION
|
|
192
|
+
header["fp"] = new_fp
|
|
193
|
+
header["preview"] = _preview(first_user_msg)
|
|
194
|
+
header["adopted_at"] = _now()
|
|
195
|
+
header["former_fps"] = _push_former_fp(
|
|
196
|
+
header.get("former_fps") or [], old_fp, new_fp,
|
|
197
|
+
)
|
|
198
|
+
with p.open(encoding="utf-8") as fh:
|
|
199
|
+
lines = fh.readlines()
|
|
200
|
+
lines[0] = json.dumps(header, ensure_ascii=False) + "\n"
|
|
201
|
+
tmp = p.with_suffix(p.suffix + ".tmp")
|
|
202
|
+
tmp.write_text("".join(lines), encoding="utf-8")
|
|
203
|
+
tmp.replace(p)
|
|
204
|
+
return header
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _normalize_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
208
|
+
"""Validate + fill `ts` on each entry. Reject headers and empty `t`."""
|
|
209
|
+
out: list[dict[str, Any]] = []
|
|
210
|
+
for raw in entries or []:
|
|
211
|
+
if not isinstance(raw, dict) or not raw.get("t"):
|
|
212
|
+
raise ValueError("each entry must be a dict with non-empty 't' key")
|
|
213
|
+
if raw["t"] == "header":
|
|
214
|
+
raise ValueError("entries must not contain headers")
|
|
215
|
+
e = dict(raw)
|
|
216
|
+
e.setdefault("ts", _now())
|
|
217
|
+
out.append(e)
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def reset_with_entries(first_user_msg: str,
|
|
222
|
+
entries: list[dict[str, Any]],
|
|
223
|
+
freq: str = "per_phase", *,
|
|
224
|
+
former_fps: list[str] | None = None,
|
|
225
|
+
path: Path | None = None) -> dict[str, Any]:
|
|
226
|
+
"""Discard current file contents and rewrite with a fresh header + entries.
|
|
227
|
+
|
|
228
|
+
Used for the 'Replace' flow: the in-memory history supersedes whatever
|
|
229
|
+
is on disk. If `former_fps` is None and a header exists, the old fp is
|
|
230
|
+
preserved via `_push_former_fp` so the returning/foreign state logic
|
|
231
|
+
still works on later switches.
|
|
232
|
+
"""
|
|
233
|
+
if freq not in VALID_FREQS:
|
|
234
|
+
raise ValueError(f"freq must be one of {sorted(VALID_FREQS)}")
|
|
235
|
+
p = path or file_path()
|
|
236
|
+
new_fp = fingerprint(first_user_msg)
|
|
237
|
+
if former_fps is None:
|
|
238
|
+
existing = read_header(p)
|
|
239
|
+
if existing:
|
|
240
|
+
former_fps = _push_former_fp(
|
|
241
|
+
existing.get("former_fps") or [],
|
|
242
|
+
existing.get("fp", ""),
|
|
243
|
+
new_fp,
|
|
244
|
+
)
|
|
245
|
+
else:
|
|
246
|
+
former_fps = []
|
|
247
|
+
header = _build_header(first_user_msg, freq, former_fps=former_fps)
|
|
248
|
+
body = _normalize_entries(entries)
|
|
249
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
lines = [json.dumps(header, ensure_ascii=False)]
|
|
251
|
+
lines += [json.dumps(e, ensure_ascii=False) for e in body]
|
|
252
|
+
tmp = p.with_suffix(p.suffix + ".tmp")
|
|
253
|
+
tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
254
|
+
tmp.replace(p)
|
|
255
|
+
return header
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def prepend_entries(entries: list[dict[str, Any]], *,
|
|
259
|
+
path: Path | None = None) -> int:
|
|
260
|
+
"""Insert entries right after the header, before existing body entries.
|
|
261
|
+
|
|
262
|
+
Used for the 'Merge' flow: the in-memory history (older) is placed
|
|
263
|
+
before the file's existing body (newer from the adopting session).
|
|
264
|
+
Returns the number of entries prepended. Header untouched.
|
|
265
|
+
"""
|
|
266
|
+
p = path or file_path()
|
|
267
|
+
if not p.is_file():
|
|
268
|
+
raise FileNotFoundError(f"no file at {p}")
|
|
269
|
+
with p.open(encoding="utf-8") as fh:
|
|
270
|
+
existing = fh.readlines()
|
|
271
|
+
if not existing:
|
|
272
|
+
raise ValueError(f"empty file at {p}")
|
|
273
|
+
header_line = existing[0]
|
|
274
|
+
body = existing[1:]
|
|
275
|
+
new_lines = [json.dumps(e, ensure_ascii=False) + "\n"
|
|
276
|
+
for e in _normalize_entries(entries)]
|
|
277
|
+
tmp = p.with_suffix(p.suffix + ".tmp")
|
|
278
|
+
tmp.write_text(header_line + "".join(new_lines) + "".join(body),
|
|
279
|
+
encoding="utf-8")
|
|
280
|
+
tmp.replace(p)
|
|
281
|
+
return len(new_lines)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def clear(*, path: Path | None = None) -> None:
|
|
285
|
+
p = path or file_path()
|
|
286
|
+
if p.exists():
|
|
287
|
+
p.unlink()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def read_entries(last: int | None = None, *,
|
|
291
|
+
path: Path | None = None) -> list[dict[str, Any]]:
|
|
292
|
+
"""Return entries (excluding the header) as a list of dicts.
|
|
293
|
+
|
|
294
|
+
`last=None` returns all entries; `last=N` returns the trailing N.
|
|
295
|
+
Malformed lines are skipped silently.
|
|
296
|
+
"""
|
|
297
|
+
p = path or file_path()
|
|
298
|
+
if not p.is_file():
|
|
299
|
+
return []
|
|
300
|
+
entries: list[dict[str, Any]] = []
|
|
301
|
+
with p.open(encoding="utf-8") as fh:
|
|
302
|
+
for i, line in enumerate(fh):
|
|
303
|
+
line = line.strip()
|
|
304
|
+
if not line:
|
|
305
|
+
continue
|
|
306
|
+
try:
|
|
307
|
+
obj = json.loads(line)
|
|
308
|
+
except json.JSONDecodeError:
|
|
309
|
+
continue
|
|
310
|
+
if i == 0 and isinstance(obj, dict) and obj.get("t") == "header":
|
|
311
|
+
continue
|
|
312
|
+
if isinstance(obj, dict):
|
|
313
|
+
entries.append(obj)
|
|
314
|
+
if last is not None and last >= 0:
|
|
315
|
+
entries = entries[-last:]
|
|
316
|
+
return entries
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def status(*, path: Path | None = None) -> dict[str, Any]:
|
|
320
|
+
p = path or file_path()
|
|
321
|
+
if not p.is_file():
|
|
322
|
+
return {"exists": False, "path": str(p)}
|
|
323
|
+
header = read_header(p)
|
|
324
|
+
size = p.stat().st_size
|
|
325
|
+
with p.open(encoding="utf-8") as fh:
|
|
326
|
+
entry_count = sum(1 for _ in fh) - (1 if header else 0)
|
|
327
|
+
return {
|
|
328
|
+
"exists": True,
|
|
329
|
+
"path": str(p),
|
|
330
|
+
"size_bytes": size,
|
|
331
|
+
"size_kb": round(size / 1024, 1),
|
|
332
|
+
"entries": max(entry_count, 0),
|
|
333
|
+
"header": header,
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def overflow_handle(max_kb: int, mode: str = "rotate", *,
|
|
338
|
+
path: Path | None = None) -> dict[str, Any]:
|
|
339
|
+
"""Enforce max_kb. Returns {'action', 'kept', 'dropped'}.
|
|
340
|
+
|
|
341
|
+
Rotate: drop oldest entries (keep header) until file ≤ max_kb.
|
|
342
|
+
Compress: mark oldest 50% as stale and leave a `needs_compress`
|
|
343
|
+
marker entry for the agent to rewrite on next turn.
|
|
344
|
+
"""
|
|
345
|
+
if mode not in VALID_OVERFLOW:
|
|
346
|
+
raise ValueError(f"mode must be one of {sorted(VALID_OVERFLOW)}")
|
|
347
|
+
p = path or file_path()
|
|
348
|
+
if not p.is_file() or p.stat().st_size <= max_kb * 1024:
|
|
349
|
+
return {"action": "noop", "kept": None, "dropped": 0}
|
|
350
|
+
with p.open(encoding="utf-8") as fh:
|
|
351
|
+
lines = fh.readlines()
|
|
352
|
+
if not lines:
|
|
353
|
+
return {"action": "noop", "kept": 0, "dropped": 0}
|
|
354
|
+
header_line = lines[0]
|
|
355
|
+
entries = lines[1:]
|
|
356
|
+
if mode == "rotate":
|
|
357
|
+
budget = max_kb * 1024 - len(header_line.encode("utf-8"))
|
|
358
|
+
kept: list[str] = []
|
|
359
|
+
total = 0
|
|
360
|
+
for line in reversed(entries):
|
|
361
|
+
size = len(line.encode("utf-8"))
|
|
362
|
+
if total + size > budget:
|
|
363
|
+
break
|
|
364
|
+
kept.append(line)
|
|
365
|
+
total += size
|
|
366
|
+
kept.reverse()
|
|
367
|
+
dropped = len(entries) - len(kept)
|
|
368
|
+
tmp = p.with_suffix(p.suffix + ".tmp")
|
|
369
|
+
tmp.write_text(header_line + "".join(kept), encoding="utf-8")
|
|
370
|
+
tmp.replace(p)
|
|
371
|
+
return {"action": "rotate", "kept": len(kept), "dropped": dropped}
|
|
372
|
+
marker = {
|
|
373
|
+
"t": "needs_compress",
|
|
374
|
+
"ts": _now(),
|
|
375
|
+
"reason": f"file exceeded {max_kb} KB, compress-mode requested",
|
|
376
|
+
}
|
|
377
|
+
append(marker, path=p)
|
|
378
|
+
return {"action": "compress_marked", "kept": len(entries), "dropped": 0}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _cmd_init(args) -> int:
|
|
382
|
+
h = init(args.first_user_msg, freq=args.freq)
|
|
383
|
+
print(json.dumps(h, ensure_ascii=False))
|
|
384
|
+
return 0
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _cmd_append(args) -> int:
|
|
388
|
+
entry = json.loads(args.json) if args.json else {}
|
|
389
|
+
entry.setdefault("t", args.type)
|
|
390
|
+
if not entry.get("t"):
|
|
391
|
+
print("error: --type or a 't' key in --json is required", file=sys.stderr)
|
|
392
|
+
return 2
|
|
393
|
+
append(entry)
|
|
394
|
+
return 0
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _cmd_status(_args) -> int:
|
|
398
|
+
print(json.dumps(status(), ensure_ascii=False, indent=2))
|
|
399
|
+
return 0
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _cmd_check(args) -> int:
|
|
403
|
+
print(check_ownership(args.first_user_msg))
|
|
404
|
+
return 0
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _cmd_state(args) -> int:
|
|
408
|
+
print(ownership_state(args.first_user_msg))
|
|
409
|
+
return 0
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _cmd_adopt(args) -> int:
|
|
413
|
+
h = adopt(args.first_user_msg)
|
|
414
|
+
print(json.dumps(h, ensure_ascii=False))
|
|
415
|
+
return 0
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _load_entries_arg(args) -> list[dict[str, Any]]:
|
|
419
|
+
if getattr(args, "entries_stdin", False):
|
|
420
|
+
raw = sys.stdin.read()
|
|
421
|
+
else:
|
|
422
|
+
raw = args.entries_json or "[]"
|
|
423
|
+
try:
|
|
424
|
+
data = json.loads(raw)
|
|
425
|
+
except json.JSONDecodeError as exc:
|
|
426
|
+
raise ValueError(f"invalid JSON for entries: {exc}") from exc
|
|
427
|
+
if not isinstance(data, list):
|
|
428
|
+
raise ValueError("entries must be a JSON array")
|
|
429
|
+
return data
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _cmd_reset(args) -> int:
|
|
433
|
+
entries = _load_entries_arg(args)
|
|
434
|
+
h = reset_with_entries(args.first_user_msg, entries, freq=args.freq)
|
|
435
|
+
print(json.dumps(h, ensure_ascii=False))
|
|
436
|
+
return 0
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _cmd_prepend(args) -> int:
|
|
440
|
+
entries = _load_entries_arg(args)
|
|
441
|
+
n = prepend_entries(entries)
|
|
442
|
+
print(json.dumps({"prepended": n}, ensure_ascii=False))
|
|
443
|
+
return 0
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _cmd_clear(_args) -> int:
|
|
447
|
+
clear()
|
|
448
|
+
return 0
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _cmd_read(args) -> int:
|
|
452
|
+
last = None if args.all else args.last
|
|
453
|
+
entries = read_entries(last=last)
|
|
454
|
+
print(json.dumps(entries, ensure_ascii=False, indent=2))
|
|
455
|
+
return 0
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _cmd_rotate(args) -> int:
|
|
459
|
+
result = overflow_handle(args.max_kb, mode=args.mode)
|
|
460
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
461
|
+
return 0
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def main(argv: list[str] | None = None) -> int:
|
|
465
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
466
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
467
|
+
p_init = sub.add_parser("init")
|
|
468
|
+
p_init.add_argument("--first-user-msg", required=True)
|
|
469
|
+
p_init.add_argument("--freq", default="per_phase", choices=sorted(VALID_FREQS))
|
|
470
|
+
p_init.set_defaults(func=_cmd_init)
|
|
471
|
+
p_app = sub.add_parser("append")
|
|
472
|
+
p_app.add_argument("--type", help="entry type (t field)")
|
|
473
|
+
p_app.add_argument("--json", help="JSON object with entry fields")
|
|
474
|
+
p_app.set_defaults(func=_cmd_append)
|
|
475
|
+
sub.add_parser("status").set_defaults(func=_cmd_status)
|
|
476
|
+
p_chk = sub.add_parser("check")
|
|
477
|
+
p_chk.add_argument("--first-user-msg", required=True)
|
|
478
|
+
p_chk.set_defaults(func=_cmd_check)
|
|
479
|
+
p_state = sub.add_parser("state")
|
|
480
|
+
p_state.add_argument("--first-user-msg", required=True)
|
|
481
|
+
p_state.set_defaults(func=_cmd_state)
|
|
482
|
+
p_ado = sub.add_parser("adopt")
|
|
483
|
+
p_ado.add_argument("--first-user-msg", required=True)
|
|
484
|
+
p_ado.set_defaults(func=_cmd_adopt)
|
|
485
|
+
p_reset = sub.add_parser("reset")
|
|
486
|
+
p_reset.add_argument("--first-user-msg", required=True)
|
|
487
|
+
p_reset.add_argument("--freq", default="per_phase",
|
|
488
|
+
choices=sorted(VALID_FREQS))
|
|
489
|
+
g_r = p_reset.add_mutually_exclusive_group(required=True)
|
|
490
|
+
g_r.add_argument("--entries-json",
|
|
491
|
+
help="JSON array of entry dicts")
|
|
492
|
+
g_r.add_argument("--entries-stdin", action="store_true",
|
|
493
|
+
help="read JSON array from stdin")
|
|
494
|
+
p_reset.set_defaults(func=_cmd_reset)
|
|
495
|
+
p_prep = sub.add_parser("prepend")
|
|
496
|
+
g_p = p_prep.add_mutually_exclusive_group(required=True)
|
|
497
|
+
g_p.add_argument("--entries-json",
|
|
498
|
+
help="JSON array of entry dicts")
|
|
499
|
+
g_p.add_argument("--entries-stdin", action="store_true",
|
|
500
|
+
help="read JSON array from stdin")
|
|
501
|
+
p_prep.set_defaults(func=_cmd_prepend)
|
|
502
|
+
sub.add_parser("clear").set_defaults(func=_cmd_clear)
|
|
503
|
+
p_read = sub.add_parser("read")
|
|
504
|
+
grp = p_read.add_mutually_exclusive_group()
|
|
505
|
+
grp.add_argument("--last", type=int, default=5,
|
|
506
|
+
help="return the trailing N entries (default: 5)")
|
|
507
|
+
grp.add_argument("--all", action="store_true",
|
|
508
|
+
help="return all entries")
|
|
509
|
+
p_read.set_defaults(func=_cmd_read)
|
|
510
|
+
p_rot = sub.add_parser("rotate")
|
|
511
|
+
p_rot.add_argument("--max-kb", type=int, default=256)
|
|
512
|
+
p_rot.add_argument("--mode", default="rotate", choices=sorted(VALID_OVERFLOW))
|
|
513
|
+
p_rot.set_defaults(func=_cmd_rotate)
|
|
514
|
+
args = ap.parse_args(argv)
|
|
515
|
+
return args.func(args)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
if __name__ == "__main__":
|
|
519
|
+
sys.exit(main())
|