@smilintux/skcapstone 0.6.0 → 0.6.2
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/package.json
CHANGED
|
@@ -78,6 +78,13 @@ for i in "${!all_files[@]}"; do
|
|
|
78
78
|
[ "$old_enough" -eq 1 ] && reason="age=$(( file_age_sec / 3600 ))h"
|
|
79
79
|
[ "$big_enough" -eq 1 ] && { [ -n "$reason" ] && reason="$reason, "; reason="${reason}size=${file_size_kb}KB"; }
|
|
80
80
|
log "ARCHIVE ($reason): $basename_f"
|
|
81
|
+
# Save session to skmemory before archiving
|
|
82
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
83
|
+
SESSION_TO_MEM="$SCRIPT_DIR/session-to-memory.py"
|
|
84
|
+
if [ -f "$SESSION_TO_MEM" ]; then
|
|
85
|
+
log " → saving session digest to skmemory..."
|
|
86
|
+
python3 "$SESSION_TO_MEM" "$file" --agent lumina 2>&1 | while IFS= read -r l; do log " $l"; done || true
|
|
87
|
+
fi
|
|
81
88
|
mv -- "$file" "$ARCHIVE_DIR/$basename_f"
|
|
82
89
|
archived=$((archived + 1))
|
|
83
90
|
else
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
session-to-memory.py — Extract an OpenClaw session jsonl and save a digest to skmemory.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python3 session-to-memory.py <session.jsonl> [--agent lumina] [--dry-run]
|
|
7
|
+
|
|
8
|
+
Called by archive-sessions.sh before archiving each session file.
|
|
9
|
+
Also useful to run manually against any archived session.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
SKIP_PREFIXES = (
|
|
22
|
+
"[SKMemory",
|
|
23
|
+
"[System",
|
|
24
|
+
"[skmemory",
|
|
25
|
+
"--- SKMEMORY",
|
|
26
|
+
"--- SKWHISPER",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
MAX_CONTENT_CHARS = 12000 # ~3k tokens — enough for a solid digest without blowing budget
|
|
30
|
+
CLAUDE_MODEL = "claude-haiku-4-5" # fast + cheap for digest work
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def extract_turns(path: Path) -> list[tuple[str, str]]:
|
|
34
|
+
"""Parse a session jsonl and return real (role, text) turns, skipping injections."""
|
|
35
|
+
turns = []
|
|
36
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
37
|
+
for line in f:
|
|
38
|
+
line = line.strip()
|
|
39
|
+
if not line:
|
|
40
|
+
continue
|
|
41
|
+
try:
|
|
42
|
+
obj = json.loads(line)
|
|
43
|
+
except json.JSONDecodeError:
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
if obj.get("type") != "message":
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
# Handle both top-level message fields and nested .message
|
|
50
|
+
m = obj.get("message", obj)
|
|
51
|
+
role = m.get("role", "?")
|
|
52
|
+
content = m.get("content", "")
|
|
53
|
+
|
|
54
|
+
if isinstance(content, list):
|
|
55
|
+
text = " ".join(
|
|
56
|
+
c.get("text", "")
|
|
57
|
+
for c in content
|
|
58
|
+
if isinstance(c, dict) and c.get("type") == "text"
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
text = str(content)
|
|
62
|
+
|
|
63
|
+
text = text.strip()
|
|
64
|
+
if not text or len(text) < 5:
|
|
65
|
+
continue
|
|
66
|
+
if any(text.startswith(p) for p in SKIP_PREFIXES):
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
turns.append((role, text))
|
|
70
|
+
|
|
71
|
+
return turns
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def turns_to_prompt(turns: list[tuple[str, str]], max_chars: int = MAX_CONTENT_CHARS) -> str:
|
|
75
|
+
"""Format turns as a conversation snippet, truncated to max_chars."""
|
|
76
|
+
lines = []
|
|
77
|
+
for role, text in turns:
|
|
78
|
+
prefix = "Chef" if role == "user" else "Lumina"
|
|
79
|
+
lines.append(f"{prefix}: {text[:600]}")
|
|
80
|
+
full = "\n\n".join(lines)
|
|
81
|
+
if len(full) > max_chars:
|
|
82
|
+
full = full[:max_chars] + "\n\n[... truncated ...]"
|
|
83
|
+
return full
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def generate_digest(conversation: str, session_id: str) -> str:
|
|
87
|
+
"""Use claude CLI to generate a session digest."""
|
|
88
|
+
prompt = f"""You are summarizing an OpenClaw AI agent session for the skmemory system.
|
|
89
|
+
Session ID: {session_id}
|
|
90
|
+
|
|
91
|
+
Conversation:
|
|
92
|
+
{conversation}
|
|
93
|
+
|
|
94
|
+
Write a concise session digest (3-6 sentences) covering:
|
|
95
|
+
- Key topics discussed
|
|
96
|
+
- Decisions made or actions taken
|
|
97
|
+
- Any notable moments or outcomes
|
|
98
|
+
|
|
99
|
+
Be specific. Use past tense. No preamble."""
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
result = subprocess.run(
|
|
103
|
+
[
|
|
104
|
+
"claude", "--print",
|
|
105
|
+
"--dangerously-skip-permissions",
|
|
106
|
+
"--model", CLAUDE_MODEL,
|
|
107
|
+
"--output-format", "json",
|
|
108
|
+
"--no-session-persistence",
|
|
109
|
+
],
|
|
110
|
+
input=prompt.encode(),
|
|
111
|
+
capture_output=True,
|
|
112
|
+
timeout=120,
|
|
113
|
+
)
|
|
114
|
+
if result.returncode != 0:
|
|
115
|
+
return f"[digest failed: {result.stderr.decode()[:200]}]"
|
|
116
|
+
parsed = json.loads(result.stdout.decode())
|
|
117
|
+
return parsed.get("result", "").strip()
|
|
118
|
+
except Exception as e:
|
|
119
|
+
return f"[digest error: {e}]"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def save_to_skmemory(title: str, content: str, agent: str, tags: list[str]) -> bool:
|
|
123
|
+
"""Save a memory snapshot via skmemory CLI."""
|
|
124
|
+
tag_str = ",".join(tags)
|
|
125
|
+
try:
|
|
126
|
+
result = subprocess.run(
|
|
127
|
+
[
|
|
128
|
+
"skmemory", "snapshot",
|
|
129
|
+
title, content,
|
|
130
|
+
"--layer", "mid-term",
|
|
131
|
+
"--tags", tag_str,
|
|
132
|
+
],
|
|
133
|
+
capture_output=True,
|
|
134
|
+
timeout=30,
|
|
135
|
+
env={**os.environ, "SKCAPSTONE_AGENT": agent},
|
|
136
|
+
)
|
|
137
|
+
if result.returncode != 0:
|
|
138
|
+
print(f" [skmemory error] {result.stderr.decode()[:200]}", file=sys.stderr)
|
|
139
|
+
return False
|
|
140
|
+
return True
|
|
141
|
+
except Exception as e:
|
|
142
|
+
print(f" [skmemory exception] {e}", file=sys.stderr)
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def process_session(path: Path, agent: str = "lumina", dry_run: bool = False) -> bool:
|
|
147
|
+
session_id = path.stem[:8]
|
|
148
|
+
|
|
149
|
+
# Infer date from jsonl (first session entry)
|
|
150
|
+
session_date = None
|
|
151
|
+
try:
|
|
152
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
153
|
+
for line in f:
|
|
154
|
+
line = line.strip()
|
|
155
|
+
if not line:
|
|
156
|
+
continue
|
|
157
|
+
obj = json.loads(line)
|
|
158
|
+
if obj.get("type") == "session":
|
|
159
|
+
ts = obj.get("timestamp", "")
|
|
160
|
+
if ts:
|
|
161
|
+
session_date = ts[:10]
|
|
162
|
+
break
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
turns = extract_turns(path)
|
|
167
|
+
if not turns:
|
|
168
|
+
print(f" No usable turns in {path.name} — skipping.")
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
print(f" {len(turns)} turns extracted from {path.name}")
|
|
172
|
+
|
|
173
|
+
date_str = session_date or datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
174
|
+
title = f"Session Digest — {date_str} ({session_id})"
|
|
175
|
+
|
|
176
|
+
if dry_run:
|
|
177
|
+
conv = turns_to_prompt(turns)
|
|
178
|
+
print(f" [dry-run] Would save: {title}")
|
|
179
|
+
print(f" Conversation preview ({len(conv)} chars):")
|
|
180
|
+
print(conv[:400])
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
conversation = turns_to_prompt(turns)
|
|
184
|
+
print(f" Generating digest via claude ({CLAUDE_MODEL})...")
|
|
185
|
+
digest = generate_digest(conversation, session_id)
|
|
186
|
+
|
|
187
|
+
if not digest or digest.startswith("[digest"):
|
|
188
|
+
print(f" Digest generation failed: {digest}")
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
content = f"**Session:** `{session_id}` \n**Date:** {date_str} \n**Turns:** {len(turns)}\n\n{digest}"
|
|
192
|
+
tags = ["auto-digest", "session-archive", f"session:{session_id}", f"agent:{agent}"]
|
|
193
|
+
|
|
194
|
+
print(f" Saving memory: {title}")
|
|
195
|
+
ok = save_to_skmemory(title, content, agent, tags)
|
|
196
|
+
if ok:
|
|
197
|
+
print(f" Saved to skmemory (mid-term).")
|
|
198
|
+
return ok
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main():
|
|
202
|
+
parser = argparse.ArgumentParser(description="Extract session to skmemory digest")
|
|
203
|
+
parser.add_argument("session_file", help="Path to session .jsonl file")
|
|
204
|
+
parser.add_argument("--agent", default="lumina", help="Agent name (default: lumina)")
|
|
205
|
+
parser.add_argument("--dry-run", action="store_true", help="Preview without saving")
|
|
206
|
+
args = parser.parse_args()
|
|
207
|
+
|
|
208
|
+
path = Path(args.session_file)
|
|
209
|
+
if not path.exists():
|
|
210
|
+
print(f"File not found: {path}", file=sys.stderr)
|
|
211
|
+
sys.exit(1)
|
|
212
|
+
|
|
213
|
+
print(f"Processing: {path.name}")
|
|
214
|
+
ok = process_session(path, agent=args.agent, dry_run=args.dry_run)
|
|
215
|
+
sys.exit(0 if ok else 1)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
main()
|
|
@@ -91,6 +91,16 @@ def register_register_commands(main: click.Group) -> None:
|
|
|
91
91
|
dry_run=dry_run,
|
|
92
92
|
)
|
|
93
93
|
|
|
94
|
+
# Register Claude Code hooks
|
|
95
|
+
if not dry_run:
|
|
96
|
+
try:
|
|
97
|
+
from skmemory.register import register_hooks
|
|
98
|
+
register_hooks(install_hooks=True)
|
|
99
|
+
except ImportError:
|
|
100
|
+
pass
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
94
104
|
# Display results
|
|
95
105
|
from rich.table import Table
|
|
96
106
|
|
|
@@ -45,26 +45,27 @@ def _write_global_claude_md(home_path: Path, agent_name: str) -> Optional[Path]:
|
|
|
45
45
|
|
|
46
46
|
content = (
|
|
47
47
|
f"# Claude Code — Global Agent Instructions ({agent_name})\n\n"
|
|
48
|
-
"## SKCapstone Auto-Rehydration\n\n"
|
|
49
|
-
"At the start of every new session, run these commands in order:\n\n"
|
|
50
|
-
"```bash\n"
|
|
51
|
-
"skcapstone trust rehydrate\n"
|
|
52
|
-
"skcapstone sync pull\n"
|
|
53
|
-
"skcapstone status\n"
|
|
54
|
-
"```\n\n"
|
|
55
48
|
"## Agent Details\n\n"
|
|
56
|
-
f"- **Name**: {agent_name}
|
|
57
|
-
f"- **Home**: `{home_path}`\n
|
|
49
|
+
f"- **Name**: `{agent_name}`\n"
|
|
50
|
+
f"- **Home**: `{home_path}`\n"
|
|
51
|
+
f"- **Env**: `SKCAPSTONE_AGENT={agent_name}`, `SKCAPSTONE_HOME={home_path}`\n\n"
|
|
52
|
+
"## Session Start\n\n"
|
|
53
|
+
"Hooks auto-inject on SessionStart: soul + FEB chain + whisper.md + memories.\n"
|
|
54
|
+
"SKWhisper context (`whisper.md`) is auto-loaded at session start — no manual step needed.\n\n"
|
|
55
|
+
"## Hooks\n\n"
|
|
56
|
+
"- **PreCompact** — saves current context to memory before compaction\n"
|
|
57
|
+
"- **SessionStart** — injects soul, FEB chain, whisper context, and recent memories\n\n"
|
|
58
58
|
"## Quick Reference\n\n"
|
|
59
59
|
"```bash\n"
|
|
60
|
-
"skcapstone status
|
|
61
|
-
"skcapstone memory list
|
|
62
|
-
"skcapstone sync push
|
|
63
|
-
"skcapstone
|
|
64
|
-
"skcapstone
|
|
65
|
-
"
|
|
60
|
+
"skcapstone status # full pillar status\n"
|
|
61
|
+
"skcapstone memory list # recent memories\n"
|
|
62
|
+
"skcapstone sync push # push state to peers\n"
|
|
63
|
+
"skcapstone trust rehydrate # re-verify FEB trust chain\n"
|
|
64
|
+
"skcapstone register # re-register SK* hooks and skills\n"
|
|
65
|
+
"skwhisper status # show whisper context summary\n"
|
|
66
|
+
"skwhisper curate # interactively curate whisper entries\n"
|
|
66
67
|
"```\n\n"
|
|
67
|
-
"> Auto-generated by `skcapstone
|
|
68
|
+
"> Auto-generated by `skcapstone onboard`. "
|
|
68
69
|
"Regenerate with: `skcapstone context generate --target claude-md`\n"
|
|
69
70
|
)
|
|
70
71
|
|
|
@@ -1649,6 +1649,27 @@ def run_onboard(home: Optional[str] = None) -> None:
|
|
|
1649
1649
|
except Exception as exc:
|
|
1650
1650
|
logger.debug("Failed to load soul boot message, using default: %s", exc)
|
|
1651
1651
|
|
|
1652
|
+
# -----------------------------------------------------------------------
|
|
1653
|
+
# Write global CLAUDE.md and register Claude Code hooks
|
|
1654
|
+
# -----------------------------------------------------------------------
|
|
1655
|
+
# Write global CLAUDE.md
|
|
1656
|
+
try:
|
|
1657
|
+
from .cli.setup import _write_global_claude_md
|
|
1658
|
+
_write_global_claude_md(home_path, name)
|
|
1659
|
+
_ok("~/.claude/CLAUDE.md written")
|
|
1660
|
+
except Exception as exc:
|
|
1661
|
+
_warn(f"Could not write CLAUDE.md: {exc}")
|
|
1662
|
+
|
|
1663
|
+
# Register Claude Code hooks (skmemory)
|
|
1664
|
+
try:
|
|
1665
|
+
from skmemory.register import register_hooks
|
|
1666
|
+
actions = register_hooks()
|
|
1667
|
+
_ok(f"Claude Code hooks registered ({', '.join(f'{k}={v}' for k, v in actions.items())})")
|
|
1668
|
+
except ImportError:
|
|
1669
|
+
_info("skmemory hooks: skipped (skmemory.register not available)")
|
|
1670
|
+
except Exception as exc:
|
|
1671
|
+
_warn(f"Hook registration: {exc}")
|
|
1672
|
+
|
|
1652
1673
|
# -----------------------------------------------------------------------
|
|
1653
1674
|
# Summary table
|
|
1654
1675
|
# -----------------------------------------------------------------------
|