n2-soul 4.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.
@@ -0,0 +1,239 @@
1
+ // Soul KV-Cache — Tiered storage manager. Hot/Warm/Cold lifecycle for snapshots.
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+
5
+ /**
6
+ * Tiered storage levels for KV-Cache snapshots.
7
+ *
8
+ * Hot (0-7 days): In-memory cache + disk. Fastest access.
9
+ * Warm (8-30 days): Disk only. Normal file/db access.
10
+ * Cold (30+ days): Archived (compressed). Lazy load on demand.
11
+ */
12
+ const TIERS = {
13
+ HOT: { name: 'hot', maxAgeDays: 7 },
14
+ WARM: { name: 'warm', maxAgeDays: 30 },
15
+ COLD: { name: 'cold', maxAgeDays: Infinity },
16
+ };
17
+
18
+ /**
19
+ * TierManager wraps a storage engine and adds tiered caching.
20
+ * Hot tier snapshots are kept in memory for fast access.
21
+ * Cold tier snapshots are moved to a compressed archive.
22
+ */
23
+ class TierManager {
24
+ /**
25
+ * @param {object} storageEngine - SnapshotEngine or SqliteStore
26
+ * @param {object} config - tier config { hotDays, warmDays }
27
+ */
28
+ constructor(storageEngine, config = {}) {
29
+ this.engine = storageEngine;
30
+ this.hotDays = config.hotDays || TIERS.HOT.maxAgeDays;
31
+ this.warmDays = config.warmDays || TIERS.WARM.maxAgeDays;
32
+ this._hotCache = {}; // { projectName: { id: session } }
33
+ }
34
+
35
+ /**
36
+ * Classify a snapshot's tier based on age.
37
+ *
38
+ * @param {object} snapshot
39
+ * @returns {'hot'|'warm'|'cold'}
40
+ */
41
+ classify(snapshot) {
42
+ const timestamp = snapshot.endedAt || snapshot.startedAt;
43
+ if (!timestamp) return 'warm';
44
+
45
+ const ageMs = Date.now() - new Date(timestamp).getTime();
46
+ const ageDays = ageMs / (24 * 60 * 60 * 1000);
47
+
48
+ if (ageDays <= this.hotDays) return 'hot';
49
+ if (ageDays <= this.warmDays) return 'warm';
50
+ return 'cold';
51
+ }
52
+
53
+ /**
54
+ * Save with automatic hot-cache population.
55
+ *
56
+ * @param {object} session
57
+ * @returns {string} Snapshot ID
58
+ */
59
+ save(session) {
60
+ const id = this.engine.save(session);
61
+
62
+ // Add to hot cache
63
+ const project = session.projectName || session.project;
64
+ if (project) {
65
+ if (!this._hotCache[project]) this._hotCache[project] = {};
66
+ this._hotCache[project][id] = { ...session, id };
67
+ }
68
+
69
+ return id;
70
+ }
71
+
72
+ /**
73
+ * Load latest with hot-cache check first.
74
+ *
75
+ * @param {string} projectName
76
+ * @returns {object|null}
77
+ */
78
+ loadLatest(projectName) {
79
+ // Check hot cache first
80
+ const cache = this._hotCache[projectName];
81
+ if (cache) {
82
+ const entries = Object.values(cache);
83
+ if (entries.length > 0) {
84
+ entries.sort((a, b) => {
85
+ const ta = new Date(b.endedAt || b.startedAt).getTime();
86
+ const tb = new Date(a.endedAt || a.startedAt).getTime();
87
+ return ta - tb;
88
+ });
89
+ return entries[0];
90
+ }
91
+ }
92
+
93
+ // Fall through to storage engine
94
+ return this.engine.loadLatest(projectName);
95
+ }
96
+
97
+ /**
98
+ * List snapshots with tier annotations.
99
+ *
100
+ * @param {string} projectName
101
+ * @param {number} limit
102
+ * @returns {object[]}
103
+ */
104
+ list(projectName, limit = 10) {
105
+ const snapshots = this.engine.list(projectName, limit);
106
+ return snapshots.map(snap => ({
107
+ ...snap,
108
+ _tier: this.classify(snap),
109
+ }));
110
+ }
111
+
112
+ /**
113
+ * Search with tier annotations.
114
+ *
115
+ * @param {string} query
116
+ * @param {string} projectName
117
+ * @param {number} limit
118
+ * @returns {object[]}
119
+ */
120
+ search(query, projectName, limit = 10) {
121
+ const results = this.engine.search(query, projectName, limit);
122
+ return results.map(snap => ({
123
+ ...snap,
124
+ _tier: this.classify(snap),
125
+ }));
126
+ }
127
+
128
+ /**
129
+ * Tier-aware garbage collection.
130
+ * - Hot: never deleted
131
+ * - Warm: normal retention
132
+ * - Cold: archived or deleted based on maxAge
133
+ *
134
+ * @param {string} projectName
135
+ * @param {number} maxAgeDays
136
+ * @param {number} maxCount
137
+ * @returns {{ deleted: number, hotCount: number, warmCount: number, coldCount: number }}
138
+ */
139
+ gc(projectName, maxAgeDays = 30, maxCount = 50) {
140
+ const result = this.engine.gc(projectName, maxAgeDays, maxCount);
141
+
142
+ // Refresh hot cache
143
+ this._refreshHotCache(projectName);
144
+
145
+ // Count tiers after GC
146
+ const remaining = this.list(projectName, 9999);
147
+ const counts = { hot: 0, warm: 0, cold: 0 };
148
+ for (const snap of remaining) {
149
+ counts[snap._tier || 'warm']++;
150
+ }
151
+
152
+ return {
153
+ deleted: result.deleted,
154
+ hotCount: counts.hot,
155
+ warmCount: counts.warm,
156
+ coldCount: counts.cold,
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Refresh hot cache for a project from storage.
162
+ * @param {string} projectName
163
+ */
164
+ _refreshHotCache(projectName) {
165
+ const snaps = this.engine.list(projectName, 100);
166
+ const cache = {};
167
+
168
+ for (const snap of snaps) {
169
+ if (this.classify(snap) === 'hot') {
170
+ cache[snap.id] = snap;
171
+ }
172
+ }
173
+
174
+ this._hotCache[projectName] = cache;
175
+ }
176
+
177
+ /**
178
+ * Get tier distribution summary for a project.
179
+ *
180
+ * @param {string} projectName
181
+ * @returns {{ hot: number, warm: number, cold: number, total: number }}
182
+ */
183
+ tierSummary(projectName) {
184
+ const snaps = this.list(projectName, 9999);
185
+ const counts = { hot: 0, warm: 0, cold: 0, total: snaps.length };
186
+
187
+ for (const snap of snaps) {
188
+ counts[snap._tier || 'warm']++;
189
+ }
190
+
191
+ return counts;
192
+ }
193
+
194
+ /**
195
+ * Warm up: preload hot tier snapshots into memory.
196
+ *
197
+ * @param {string} projectName
198
+ * @returns {number} Number of snapshots cached
199
+ */
200
+ warmUp(projectName) {
201
+ this._refreshHotCache(projectName);
202
+ return Object.keys(this._hotCache[projectName] || {}).length;
203
+ }
204
+
205
+ /**
206
+ * Clear hot cache for a project.
207
+ * @param {string} projectName
208
+ */
209
+ evict(projectName) {
210
+ delete this._hotCache[projectName];
211
+ }
212
+
213
+ /**
214
+ * Proxy: loadById
215
+ */
216
+ loadById(projectName, snapshotId) {
217
+ return this.engine.loadById(projectName, snapshotId);
218
+ }
219
+
220
+ /**
221
+ * Proxy: migrateFromJson (if available)
222
+ */
223
+ migrateFromJson(jsonBaseDir, projectName) {
224
+ if (this.engine.migrateFromJson) {
225
+ return this.engine.migrateFromJson(jsonBaseDir, projectName);
226
+ }
227
+ return { error: 'Migration not available for this backend' };
228
+ }
229
+
230
+ /**
231
+ * Proxy: dispose
232
+ */
233
+ dispose() {
234
+ this._hotCache = {};
235
+ if (this.engine.dispose) this.engine.dispose();
236
+ }
237
+ }
238
+
239
+ module.exports = { TierManager, TIERS };
@@ -0,0 +1,153 @@
1
+ // Soul KV-Cache — Progressive loading. Extracts context at L1/L2/L3 token budgets.
2
+ const { extractKeywords } = require('./agent-adapter');
3
+
4
+ /**
5
+ * Progressive loading levels for KV-Cache context restoration.
6
+ * Lower levels use fewer tokens but provide less context.
7
+ *
8
+ * L1: Minimal — keywords + TODO only (~500 tokens)
9
+ * L2: Standard — L1 + compressed summary + decisions (~2000 tokens)
10
+ * L3: Full — complete uncompressed context (no limit)
11
+ */
12
+ const LEVELS = {
13
+ L1: { name: 'minimal', maxTokens: 500 },
14
+ L2: { name: 'standard', maxTokens: 2000 },
15
+ L3: { name: 'full', maxTokens: Infinity },
16
+ };
17
+
18
+ /**
19
+ * Extracts context from a snapshot at the specified progressive level.
20
+ *
21
+ * @param {object} snapshot - KV-Cache session snapshot
22
+ * @param {string} level - 'L1', 'L2', or 'L3'
23
+ * @returns {{ level: string, tokens: number, prompt: string }}
24
+ */
25
+ function extractAtLevel(snapshot, level = 'L2') {
26
+ if (!snapshot) return { level, tokens: 0, prompt: '' };
27
+
28
+ const spec = LEVELS[level] || LEVELS.L2;
29
+ const lines = [];
30
+
31
+ // --- L1: Minimal (keywords + TODO) ---
32
+ lines.push(`[Session: ${snapshot.agentName} | ${(snapshot.endedAt || snapshot.startedAt || '').split('T')[0]}]`);
33
+
34
+ if (snapshot.parentSessionId) {
35
+ lines.push(`Chain: ${snapshot.parentSessionId.slice(0, 8)} -> ${snapshot.id.slice(0, 8)}`);
36
+ }
37
+
38
+ if (snapshot.keys?.length > 0) {
39
+ lines.push(`Topics: ${snapshot.keys.slice(0, 10).join(', ')}`);
40
+ }
41
+
42
+ if (snapshot.context?.todo?.length > 0) {
43
+ lines.push('TODO:');
44
+ for (const t of snapshot.context.todo) {
45
+ lines.push(` - ${t}`);
46
+ }
47
+ }
48
+
49
+ let prompt = lines.join('\n');
50
+ let tokens = estimateTokenCount(prompt);
51
+
52
+ if (level === 'L1' || tokens >= spec.maxTokens) {
53
+ return trimToTokenBudget(prompt, tokens, spec.maxTokens, 'L1');
54
+ }
55
+
56
+ // --- L2: Standard (+ summary + decisions) ---
57
+ if (snapshot.context?.decisions?.length > 0) {
58
+ lines.push('Decisions:');
59
+ for (const d of snapshot.context.decisions) {
60
+ lines.push(` - ${d}`);
61
+ }
62
+ }
63
+
64
+ if (snapshot.context?.summary) {
65
+ lines.push(`Summary: ${snapshot.context.summary}`);
66
+ }
67
+
68
+ prompt = lines.join('\n');
69
+ tokens = estimateTokenCount(prompt);
70
+
71
+ if (level === 'L2' || tokens >= spec.maxTokens) {
72
+ return trimToTokenBudget(prompt, tokens, spec.maxTokens, 'L2');
73
+ }
74
+
75
+ // --- L3: Full (+ files changed) ---
76
+ if (snapshot.context?.filesChanged?.length > 0) {
77
+ lines.push('Files changed:');
78
+ for (const f of snapshot.context.filesChanged) {
79
+ const entry = typeof f === 'string' ? f : `${f.path} — ${f.desc || ''}`;
80
+ lines.push(` - ${entry}`);
81
+ }
82
+ }
83
+
84
+ // Include raw metadata
85
+ lines.push(`Agent type: ${snapshot.agentType || 'unknown'}`);
86
+ if (snapshot.model) lines.push(`Model: ${snapshot.model}`);
87
+ if (snapshot.turnCount) lines.push(`Turns: ${snapshot.turnCount}`);
88
+ if (snapshot.tokenEstimate) lines.push(`Token estimate: ${snapshot.tokenEstimate}`);
89
+
90
+ prompt = lines.join('\n');
91
+ tokens = estimateTokenCount(prompt);
92
+
93
+ return { level: 'L3', tokens, prompt };
94
+ }
95
+
96
+ /**
97
+ * Auto-selects the best progressive level based on available token budget.
98
+ * Starts from L3 and downgrades until it fits.
99
+ *
100
+ * @param {object} snapshot - KV-Cache session snapshot
101
+ * @param {number} budgetTokens - Available token budget
102
+ * @returns {{ level: string, tokens: number, prompt: string }}
103
+ */
104
+ function autoLevel(snapshot, budgetTokens = 2000) {
105
+ if (!snapshot) return { level: 'L1', tokens: 0, prompt: '' };
106
+
107
+ // Try from highest to lowest
108
+ for (const lvl of ['L3', 'L2', 'L1']) {
109
+ const result = extractAtLevel(snapshot, lvl);
110
+ if (result.tokens <= budgetTokens) {
111
+ return result;
112
+ }
113
+ }
114
+
115
+ // Even L1 is over budget — trim L1
116
+ const l1 = extractAtLevel(snapshot, 'L1');
117
+ return trimToTokenBudget(l1.prompt, l1.tokens, budgetTokens, 'L1');
118
+ }
119
+
120
+ /**
121
+ * Estimates token count for text (model-agnostic).
122
+ * CJK characters ~1 token each, ASCII ~4 chars per token.
123
+ *
124
+ * @param {string} text
125
+ * @returns {number}
126
+ */
127
+ function estimateTokenCount(text) {
128
+ if (!text) return 0;
129
+ const cjkCount = (text.match(/[\u3000-\u9fff\uac00-\ud7af]/g) || []).length;
130
+ const asciiCount = text.length - cjkCount;
131
+ return Math.ceil(asciiCount / 4 + cjkCount / 2);
132
+ }
133
+
134
+ /**
135
+ * Trims prompt text to fit within a token budget.
136
+ *
137
+ * @param {string} prompt
138
+ * @param {number} currentTokens
139
+ * @param {number} maxTokens
140
+ * @param {string} level
141
+ * @returns {{ level: string, tokens: number, prompt: string }}
142
+ */
143
+ function trimToTokenBudget(prompt, currentTokens, maxTokens, level) {
144
+ if (currentTokens <= maxTokens) {
145
+ return { level, tokens: currentTokens, prompt };
146
+ }
147
+ // Conservative: assume 3 chars per token for trimming
148
+ const maxChars = maxTokens * 3;
149
+ const trimmed = prompt.slice(0, maxChars) + '\n...(truncated)';
150
+ return { level, tokens: maxTokens, prompt: trimmed };
151
+ }
152
+
153
+ module.exports = { extractAtLevel, autoLevel, estimateTokenCount, LEVELS };
package/lib/paths.js ADDED
@@ -0,0 +1,20 @@
1
+ // Soul — Central path manager. Cross-platform compatible.
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+
5
+ // soul/lib/paths.js → 2 levels up = project root
6
+ const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
7
+ const DATA_ROOT = path.join(path.resolve(__dirname, '..'), 'data');
8
+
9
+ /** Agents directory path (auto-created if needed) */
10
+ function getAgentsDir() {
11
+ const dir = path.join(DATA_ROOT, 'agents');
12
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
13
+ return dir;
14
+ }
15
+
16
+ module.exports = {
17
+ PROJECT_ROOT,
18
+ DATA_ROOT,
19
+ getAgentsDir,
20
+ };
@@ -0,0 +1,189 @@
1
+ // Soul MCP v4.0 — Soul engine: Board, Ledger, and File Index management.
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { readJson, writeJson, nowISO, logError } = require('./utils');
5
+
6
+ class SoulEngine {
7
+ constructor(dataDir) {
8
+ this.dataDir = dataDir;
9
+ }
10
+
11
+ // -- Path helpers --
12
+
13
+ projectDir(projectName) {
14
+ return path.join(this.dataDir, 'projects', projectName);
15
+ }
16
+
17
+ boardPath(projectName) {
18
+ return path.join(this.projectDir(projectName), 'soul-board.json');
19
+ }
20
+
21
+ fileIndexPath(projectName) {
22
+ return path.join(this.projectDir(projectName), 'file-index.json');
23
+ }
24
+
25
+ ledgerDir(projectName, date) {
26
+ const [y, m, d] = (date || nowISO().split('T')[0]).split('-');
27
+ return path.join(this.projectDir(projectName), 'ledger', y, m, d);
28
+ }
29
+
30
+ // -- Soul Board --
31
+
32
+ readBoard(projectName) {
33
+ return readJson(this.boardPath(projectName)) || this._defaultBoard(projectName);
34
+ }
35
+
36
+ writeBoard(projectName, board) {
37
+ board.updatedAt = nowISO();
38
+ writeJson(this.boardPath(projectName), board);
39
+ }
40
+
41
+ _defaultBoard(projectName) {
42
+ return {
43
+ project: projectName,
44
+ updatedAt: nowISO(),
45
+ updatedBy: null,
46
+ state: { summary: '', version: '', health: 'unknown' },
47
+ activeWork: {},
48
+ fileOwnership: {},
49
+ decisions: [],
50
+ handoff: { from: null, summary: '', todo: [], blockers: [] },
51
+ lastLedger: null,
52
+ };
53
+ }
54
+
55
+ // -- File Ownership --
56
+
57
+ claimFile(projectName, filePath, agent, intent) {
58
+ const board = this.readBoard(projectName);
59
+ const existing = board.fileOwnership[filePath];
60
+ if (existing && existing.owner && existing.owner !== agent) {
61
+ return { ok: false, owner: existing.owner, intent: existing.intent };
62
+ }
63
+ board.fileOwnership[filePath] = { owner: agent, since: nowISO(), intent };
64
+ board.updatedBy = agent;
65
+ this.writeBoard(projectName, board);
66
+ return { ok: true };
67
+ }
68
+
69
+ releaseFiles(projectName, agent) {
70
+ const board = this.readBoard(projectName);
71
+ for (const [fp, info] of Object.entries(board.fileOwnership)) {
72
+ if (info.owner === agent) {
73
+ board.fileOwnership[fp] = { owner: null };
74
+ }
75
+ }
76
+ board.updatedBy = agent;
77
+ this.writeBoard(projectName, board);
78
+ }
79
+
80
+ // -- Active Work --
81
+
82
+ setActiveWork(projectName, agent, task, files) {
83
+ const board = this.readBoard(projectName);
84
+ board.activeWork[agent] = { task, since: nowISO(), files: files || [] };
85
+ board.updatedBy = agent;
86
+ this.writeBoard(projectName, board);
87
+ }
88
+
89
+ clearActiveWork(projectName, agent) {
90
+ const board = this.readBoard(projectName);
91
+ board.activeWork[agent] = null;
92
+ board.updatedBy = agent;
93
+ this.writeBoard(projectName, board);
94
+ }
95
+
96
+ // -- Ledger --
97
+
98
+ getNextLedgerId(projectName, date) {
99
+ const dir = this.ledgerDir(projectName, date);
100
+ if (!fs.existsSync(dir)) return '001';
101
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
102
+ const nums = files.map(f => parseInt(f.split('-')[0]) || 0);
103
+ const max = nums.length > 0 ? Math.max(...nums) : 0;
104
+ return String(max + 1).padStart(3, '0');
105
+ }
106
+
107
+ writeLedger(projectName, agent, entry) {
108
+ const date = nowISO().split('T')[0];
109
+ const id = this.getNextLedgerId(projectName, date);
110
+ const ledgerEntry = {
111
+ id,
112
+ agent,
113
+ startedAt: entry.startedAt || nowISO(),
114
+ completedAt: nowISO(),
115
+ title: entry.title || 'Untitled work',
116
+ filesCreated: entry.filesCreated || [],
117
+ filesModified: entry.filesModified || [],
118
+ filesDeleted: entry.filesDeleted || [],
119
+ decisions: entry.decisions || [],
120
+ summary: entry.summary || '',
121
+ };
122
+
123
+ const dir = this.ledgerDir(projectName, date);
124
+ const fileName = `${id}-${agent.toLowerCase().replace(/[^a-z0-9]/g, '')}.json`;
125
+ writeJson(path.join(dir, fileName), ledgerEntry);
126
+
127
+ // Update board's lastLedger reference
128
+ const [y, m, d] = date.split('-');
129
+ const board = this.readBoard(projectName);
130
+ board.lastLedger = `${y}/${m}/${d}/${id}-${agent}`;
131
+ board.updatedBy = agent;
132
+ this.writeBoard(projectName, board);
133
+
134
+ return { id, path: path.join(dir, fileName) };
135
+ }
136
+
137
+ // -- File Index --
138
+
139
+ readFileIndex(projectName) {
140
+ return readJson(this.fileIndexPath(projectName)) || { updatedAt: nowISO(), tree: {}, directories: {} };
141
+ }
142
+
143
+ writeFileIndex(projectName, index) {
144
+ index.updatedAt = nowISO();
145
+ writeJson(this.fileIndexPath(projectName), index);
146
+ }
147
+
148
+ // Auto-scan a directory and generate file-index tree
149
+ scanDirectory(rootDir, options = {}) {
150
+ const maxDepth = options.maxDepth || 5;
151
+ const excludes = options.excludes || ['node_modules', '.git', 'dist', 'out', '.next'];
152
+
153
+ function walk(dir, depth) {
154
+ if (depth > maxDepth) return {};
155
+ const result = {};
156
+ try {
157
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
158
+ for (const entry of entries) {
159
+ if (excludes.includes(entry.name)) continue;
160
+ if (entry.name.startsWith('.') && entry.name !== '.env') continue;
161
+
162
+ const fullPath = path.join(dir, entry.name);
163
+ if (entry.isDirectory()) {
164
+ const key = entry.name + '/';
165
+ result[key] = {
166
+ desc: '',
167
+ children: walk(fullPath, depth + 1),
168
+ };
169
+ } else {
170
+ const stat = fs.statSync(fullPath);
171
+ result[entry.name] = {
172
+ desc: '',
173
+ created: stat.birthtime.toISOString().split('T')[0],
174
+ modified: stat.mtime.toISOString().split('T')[0],
175
+ status: 'active',
176
+ };
177
+ }
178
+ }
179
+ } catch (e) {
180
+ logError('scanDirectory', e);
181
+ }
182
+ return result;
183
+ }
184
+
185
+ return walk(rootDir, 0);
186
+ }
187
+ }
188
+
189
+ module.exports = { SoulEngine };
package/lib/utils.js ADDED
@@ -0,0 +1,97 @@
1
+ // Soul MCP v4.0 — Shared utility functions (file I/O, time, security, logging)
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ // -- Logging --
6
+
7
+ function logError(context, err) {
8
+ const msg = err instanceof Error ? err.message : String(err);
9
+ console.error(`[soul:${context}]`, msg);
10
+ }
11
+
12
+ // -- File I/O --
13
+
14
+ function readFile(filePath) {
15
+ try {
16
+ return fs.readFileSync(filePath, 'utf8');
17
+ } catch (e) {
18
+ logError('readFile', e);
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function readJson(filePath) {
24
+ const content = readFile(filePath);
25
+ if (!content) return null;
26
+ try {
27
+ return JSON.parse(content);
28
+ } catch (e) {
29
+ logError('readJson', e);
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function writeJson(filePath, data) {
35
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
36
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
37
+ }
38
+
39
+ function writeFile(filePath, content) {
40
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
41
+ fs.writeFileSync(filePath, content, 'utf8');
42
+ }
43
+
44
+ // -- Time --
45
+
46
+ function today() {
47
+ return new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Seoul' });
48
+ }
49
+
50
+ function nowISO() {
51
+ const formatter = new Intl.DateTimeFormat('sv-SE', {
52
+ timeZone: 'Asia/Seoul',
53
+ year: 'numeric', month: '2-digit', day: '2-digit',
54
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
55
+ hour12: false,
56
+ });
57
+ const parts = formatter.formatToParts(new Date());
58
+ const get = (type) => parts.find(p => p.type === type)?.value || '00';
59
+ return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}+09:00`;
60
+ }
61
+
62
+ // -- Security --
63
+
64
+ function safePath(filePath, baseDir) {
65
+ const resolved = path.resolve(baseDir, filePath);
66
+ const normalizedBase = path.resolve(baseDir);
67
+ if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) {
68
+ logError('safePath', `Path traversal blocked: ${filePath}`);
69
+ return null;
70
+ }
71
+ return resolved;
72
+ }
73
+
74
+ // -- First-line comment validation --
75
+
76
+ function validateFirstLineComment(filePath) {
77
+ try {
78
+ const content = fs.readFileSync(filePath, 'utf8');
79
+ const firstLine = content.split('\n')[0].trim();
80
+ const patterns = [
81
+ /^\/\/\s*.+/, // JS/TS
82
+ /^#\s*.+/, // Python/Shell/YAML
83
+ /^<!--\s*.+/, // HTML/MD
84
+ /^\/\*\s*.+/, // CSS/Java
85
+ /^\{.*"_desc"/, // JSON with _desc field
86
+ ];
87
+ return patterns.some(p => p.test(firstLine));
88
+ } catch (e) {
89
+ logError('validateFirstLineComment', e);
90
+ return false;
91
+ }
92
+ }
93
+
94
+ module.exports = {
95
+ logError, readFile, readJson, writeJson, writeFile,
96
+ today, nowISO, safePath, validateFirstLineComment,
97
+ };