knight-os 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) {{YEAR}} {{AUTHOR}}
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,194 @@
1
+ # knight-os
2
+
3
+ AI companion OS for OpenClaw — memory, reflection, and identity framework.
4
+
5
+ Give your OpenClaw AI a name, a personality, and the ability to learn from experience.
6
+
7
+ ## Prerequisites
8
+
9
+ Install [OpenClaw](https://github.com/openclaw/openclaw) first:
10
+
11
+ ```bash
12
+ npm install -g openclaw
13
+ ```
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install -g knight-os
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```bash
24
+ knight setup
25
+ ```
26
+
27
+ The setup wizard will:
28
+
29
+ 1. Verify OpenClaw is installed
30
+ 2. Ask for your AI's name, your name, and timezone
31
+ 3. Write all framework files into your OpenClaw workspace
32
+ 4. Optionally configure Telegram notifications
33
+ 5. Register the Heartbeat scheduler (macOS/Linux)
34
+
35
+ After setup, start chatting via OpenClaw:
36
+
37
+ ```bash
38
+ openclaw chat
39
+ ```
40
+
41
+ ### Custom workspace path
42
+
43
+ If your OpenClaw workspace is not at the default `~/.openclaw/workspace`, enter your path when prompted:
44
+
45
+ ```
46
+ Workspace directory [~/.openclaw/workspace]: /workspace/projects
47
+ ```
48
+
49
+ Knight OS will write all files there and OpenClaw will pick them up automatically.
50
+
51
+ ---
52
+
53
+ ## How Memory Works
54
+
55
+ This is the core of what knight-os adds. Your AI learns from experience through a simple loop:
56
+
57
+ ```
58
+ You finish a task
59
+
60
+ write-reflection.py → memory/reflections/YYYY-MM-DD.jsonl
61
+
62
+ Heartbeat runs → reflection-analyzer.py → candidate rules extracted
63
+
64
+ You confirm → rules written to memory/ai-patterns.md
65
+
66
+ Next session → ai-patterns.md loaded in system prompt → AI behaves better
67
+ ```
68
+
69
+ **In practice:**
70
+
71
+ ```bash
72
+ # After completing any task, run:
73
+ python3 ~/.openclaw/workspace/scripts/write-reflection.py \
74
+ --context "Deployed new feature" \
75
+ --what_worked "Clear requirements helped" \
76
+ --what_failed "Forgot to update tests" \
77
+ --next_time "Write tests first, then implement"
78
+
79
+ # Every 6 hours (automatic), the heartbeat:
80
+ # 1. Scans reflections for repeated failure patterns
81
+ # 2. Extracts candidate rules
82
+ # 3. Notifies you (if Telegram configured)
83
+
84
+ # You review and add confirmed rules to:
85
+ # ~/.openclaw/workspace/memory/ai-patterns.md
86
+ ```
87
+
88
+ Over time, `ai-patterns.md` accumulates rules your AI uses automatically in every session.
89
+
90
+ ---
91
+
92
+ ## Memory File Structure
93
+
94
+ ```
95
+ ~/.openclaw/workspace/
96
+ ├── SOUL.md # AI identity and personality
97
+ ├── AGENTS.md # Boot sequence, behavior norms, script reference
98
+ ├── MEMORY.md # Long-term memory index
99
+ ├── REDLINES.md # Safety boundaries
100
+ ├── USER.md # Your profile and preferences
101
+ ├── TOOLS.md # Tool reference and credentials map
102
+ ├── PROJECTS.md # Active project index
103
+ ├── HEARTBEAT.md # Heartbeat task configuration
104
+ ├── memory/
105
+ │ ├── ai-patterns.md # Learned behavior rules (grows over time)
106
+ │ ├── user-patterns.md # Observed user behavior
107
+ │ ├── reflections/ # Task reflection logs (JSONL)
108
+ │ ├── logs/ # Session logs
109
+ │ └── projects/<name>/ # Per-project context
110
+ └── scripts/
111
+ ├── write-reflection.py # Log a reflection after task completion
112
+ ├── reflection-analyzer.py # Extract rules from reflection patterns
113
+ ├── heartbeat.py # Periodic maintenance tasks
114
+ ├── compress-memory.py # Archive old logs
115
+ └── knight-status.py # Workspace health check
116
+ ```
117
+
118
+ ---
119
+
120
+ ## Commands
121
+
122
+ ```bash
123
+ knight setup # Configure Knight OS (requires OpenClaw installed)
124
+ knight init # Initialize workspace standalone (no OpenClaw check)
125
+ knight chat # Interactive AI chat (Anthropic API directly)
126
+ knight status # Check workspace file status
127
+ knight version # Show version
128
+ ```
129
+
130
+ ### Standalone chat (`knight chat`)
131
+
132
+ If you want to chat without OpenClaw, you can use the built-in chat command.
133
+ Requires `ANTHROPIC_API_KEY` in your workspace `.env`.
134
+
135
+ ---
136
+
137
+ ## Runtime Scripts
138
+
139
+ ```bash
140
+ # Log a reflection after completing a task
141
+ python3 scripts/write-reflection.py \
142
+ --context "Task title" \
143
+ --what_worked "What went well" \
144
+ --what_failed "What did not work" \
145
+ --next_time "How to improve"
146
+
147
+ # Analyze reflection patterns (run by heartbeat automatically)
148
+ python3 scripts/reflection-analyzer.py --min-count 2
149
+
150
+ # Check workspace health
151
+ python3 scripts/knight-status.py
152
+
153
+ # Archive old logs
154
+ python3 scripts/compress-memory.py --execute
155
+
156
+ # Run heartbeat manually
157
+ python3 scripts/heartbeat.py
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Core Principles
163
+
164
+ ### Framework & Content Separation
165
+
166
+ knight-os provides the **structure** — the files, the rules, the mechanisms.
167
+ You provide the **content** — your AI's personality, your preferences, your specific tools.
168
+
169
+ ### Learning from Feedback
170
+
171
+ The system evolves. Corrections become rules (`ai-patterns.md`), observations become understanding (`user-patterns.md`), decisions become memory (`MEMORY.md`). Nothing is static.
172
+
173
+ ### Memory Layering
174
+
175
+ | Layer | Location | When promoted |
176
+ |-------|----------|--------------|
177
+ | Working | In-context (current session) | — |
178
+ | Short-term | `memory/YYYY-MM-DD.md` | End of session |
179
+ | Long-term | `MEMORY.md` | Pattern repeats 3+ times or user confirms |
180
+ | Patterns | `memory/ai-patterns.md` | After reflection analysis + confirmation |
181
+
182
+ ---
183
+
184
+ ## Contributing
185
+
186
+ Contributions welcome. Keep templates generic — no personal data or tool credentials.
187
+
188
+ 1. Fork this repository
189
+ 2. Create a feature branch
190
+ 3. Submit a pull request
191
+
192
+ ## License
193
+
194
+ MIT
package/bin/knight.js ADDED
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const readline = require('readline');
8
+ const { loadConfig, resolveWorkspace } = require('../src/config');
9
+ const { chat } = require('../src/chat');
10
+ const { setup } = require('../src/setup');
11
+
12
+ const VERSION = '0.1.0';
13
+ const DEFAULT_WORKSPACE = path.join(process.env.HOME || '~', '.openclaw', 'workspace');
14
+ const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
15
+
16
+ function ask(rl, question) {
17
+ return new Promise((resolve) => {
18
+ rl.question(question, (answer) => resolve(answer.trim()));
19
+ });
20
+ }
21
+
22
+ function getAllTemplateFiles(dir, base) {
23
+ base = base || dir;
24
+ let results = [];
25
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
26
+ for (const entry of entries) {
27
+ const fullPath = path.join(dir, entry.name);
28
+ if (entry.isDirectory()) {
29
+ results = results.concat(getAllTemplateFiles(fullPath, base));
30
+ } else {
31
+ results.push(path.relative(base, fullPath));
32
+ }
33
+ }
34
+ return results;
35
+ }
36
+
37
+ function replacePlaceholders(content, vars) {
38
+ return content
39
+ .replace(/\{\{AI_NAME\}\}/g, vars.aiName)
40
+ .replace(/\{\{USER_NAME\}\}/g, vars.userName)
41
+ .replace(/\{\{TIMEZONE\}\}/g, vars.timezone)
42
+ .replace(/\{\{LANGUAGE\}\}/g, vars.language || 'en')
43
+ .replace(/\{\{CHANNEL\}\}/g, vars.channel || 'direct');
44
+ }
45
+
46
+ async function commandInit() {
47
+ const rl = readline.createInterface({
48
+ input: process.stdin,
49
+ output: process.stdout,
50
+ });
51
+
52
+ console.log('\n🐉 Knight OS — OpenClaw Workspace Initializer\n');
53
+
54
+ const aiName = await ask(rl, "Your AI companion's name? (e.g. Aria, Nova, Kai): ");
55
+ if (!aiName) {
56
+ console.log('AI name is required.');
57
+ rl.close();
58
+ process.exit(1);
59
+ }
60
+
61
+ const userName = await ask(rl, 'Your name? (used to personalize the workspace): ');
62
+ if (!userName) {
63
+ console.log('Your name is required.');
64
+ rl.close();
65
+ process.exit(1);
66
+ }
67
+
68
+ const timezone = await ask(rl, 'Your timezone? (e.g. Asia/Tokyo, America/New_York): ');
69
+ if (!timezone) {
70
+ console.log('Timezone is required.');
71
+ rl.close();
72
+ process.exit(1);
73
+ }
74
+
75
+ const workspaceInput = await ask(rl, `Workspace directory? [${DEFAULT_WORKSPACE}]: `);
76
+ const workspace = workspaceInput || DEFAULT_WORKSPACE;
77
+
78
+ const apiKeyInput = await ask(
79
+ rl,
80
+ 'Anthropic API key? (starts with sk-ant-, leave blank to skip): '
81
+ );
82
+
83
+ const modelInput = await ask(rl, 'Default model? [claude-sonnet-4-5]: ');
84
+ const modelName = modelInput || 'claude-sonnet-4-5';
85
+
86
+ console.log('\n--- Preview ---');
87
+ console.log(` AI Name: ${aiName}`);
88
+ console.log(` User Name: ${userName}`);
89
+ console.log(` Timezone: ${timezone}`);
90
+ console.log(` Workspace: ${workspace}`);
91
+ console.log(` Model: ${modelName}`);
92
+ if (apiKeyInput) console.log(` API Key: ${apiKeyInput.slice(0, 12)}...`);
93
+ console.log('---------------\n');
94
+
95
+ const confirm = await ask(rl, 'Press Enter to confirm, or type "no" to cancel: ');
96
+ if (confirm.toLowerCase() === 'no') {
97
+ console.log('Cancelled.');
98
+ rl.close();
99
+ process.exit(0);
100
+ }
101
+
102
+ rl.close();
103
+
104
+ const vars = { aiName, userName, timezone };
105
+ const templateFiles = getAllTemplateFiles(TEMPLATES_DIR);
106
+
107
+ fs.mkdirSync(workspace, { recursive: true });
108
+
109
+ let written = 0;
110
+ let skipped = 0;
111
+
112
+ for (const relPath of templateFiles) {
113
+ const srcPath = path.join(TEMPLATES_DIR, relPath);
114
+ const destPath = path.join(workspace, relPath);
115
+ const destDir = path.dirname(destPath);
116
+
117
+ fs.mkdirSync(destDir, { recursive: true });
118
+
119
+ if (fs.existsSync(destPath)) {
120
+ const rlConfirm = readline.createInterface({
121
+ input: process.stdin,
122
+ output: process.stdout,
123
+ });
124
+ const overwrite = await ask(rlConfirm, ` File exists: ${relPath} — overwrite? [y/N]: `);
125
+ rlConfirm.close();
126
+ if (overwrite.toLowerCase() !== 'y') {
127
+ skipped++;
128
+ continue;
129
+ }
130
+ }
131
+
132
+ const content = fs.readFileSync(srcPath, 'utf-8');
133
+ const processed = replacePlaceholders(content, vars);
134
+ fs.writeFileSync(destPath, processed, 'utf-8');
135
+ written++;
136
+ }
137
+
138
+ if (apiKeyInput) {
139
+ const envPath = path.join(workspace, '.env');
140
+ const envLine = `ANTHROPIC_API_KEY=${apiKeyInput}\n`;
141
+ if (fs.existsSync(envPath)) {
142
+ fs.appendFileSync(envPath, envLine, 'utf-8');
143
+ } else {
144
+ fs.writeFileSync(envPath, envLine, 'utf-8');
145
+ }
146
+ }
147
+
148
+ const configPath = path.join(workspace, 'knight.config.json');
149
+ let existingConfig = {};
150
+ if (fs.existsSync(configPath)) {
151
+ try {
152
+ existingConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
153
+ } catch {}
154
+ }
155
+ existingConfig.model = {
156
+ provider: 'anthropic',
157
+ name: modelName,
158
+ max_tokens: 8096,
159
+ system_prompt_files: ['SOUL.md', 'AGENTS.md', 'MEMORY.md', 'REDLINES.md'],
160
+ };
161
+ fs.writeFileSync(configPath, JSON.stringify(existingConfig, null, 2) + '\n', 'utf-8');
162
+
163
+ console.log(`\n✅ Workspace initialized at ${workspace}`);
164
+ console.log(` ${written} file(s) written, ${skipped} skipped.\n`);
165
+ console.log('Next steps:');
166
+ console.log(` 1. Review your workspace files in ${workspace}`);
167
+ console.log(' 2. Customize SOUL.md and USER.md to match your preferences');
168
+ console.log(' 3. Run \`knight chat\` to start talking to your AI companion');
169
+ console.log('');
170
+ }
171
+
172
+ function commandStatus() {
173
+ const workspace = DEFAULT_WORKSPACE;
174
+ const requiredFiles = [
175
+ 'AGENTS.md',
176
+ 'SOUL.md',
177
+ 'MEMORY.md',
178
+ 'HEARTBEAT.md',
179
+ 'REDLINES.md',
180
+ 'USER.md',
181
+ 'TOOLS.md',
182
+ 'memory/ai-patterns.md',
183
+ 'memory/user-patterns.md',
184
+ ];
185
+
186
+ console.log(`\n📋 Knight OS — Workspace Status`);
187
+ console.log(` Directory: ${workspace}\n`);
188
+
189
+ if (!fs.existsSync(workspace)) {
190
+ console.log(' ❌ Workspace directory does not exist.');
191
+ console.log(' Run "knight init" to create it.\n');
192
+ process.exit(1);
193
+ }
194
+
195
+ let ok = 0;
196
+ let missing = 0;
197
+
198
+ for (const file of requiredFiles) {
199
+ const fullPath = path.join(workspace, file);
200
+ if (fs.existsSync(fullPath)) {
201
+ console.log(` ✅ ${file}`);
202
+ ok++;
203
+ } else {
204
+ console.log(` ❌ ${file}`);
205
+ missing++;
206
+ }
207
+ }
208
+
209
+ console.log(`\n Result: ${ok} present, ${missing} missing.\n`);
210
+ }
211
+
212
+ function commandVersion() {
213
+ console.log(`knight-os v${VERSION}`);
214
+ }
215
+
216
+ async function commandChat() {
217
+ const config = loadConfig();
218
+ const workspace = resolveWorkspace(config);
219
+ await chat(config, workspace);
220
+ }
221
+
222
+ const command = process.argv[2];
223
+
224
+ switch (command) {
225
+ case 'setup':
226
+ setup();
227
+ break;
228
+ case 'init':
229
+ commandInit();
230
+ break;
231
+ case 'chat':
232
+ commandChat();
233
+ break;
234
+ case 'status':
235
+ commandStatus();
236
+ break;
237
+ case 'version':
238
+ case '--version':
239
+ case '-v':
240
+ commandVersion();
241
+ break;
242
+ default:
243
+ console.log(`knight-os v${VERSION}`);
244
+ console.log('\nUsage: knight <command>\n');
245
+ console.log('Commands:');
246
+ console.log(' setup Configure Knight OS for an existing OpenClaw installation');
247
+ console.log(' init Initialize a new workspace (standalone, no OpenClaw required)');
248
+ console.log(' chat Start interactive AI chat session');
249
+ console.log(' status Check workspace file status');
250
+ console.log(' version Show version number');
251
+ console.log('');
252
+ break;
253
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "knight-os",
3
+ "version": "0.1.0",
4
+ "description": "AI companion OS for OpenClaw — memory, reflection, and identity framework",
5
+ "main": "bin/knight.js",
6
+ "bin": {
7
+ "knight": "./bin/knight.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node bin/knight.js version"
11
+ },
12
+ "keywords": [
13
+ "openclaw",
14
+ "ai",
15
+ "workspace",
16
+ "agent",
17
+ "memory",
18
+ "reflection",
19
+ "identity",
20
+ "llm"
21
+ ],
22
+ "author": "Steve Wang <iloveopt@gmail.com>",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/iloveopt/knight-os.git"
27
+ },
28
+ "homepage": "https://github.com/iloveopt/knight-os#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/iloveopt/knight-os/issues"
31
+ },
32
+ "engines": {
33
+ "node": ">=16.0.0"
34
+ },
35
+ "files": [
36
+ "bin/",
37
+ "src/",
38
+ "scripts/",
39
+ "templates/",
40
+ "README.md",
41
+ "LICENSE"
42
+ ]
43
+ }
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ compress-memory.py — Archive and compress old log files.
4
+
5
+ Usage:
6
+ python3 scripts/compress-memory.py # Dry-run by default (report only)
7
+ python3 scripts/compress-memory.py --dry-run # Explicit dry-run
8
+ python3 scripts/compress-memory.py --execute # Actually move files to archive
9
+ python3 scripts/compress-memory.py --days 14 # Keep only last 14 days (default: 30)
10
+ python3 scripts/compress-memory.py --threshold 200 # Line threshold (default: 500)
11
+
12
+ Scans {workspace}/memory/logs/ and archives files older than --days into memory/logs/archive/.
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ import argparse
19
+ import shutil
20
+ from datetime import datetime, timezone, timedelta
21
+ from pathlib import Path
22
+
23
+
24
+ def load_config():
25
+ config_paths = [
26
+ Path.cwd() / "knight.config.json",
27
+ Path.home() / ".knight" / "config.json",
28
+ ]
29
+ for p in config_paths:
30
+ if p.exists():
31
+ try:
32
+ return json.loads(p.read_text())
33
+ except (json.JSONDecodeError, OSError):
34
+ pass
35
+ return {}
36
+
37
+
38
+ def resolve_workspace(config):
39
+ ws = config.get("workspace", "~/.openclaw/workspace")
40
+ return Path(ws).expanduser()
41
+
42
+
43
+ def scan_logs(logs_dir: Path):
44
+ """Scan log files and return stats."""
45
+ files = []
46
+ total_lines = 0
47
+ total_size = 0
48
+
49
+ for f in sorted(logs_dir.iterdir()):
50
+ if f.is_file() and f.suffix in (".md", ".log", ".jsonl", ".txt"):
51
+ size = f.stat().st_size
52
+ mtime = datetime.fromtimestamp(f.stat().st_mtime, tz=timezone.utc)
53
+ try:
54
+ lines = sum(1 for _ in open(f, encoding="utf-8", errors="ignore"))
55
+ except OSError:
56
+ lines = 0
57
+
58
+ files.append({
59
+ "path": f,
60
+ "name": f.name,
61
+ "size": size,
62
+ "lines": lines,
63
+ "mtime": mtime,
64
+ })
65
+ total_lines += lines
66
+ total_size += size
67
+
68
+ return files, total_lines, total_size
69
+
70
+
71
+ def main():
72
+ parser = argparse.ArgumentParser(description="Archive and compress old log files.")
73
+ parser.add_argument("--dry-run", action="store_true", default=True, help="Report only (default)")
74
+ parser.add_argument("--execute", action="store_true", help="Actually archive old files")
75
+ parser.add_argument("--days", type=int, default=30, help="Keep files from last N days (default: 30)")
76
+ parser.add_argument("--threshold", type=int, default=500, help="Line count threshold for warning (default: 500)")
77
+ args = parser.parse_args()
78
+
79
+ if args.execute:
80
+ args.dry_run = False
81
+
82
+ config = load_config()
83
+ workspace = resolve_workspace(config)
84
+ local_cfg = config.get("storage", {}).get("local", {})
85
+ logs_dir = workspace / local_cfg.get("logs_dir", "memory/logs")
86
+
87
+ print(f"[knight] Memory compression — scanning {logs_dir}")
88
+
89
+ if not logs_dir.exists():
90
+ print(f" Logs directory does not exist: {logs_dir}")
91
+ print(" Nothing to compress.")
92
+ return
93
+
94
+ files, total_lines, total_size = scan_logs(logs_dir)
95
+ size_mb = total_size / (1024 * 1024)
96
+
97
+ print(f" Found {len(files)} log files")
98
+ print(f" Total: {total_lines} lines, {size_mb:.2f} MB")
99
+
100
+ if total_lines <= args.threshold:
101
+ print(f" Below threshold ({args.threshold} lines) — no compression needed.")
102
+ return
103
+
104
+ print(f" Above threshold ({args.threshold} lines) — compression recommended.")
105
+
106
+ cutoff = datetime.now(timezone.utc) - timedelta(days=args.days)
107
+ to_archive = [f for f in files if f["mtime"] < cutoff]
108
+ to_keep = [f for f in files if f["mtime"] >= cutoff]
109
+
110
+ archive_lines = sum(f["lines"] for f in to_archive)
111
+ archive_size = sum(f["size"] for f in to_archive)
112
+
113
+ print(f"\n Archive candidates (older than {args.days} days):")
114
+ print(f" {len(to_archive)} files, {archive_lines} lines, {archive_size / 1024:.1f} KB")
115
+ print(f" Keeping (recent {args.days} days):")
116
+ print(f" {len(to_keep)} files, {sum(f['lines'] for f in to_keep)} lines")
117
+
118
+ if not to_archive:
119
+ print("\n No files old enough to archive.")
120
+ return
121
+
122
+ if args.dry_run:
123
+ print("\n [dry-run] Files that would be archived:")
124
+ for f in to_archive[:10]:
125
+ print(f" {f['name']} ({f['lines']} lines, {f['mtime'].strftime('%Y-%m-%d')})")
126
+ if len(to_archive) > 10:
127
+ print(f" ... and {len(to_archive) - 10} more")
128
+ print("\n Run with --execute to perform archival.")
129
+ return
130
+
131
+ archive_dir = logs_dir / "archive"
132
+ archive_dir.mkdir(parents=True, exist_ok=True)
133
+
134
+ moved = 0
135
+ for f in to_archive:
136
+ dest = archive_dir / f["name"]
137
+ if dest.exists():
138
+ dest = archive_dir / f"{f['path'].stem}_{f['mtime'].strftime('%Y%m%d')}{f['path'].suffix}"
139
+ shutil.move(str(f["path"]), str(dest))
140
+ moved += 1
141
+
142
+ print(f"\n Archived {moved} files to {archive_dir}")
143
+ print(f" Freed {archive_lines} lines, {archive_size / 1024:.1f} KB")
144
+
145
+
146
+ if __name__ == "__main__":
147
+ main()