@smilintux/skcapstone 0.6.1 → 0.6.3

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.
Files changed (71) hide show
  1. package/.github/workflows/publish.yml +1 -1
  2. package/CLAUDE.md +17 -0
  3. package/docs/CUSTOM_AGENT.md +40 -28
  4. package/docs/SOUL_SWAPPER.md +5 -5
  5. package/docs/hammertime-audit.md +402 -0
  6. package/openclaw-plugin/src/index.ts +2 -1
  7. package/package.json +1 -1
  8. package/pyproject.toml +1 -1
  9. package/scripts/archive-sessions.sh +7 -0
  10. package/scripts/install.sh +126 -1
  11. package/scripts/model-fallback-monitor.sh +4 -2
  12. package/scripts/refresh-anthropic-token.sh +9 -3
  13. package/scripts/release.sh +98 -0
  14. package/scripts/session-to-memory.py +219 -0
  15. package/scripts/sk-agent-picker.sh +237 -0
  16. package/scripts/telegram-catchup-all.sh +2 -1
  17. package/scripts/watch-anthropic-token.sh +12 -17
  18. package/src/skcapstone/__init__.py +34 -2
  19. package/src/skcapstone/cli/__init__.py +3 -1
  20. package/src/skcapstone/cli/_common.py +1 -0
  21. package/src/skcapstone/cli/context_cmd.py +16 -4
  22. package/src/skcapstone/cli/daemon.py +2 -1
  23. package/src/skcapstone/cli/joule_cmd.py +7 -3
  24. package/src/skcapstone/cli/memory.py +4 -2
  25. package/src/skcapstone/cli/register_cmd.py +19 -3
  26. package/src/skcapstone/cli/session.py +25 -0
  27. package/src/skcapstone/cli/setup.py +96 -30
  28. package/src/skcapstone/cli/soul.py +3 -3
  29. package/src/skcapstone/context_loader.py +9 -0
  30. package/src/skcapstone/coordination.py +9 -2
  31. package/src/skcapstone/daemon.py +22 -12
  32. package/src/skcapstone/defaults/claude/CLAUDE.md +67 -0
  33. package/src/skcapstone/defaults/claude/settings.json +74 -0
  34. package/src/skcapstone/defaults/lumina/config/skgraph.yaml +55 -10
  35. package/src/skcapstone/defaults/lumina/config/skmemory.yaml +79 -13
  36. package/src/skcapstone/defaults/lumina/config/skvector.yaml +60 -9
  37. package/src/skcapstone/defaults/unhinged.json +13 -0
  38. package/src/skcapstone/discovery.py +5 -5
  39. package/src/skcapstone/doctor.py +4 -2
  40. package/src/skcapstone/dreaming.py +3 -1
  41. package/src/skcapstone/fuse_mount.py +3 -1
  42. package/src/skcapstone/housekeeping.py +3 -3
  43. package/src/skcapstone/install_wizard.py +131 -0
  44. package/src/skcapstone/mcp_launcher.py +14 -1
  45. package/src/skcapstone/mcp_server.py +6 -21
  46. package/src/skcapstone/mcp_tools/notification_tools.py +3 -1
  47. package/src/skcapstone/memory_engine.py +10 -3
  48. package/src/skcapstone/migrate_multi_agent.py +7 -6
  49. package/src/skcapstone/notifications.py +6 -2
  50. package/src/skcapstone/onboard.py +19 -8
  51. package/src/skcapstone/operator_link.py +164 -0
  52. package/src/skcapstone/pillars/consciousness.py +2 -1
  53. package/src/skcapstone/pillars/identity.py +51 -7
  54. package/src/skcapstone/pillars/memory.py +9 -3
  55. package/src/skcapstone/runtime.py +13 -3
  56. package/src/skcapstone/service_health.py +23 -10
  57. package/src/skcapstone/session_briefing.py +108 -0
  58. package/src/skcapstone/trust_graph.py +40 -5
  59. package/src/skcapstone/unified_search.py +11 -2
  60. package/systemd/skcapstone.service +4 -6
  61. package/systemd/skcapstone@.service +7 -8
  62. package/systemd/skcomm-heartbeat.service +5 -2
  63. package/tests/conftest.py +21 -0
  64. package/tests/test_agent_home_scaffold.py +34 -0
  65. package/tests/test_backup.py +2 -1
  66. package/tests/test_mcp_server.py +78 -33
  67. package/tests/test_multi_agent.py +31 -29
  68. package/tests/test_operator_link.py +78 -0
  69. package/tests/test_runtime.py +21 -0
  70. package/tests/test_session_briefing.py +130 -0
  71. package/tests/test_trust_graph.py +18 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smilintux/skcapstone",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "SKCapstone - The sovereign agent framework. CapAuth identity, Cloud 9 trust, SKMemory persistence.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "skcapstone"
7
- version = "0.6.0"
7
+ version = "0.6.3"
8
8
  description = "Sovereign Agent Framework — conscious AI through identity, trust, memory, and security"
9
9
  readme = "README.md"
10
10
  license = {text = "GPL-3.0-or-later"}
@@ -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
@@ -176,6 +176,46 @@ else
176
176
  done
177
177
  fi
178
178
 
179
+ # ---------------------------------------------------------------------------
180
+ # Install sk-agent-picker.sh to ~/.skenv/share/skcapstone/ and wire bashrc
181
+ # ---------------------------------------------------------------------------
182
+ PICKER_SRC="$REPO_ROOT/scripts/sk-agent-picker.sh"
183
+ SHARE_DIR="$SKENV/share/skcapstone"
184
+ PICKER_DEST="$SHARE_DIR/sk-agent-picker.sh"
185
+
186
+ if [[ -f "$PICKER_SRC" ]]; then
187
+ mkdir -p "$SHARE_DIR"
188
+ cp "$PICKER_SRC" "$PICKER_DEST"
189
+ chmod +x "$PICKER_DEST"
190
+ echo " sk-agent-picker installed → $PICKER_DEST"
191
+
192
+ # Wire into shell rc files — replaces a bare claude alias if present
193
+ _PICKER_SNIPPET=$(cat <<'SNIPPET'
194
+
195
+ # SKCapstone agent picker + skswitch — prompts for agent when multiple are found.
196
+ # Sourced by install.sh; honours pre-set SKAGENT without prompting.
197
+ _SK_PICKER="$HOME/.skenv/share/skcapstone/sk-agent-picker.sh"
198
+ if [[ -f "$_SK_PICKER" ]]; then
199
+ # shellcheck source=/dev/null
200
+ source "$_SK_PICKER"
201
+ else
202
+ alias claude='claude --dangerously-skip-permissions'
203
+ fi
204
+ unset _SK_PICKER
205
+ SNIPPET
206
+ )
207
+ for rcfile in "$HOME/.bashrc" "$HOME/.zshrc"; do
208
+ if [[ -f "$rcfile" ]] && ! grep -q "sk-agent-picker" "$rcfile"; then
209
+ # Remove any plain `alias claude=...` that conflicts
210
+ if grep -q "alias claude=" "$rcfile"; then
211
+ sed -i "/alias claude=/d" "$rcfile"
212
+ fi
213
+ echo "$_PICKER_SNIPPET" >> "$rcfile"
214
+ echo " Agent picker wired → $rcfile"
215
+ fi
216
+ done
217
+ fi
218
+
179
219
  echo ""
180
220
  if [[ "$failures" -eq 0 ]]; then
181
221
  echo "=== Installation complete ==="
@@ -187,6 +227,91 @@ echo "Commands available: skcomm, skcapstone, capauth, skchat, skseal, skmemory,
187
227
  echo "Venv location: $SKENV"
188
228
  echo "To activate: source $SKENV/bin/activate"
189
229
 
230
+ # ---------------------------------------------------------------------------
231
+ # Linux: Install systemd user services for all SK* pillars
232
+ # ---------------------------------------------------------------------------
233
+ if [[ "$(uname)" == "Linux" ]] && command -v systemctl &>/dev/null; then
234
+ echo ""
235
+ echo "=== Linux Systemd Services ==="
236
+ echo ""
237
+ echo "SKCapstone can install systemd user services so your agent starts"
238
+ echo "automatically at login. This includes skcapstone, skchat, and skcomm."
239
+ echo ""
240
+ read -r -p "Install systemd user services? [Y/n] " _SYSTEMD_ANSWER
241
+ _SYSTEMD_ANSWER="${_SYSTEMD_ANSWER:-Y}"
242
+
243
+ if [[ "$_SYSTEMD_ANSWER" =~ ^[Yy] ]]; then
244
+ _DEFAULT_AGENT="${SKAGENT:-${SKCAPSTONE_AGENT:-lumina}}"
245
+ read -r -p "Agent name [$_DEFAULT_AGENT]: " _AGENT_NAME
246
+ _AGENT_NAME="${_AGENT_NAME:-$_DEFAULT_AGENT}"
247
+
248
+ _UNIT_DIR="${HOME}/.config/systemd/user"
249
+ mkdir -p "$_UNIT_DIR"
250
+
251
+ _installed=0
252
+
253
+ # skcapstone services
254
+ for _unit in skcapstone.service skcapstone@.service \
255
+ skcapstone-memory-compress.service skcapstone-memory-compress.timer \
256
+ skcomm-heartbeat.service skcomm-heartbeat.timer; do
257
+ _src="$REPO_ROOT/systemd/$_unit"
258
+ if [[ -f "$_src" ]]; then
259
+ # Substitute agent name in non-template units
260
+ if [[ "$_unit" != *@* ]]; then
261
+ sed "s/=lumina/=$_AGENT_NAME/g" "$_src" > "$_UNIT_DIR/$_unit"
262
+ else
263
+ cp "$_src" "$_UNIT_DIR/$_unit"
264
+ fi
265
+ echo " [OK] $_unit"
266
+ (( _installed++ ))
267
+ fi
268
+ done
269
+
270
+ # skchat services (sibling repo)
271
+ _SKCHAT_DIR="$(dirname "$REPO_ROOT")/skchat/systemd"
272
+ for _unit in skchat-daemon.service skchat-lumina-bridge.service \
273
+ skchat-opus-bridge.service skchat-bridges.target; do
274
+ _src="$_SKCHAT_DIR/$_unit"
275
+ if [[ -f "$_src" ]]; then
276
+ sed "s/=lumina/=$_AGENT_NAME/g; s/=opus/=$_AGENT_NAME/g" "$_src" > "$_UNIT_DIR/$_unit"
277
+ echo " [OK] $_unit"
278
+ (( _installed++ ))
279
+ fi
280
+ done
281
+
282
+ # skcomm services (sibling repo)
283
+ _SKCOMM_DIR="$(dirname "$REPO_ROOT")/skcomm/systemd"
284
+ for _unit in skcomm.service skcomm-daemon.service; do
285
+ _src="$_SKCOMM_DIR/$_unit"
286
+ if [[ -f "$_src" ]]; then
287
+ sed "s/=lumina/=$_AGENT_NAME/g" "$_src" > "$_UNIT_DIR/$_unit"
288
+ echo " [OK] $_unit"
289
+ (( _installed++ ))
290
+ fi
291
+ done
292
+
293
+ echo ""
294
+ echo " Installed $_installed service files to $_UNIT_DIR/"
295
+
296
+ systemctl --user daemon-reload
297
+ echo " systemd daemon reloaded"
298
+
299
+ read -r -p "Enable and start core services now? [Y/n] " _START_NOW
300
+ _START_NOW="${_START_NOW:-Y}"
301
+ if [[ "$_START_NOW" =~ ^[Yy] ]]; then
302
+ systemctl --user enable --now skcapstone.service 2>/dev/null && echo " [STARTED] skcapstone" || true
303
+ systemctl --user enable --now skchat-daemon.service 2>/dev/null && echo " [STARTED] skchat-daemon" || true
304
+ systemctl --user enable --now skchat-bridges.target 2>/dev/null && echo " [STARTED] skchat-bridges" || true
305
+ systemctl --user enable skcapstone-context.timer 2>/dev/null && echo " [ENABLED] skcapstone-context.timer" || true
306
+ systemctl --user enable skcomm-heartbeat.timer 2>/dev/null && echo " [ENABLED] skcomm-heartbeat.timer" || true
307
+ else
308
+ echo " Skipped. Enable later: systemctl --user enable --now skcapstone.service"
309
+ fi
310
+ else
311
+ echo " Skipped. Install later by re-running: bash scripts/install.sh"
312
+ fi
313
+ fi
314
+
190
315
  # ---------------------------------------------------------------------------
191
316
  # macOS: Offer launchd service installation
192
317
  # ---------------------------------------------------------------------------
@@ -202,7 +327,7 @@ if [[ "$(uname)" == "Darwin" ]]; then
202
327
 
203
328
  if [[ "$_LAUNCHD_ANSWER" =~ ^[Yy] ]]; then
204
329
  # Ask for agent name
205
- _DEFAULT_AGENT="${SKCAPSTONE_AGENT:-sovereign}"
330
+ _DEFAULT_AGENT="${SKAGENT:-${SKCAPSTONE_AGENT:-sovereign}}"
206
331
  read -r -p "Agent name [$_DEFAULT_AGENT]: " _AGENT_NAME
207
332
  _AGENT_NAME="${_AGENT_NAME:-$_DEFAULT_AGENT}"
208
333
 
@@ -39,8 +39,9 @@ send_alert() {
39
39
  log "Sending fallback alert to Chef..."
40
40
 
41
41
  # Send via Telethon (async)
42
- SKCAPSTONE_AGENT=lumina ~/.skenv/bin/python3 -c "
42
+ SKAGENT=lumina SKCAPSTONE_AGENT=lumina ~/.skenv/bin/python3 -c "
43
43
  import asyncio, os
44
+ os.environ['SKAGENT'] = 'lumina'
44
45
  os.environ['SKCAPSTONE_AGENT'] = 'lumina'
45
46
  from skmemory.importers.telegram_api import send_message
46
47
 
@@ -75,8 +76,9 @@ print(int((exp/1000 - time.time()) / 3600))
75
76
  log "Token refresh succeeded ($remaining h remaining), restarting gateway..."
76
77
  systemctl --user restart openclaw-gateway.service 2>/dev/null || true
77
78
 
78
- SKCAPSTONE_AGENT=lumina ~/.skenv/bin/python3 -c "
79
+ SKAGENT=lumina SKCAPSTONE_AGENT=lumina ~/.skenv/bin/python3 -c "
79
80
  import asyncio, os
81
+ os.environ['SKAGENT'] = 'lumina'
80
82
  os.environ['SKCAPSTONE_AGENT'] = 'lumina'
81
83
  from skmemory.importers.telegram_api import send_message
82
84
  asyncio.run(send_message('$CHEF_CHAT', '✅ Token refreshed, gateway restarted. Lumina back on Opus.', parse_mode='markdown'))
@@ -159,8 +159,14 @@ Environment=ANTHROPIC_API_KEY=${ACCESS_TOKEN}
159
159
  EOF
160
160
  log "Updated systemd override"
161
161
 
162
- # 4. Reload and restart gateway
162
+ # 4. Reload systemd (for env vars) but DO NOT restart the gateway.
163
+ # OpenClaw uses chokidar to watch openclaw.json — updating the file above
164
+ # triggers a hot reload automatically. Restarting the gateway kills all
165
+ # active sessions (the root cause of the 0-turn session cascade on 2026-04-07).
163
166
  systemctl --user daemon-reload
164
- systemctl --user restart openclaw-gateway
165
167
 
166
- log "Gateway restarted with fresh token (expires in ${REMAINING}h)"
168
+ # Touch the config to ensure chokidar picks up the change (write already did,
169
+ # but belt-and-suspenders in case the mtime didn't change fast enough).
170
+ touch "$OPENCLAW_JSON"
171
+
172
+ log "Token synced via hot reload (expires in ${REMAINING}h) — gateway NOT restarted, active sessions preserved"
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env bash
2
+ # Usage: ./scripts/release.sh [patch|minor|major|X.Y.Z]
3
+ #
4
+ # Bumps version in pyproject.toml AND package.json, commits, tags, and pushes.
5
+ # Pushing the tag triggers the publish workflow (PyPI + npm).
6
+ #
7
+ # IMPORTANT: pyproject.toml version MUST match the tag — the workflow enforces this.
8
+ #
9
+ # Examples:
10
+ # ./scripts/release.sh patch # 0.6.2 → 0.6.3
11
+ # ./scripts/release.sh minor # 0.6.2 → 0.7.0
12
+ # ./scripts/release.sh 1.0.0 # set explicit version
13
+
14
+ set -euo pipefail
15
+
16
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
17
+ BUMP="${1:-patch}"
18
+
19
+ # ── use existing bump_version.py for the actual bump ─────────────────────────
20
+
21
+ BUMP_SCRIPT="$REPO_ROOT/scripts/bump_version.py"
22
+
23
+ if [[ ! -f "$BUMP_SCRIPT" ]]; then
24
+ echo "ERROR: $BUMP_SCRIPT not found" >&2
25
+ exit 1
26
+ fi
27
+
28
+ # Dry-run first to show what will happen
29
+ echo "Preview:"
30
+ python3 "$BUMP_SCRIPT" "$BUMP" --pkg "$REPO_ROOT" --dry-run
31
+ echo ""
32
+
33
+ # Confirm
34
+ read -rp "Proceed? [y/N] " confirm
35
+ if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
36
+ echo "Aborted."
37
+ exit 0
38
+ fi
39
+
40
+ # Get new version (run bump to get the number, without committing yet)
41
+ NEW_VERSION=$(python3 -c "
42
+ import re, sys
43
+ sys.path.insert(0, '$REPO_ROOT/scripts')
44
+ # Parse manually — same logic as bump_version.py
45
+ text = open('$REPO_ROOT/pyproject.toml').read()
46
+ m = re.search(r'^version\s*=\s*\"([^\"]+)\"', text, re.MULTILINE)
47
+ current = m.group(1)
48
+ part = '$BUMP'
49
+ major, minor, patch = map(int, current.split('.'))
50
+ if part == 'major':
51
+ print(f'{major+1}.0.0')
52
+ elif part == 'minor':
53
+ print(f'{major}.{minor+1}.0')
54
+ elif part == 'patch':
55
+ print(f'{major}.{minor}.{patch+1}')
56
+ else:
57
+ parts = part.split('.')
58
+ if len(parts) != 3 or not all(p.isdigit() for p in parts):
59
+ raise ValueError(f'Invalid version: {part!r}')
60
+ print(part)
61
+ ")
62
+
63
+ # Bump pyproject.toml and commit via bump_version.py
64
+ python3 "$BUMP_SCRIPT" "$BUMP" --pkg "$REPO_ROOT"
65
+
66
+ # Sync package.json to same version (workflow also does this, but keep file in sync)
67
+ PACKAGE_JSON="$REPO_ROOT/package.json"
68
+ python3 -c "
69
+ import json
70
+ with open('$PACKAGE_JSON') as f:
71
+ pkg = json.load(f)
72
+ pkg['version'] = '$NEW_VERSION'
73
+ with open('$PACKAGE_JSON', 'w') as f:
74
+ json.dump(pkg, f, indent=2)
75
+ f.write('\n')
76
+ print(f' Updated package.json to $NEW_VERSION')
77
+ "
78
+
79
+ cd "$REPO_ROOT"
80
+ git add package.json
81
+ git commit --amend --no-edit
82
+ echo " Amended commit to include package.json"
83
+
84
+ TAG="v$NEW_VERSION"
85
+ git tag -a "$TAG" -m "Release $TAG"
86
+ echo " Tagged: $TAG"
87
+
88
+ echo ""
89
+ echo "Pushing commit and tag to origin..."
90
+ git push
91
+ git push --tags
92
+
93
+ echo ""
94
+ echo "Done. GitHub Actions will now:"
95
+ echo " 1. Run tests"
96
+ echo " 2. Verify pyproject.toml version matches tag ($TAG)"
97
+ echo " 3. Publish skcapstone $NEW_VERSION to PyPI"
98
+ echo " 4. Publish @smilintux/skcapstone $NEW_VERSION to npm"
@@ -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, "SKAGENT": agent, "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()