@smilintux/skcapstone 0.6.1 → 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()
|