kiroo 0.8.0 → 0.9.5

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,100 @@
1
+ const REDACTED = '<REDACTED>';
2
+
3
+ const SENSITIVE_KEY_PATTERN =
4
+ /authorization|cookie|set-cookie|token|secret|password|passwd|pwd|api[-_]?key|x-api-key|client[-_]?secret|session|jwt|access[-_]?token|refresh[-_]?token/i;
5
+
6
+ function redactSensitiveString(value, redactedValue = REDACTED) {
7
+ if (typeof value !== 'string') {
8
+ return redactedValue;
9
+ }
10
+
11
+ if (/^bearer\s+/i.test(value)) {
12
+ return `Bearer ${redactedValue}`;
13
+ }
14
+
15
+ if (/^basic\s+/i.test(value)) {
16
+ return `Basic ${redactedValue}`;
17
+ }
18
+
19
+ return redactedValue;
20
+ }
21
+
22
+ export function isSensitiveKey(key, sensitiveKeyPattern = SENSITIVE_KEY_PATTERN) {
23
+ return sensitiveKeyPattern.test(String(key || ''));
24
+ }
25
+
26
+ export function redactSensitiveInUrl(urlValue, options = {}) {
27
+ const redactedValue = options.redactedValue || REDACTED;
28
+ const sensitiveKeyPattern = options.sensitiveKeyPattern || SENSITIVE_KEY_PATTERN;
29
+
30
+ if (typeof urlValue !== 'string' || !urlValue.includes('?')) {
31
+ return urlValue;
32
+ }
33
+
34
+ const sanitizeParams = (url) => {
35
+ for (const key of Array.from(url.searchParams.keys())) {
36
+ if (isSensitiveKey(key, sensitiveKeyPattern)) {
37
+ url.searchParams.set(key, redactedValue);
38
+ }
39
+ }
40
+ return url.toString();
41
+ };
42
+
43
+ try {
44
+ return sanitizeParams(new URL(urlValue));
45
+ } catch {
46
+ try {
47
+ const parsed = new URL(urlValue, 'http://kiroo.local');
48
+ sanitizeParams(parsed);
49
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
50
+ } catch {
51
+ return urlValue;
52
+ }
53
+ }
54
+ }
55
+
56
+ function sanitizeValue(value, currentKey = '', options = {}) {
57
+ const redactedValue = options.redactedValue || REDACTED;
58
+ const sensitiveKeyPattern = options.sensitiveKeyPattern || SENSITIVE_KEY_PATTERN;
59
+
60
+ if (isSensitiveKey(currentKey, sensitiveKeyPattern)) {
61
+ return redactSensitiveString(value, redactedValue);
62
+ }
63
+
64
+ if (typeof value === 'string') {
65
+ if (currentKey.toLowerCase() === 'url') {
66
+ return redactSensitiveInUrl(value, options);
67
+ }
68
+ return value;
69
+ }
70
+
71
+ if (Array.isArray(value)) {
72
+ return value.map((item) => sanitizeValue(item, currentKey, options));
73
+ }
74
+
75
+ if (value && typeof value === 'object') {
76
+ const out = {};
77
+ for (const [key, nestedValue] of Object.entries(value)) {
78
+ out[key] = sanitizeValue(nestedValue, key, options);
79
+ }
80
+ return out;
81
+ }
82
+
83
+ return value;
84
+ }
85
+
86
+ export function sanitizeInteractionRecord(record, options = {}) {
87
+ const mergedOptions = {
88
+ enabled: options.enabled !== false,
89
+ redactedValue: options.redactedValue || REDACTED,
90
+ sensitiveKeyPattern: options.sensitiveKeyPattern || SENSITIVE_KEY_PATTERN
91
+ };
92
+
93
+ if (!mergedOptions.enabled) {
94
+ return record;
95
+ }
96
+
97
+ return sanitizeValue(record, '', mergedOptions);
98
+ }
99
+
100
+ export { REDACTED };
package/src/snapshot.js CHANGED
@@ -60,7 +60,7 @@ export async function compareSnapshots(tag1, tag2, lang) {
60
60
  console.log(chalk.magenta(` 🌍 Translating output to: ${chalk.white(lang.toUpperCase())} using Lingo.dev...`));
61
61
  }
62
62
 
63
- const results = [];
63
+ const resultMap = new Map();
64
64
  let breakingChanges = 0;
65
65
 
66
66
  // Helper to get path from URL string
@@ -74,27 +74,66 @@ export async function compareSnapshots(tag1, tag2, lang) {
74
74
  }
75
75
  };
76
76
 
77
+ const compareByMethodAndPath = (a, b) => {
78
+ const pathA = getPath(a.url || '');
79
+ const pathB = getPath(b.url || '');
80
+ const methodA = String(a.method || '').toUpperCase();
81
+ const methodB = String(b.method || '').toUpperCase();
82
+ if (methodA !== methodB) return methodA.localeCompare(methodB);
83
+ return pathA.localeCompare(pathB);
84
+ };
85
+
86
+ const s1Interactions = [...s1.interactions].sort(compareByMethodAndPath);
87
+ const s2Interactions = [...s2.interactions].sort(compareByMethodAndPath);
88
+ const consumedS1Indexes = new Set();
89
+
90
+ const isBreakingStatusChange = (beforeStatus, afterStatus) => {
91
+ const before2xx = beforeStatus >= 200 && beforeStatus < 300;
92
+ const after3xx = afterStatus >= 300 && afterStatus < 400;
93
+ const after4xx5xx = afterStatus >= 400;
94
+
95
+ if (before2xx && afterStatus === 304) return false;
96
+ if (before2xx && after3xx) return false;
97
+ if (before2xx && after4xx5xx) return true;
98
+ return beforeStatus !== afterStatus;
99
+ };
100
+
101
+ const addResult = (type, method, url, msg) => {
102
+ const cleanMethod = String(method || '').toUpperCase();
103
+ const key = `${type}|${cleanMethod}|${url}`;
104
+ if (!resultMap.has(key)) {
105
+ resultMap.set(key, { type, method: cleanMethod, url, messages: [], occurrences: 0 });
106
+ }
107
+ const row = resultMap.get(key);
108
+ row.occurrences += 1;
109
+ if (msg && !row.messages.includes(msg)) {
110
+ row.messages.push(msg);
111
+ }
112
+ };
113
+
77
114
  // Domain-agnostic comparison: match by Path and Method
78
- s2.interactions.forEach(int2 => {
115
+ s2Interactions.forEach(int2 => {
79
116
  const path2 = getPath(int2.url);
80
- const int1 = s1.interactions.find(i => getPath(i.url) === path2 && i.method === int2.method);
117
+ const candidates = s1Interactions
118
+ .map((item, index) => ({ item, index }))
119
+ .filter(({ item, index }) => !consumedS1Indexes.has(index) && getPath(item.url) === path2 && item.method === int2.method);
120
+ const match = candidates.find(({ item }) => item.id && int2.id && item.id === int2.id) || candidates[0];
121
+ const int1 = match?.item;
81
122
 
82
123
  if (!int1) {
83
- results.push({
84
- type: 'NEW',
85
- method: int2.method,
86
- url: path2,
87
- msg: chalk.blue('New interaction added')
88
- });
124
+ addResult('NEW', int2.method, path2, 'New interaction added');
89
125
  return;
90
126
  }
127
+ consumedS1Indexes.add(match.index);
91
128
 
92
129
  const diffs = [];
93
130
 
94
131
  // Compare status
95
132
  if (int1.response.status !== int2.response.status) {
96
133
  diffs.push(`Status: ${chalk.gray(int1.response.status)} → ${chalk.red(int2.response.status)}`);
97
- breakingChanges++;
134
+ if (isBreakingStatusChange(int1.response.status, int2.response.status)) {
135
+ breakingChanges++;
136
+ }
98
137
  }
99
138
 
100
139
  // Helper for deep structural comparison
@@ -154,15 +193,19 @@ export async function compareSnapshots(tag1, tag2, lang) {
154
193
  }
155
194
  }
156
195
  if (diffs.length > 0) {
157
- results.push({
158
- type: 'CHANGE',
159
- method: int2.method,
160
- url: path2,
161
- msg: diffs.join('\n ')
162
- });
196
+ addResult('CHANGE', int2.method, path2, diffs.join('\n '));
163
197
  }
164
198
  });
165
199
 
200
+ s1Interactions.forEach((int1, index) => {
201
+ if (consumedS1Indexes.has(index)) return;
202
+ const path1 = getPath(int1.url);
203
+ addResult('REMOVED', int1.method, path1, 'Interaction removed in target snapshot');
204
+ breakingChanges++;
205
+ });
206
+
207
+ const results = [...resultMap.values()];
208
+
166
209
  if (results.length === 0) {
167
210
  let finalMsg = 'No differences detected. Your API is stable!';
168
211
  if (lang) finalMsg = await translateText(finalMsg, lang);
@@ -170,8 +213,18 @@ export async function compareSnapshots(tag1, tag2, lang) {
170
213
  } else {
171
214
  console.log('');
172
215
 
173
- for (const res of results) {
174
- let printMsg = res.msg;
216
+ const sortedResults = [...results].sort((a, b) => {
217
+ const methodA = String(a.method || '').toUpperCase();
218
+ const methodB = String(b.method || '').toUpperCase();
219
+ if (methodA !== methodB) return methodA.localeCompare(methodB);
220
+ return String(a.url || '').localeCompare(String(b.url || ''));
221
+ });
222
+
223
+ for (const res of sortedResults) {
224
+ let printMsg = res.messages.join('\n ');
225
+ if (res.occurrences > 1 && (res.type === 'NEW' || res.type === 'REMOVED')) {
226
+ printMsg += `\n (${res.occurrences} occurrences)`;
227
+ }
175
228
  if (lang) {
176
229
  // Basic translation hook for individual diff items (stripping ansi)
177
230
  const cleanMsg = printMsg.replace(/\x1B\[[0-9;]*m/g, '');
@@ -179,7 +232,11 @@ export async function compareSnapshots(tag1, tag2, lang) {
179
232
  printMsg = chalk.yellow('[Translated] ') + translatedMsg;
180
233
  }
181
234
 
182
- const symbol = res.type === 'NEW' ? chalk.blue('+') : chalk.yellow('⚠️');
235
+ const symbol = res.type === 'NEW'
236
+ ? chalk.blue('+')
237
+ : res.type === 'REMOVED'
238
+ ? chalk.red('-')
239
+ : chalk.yellow('⚠️');
183
240
  console.log(` ${symbol} ${chalk.white(res.method)} ${chalk.gray(res.url)}`);
184
241
  console.log(` ${printMsg}`);
185
242
  }
package/src/stats.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import chalk from 'chalk';
2
2
  import Table from 'cli-table3';
3
3
  import { getAllInteractions } from './storage.js';
4
+ import { translateText } from './lingo.js';
4
5
 
5
- export function showStats() {
6
+ export async function showStats(options = {}) {
7
+ const lang = options.lang;
6
8
  const interactions = getAllInteractions();
7
9
 
8
10
  if (interactions.length === 0) {
@@ -11,7 +13,9 @@ export function showStats() {
11
13
  return;
12
14
  }
13
15
 
14
- console.log(chalk.cyan.bold('\n 📊 Kiroo Analytics Dashboard\n'));
16
+ let title = '📊 Kiroo Analytics Dashboard';
17
+ if (lang) title = await translateText(title, lang);
18
+ console.log(chalk.cyan.bold(`\n ${title}\n`));
15
19
 
16
20
  // 1. General Metrics
17
21
  const total = interactions.length;
@@ -26,7 +30,9 @@ export function showStats() {
26
30
  { [chalk.white('Avg. Duration')]: chalk.white(avgDuration + 'ms') }
27
31
  );
28
32
 
29
- console.log(chalk.white.bold(' ● General Performance'));
33
+ let generalHeader = '● General Performance';
34
+ if (lang) generalHeader = await translateText(generalHeader, lang);
35
+ console.log(chalk.white.bold(` ${generalHeader}`));
30
36
  console.log(generalTable.toString());
31
37
 
32
38
  // 2. Method Distribution
@@ -45,7 +51,9 @@ export function showStats() {
45
51
  methodTable.push([chalk.white(method), chalk.white(count), chalk.gray(percentage)]);
46
52
  });
47
53
 
48
- console.log(chalk.white.bold('\n ● Request Distribution'));
54
+ let distHeader = '● Request Distribution';
55
+ if (lang) distHeader = await translateText(distHeader, lang);
56
+ console.log(chalk.white.bold(`\n ${distHeader}`));
49
57
  console.log(methodTable.toString());
50
58
 
51
59
  // 3. Slowest Endpoints
@@ -67,7 +75,9 @@ export function showStats() {
67
75
  ]);
68
76
  });
69
77
 
70
- console.log(chalk.white.bold('\n ● Top 5 Slowest Endpoints (Bottlenecks)'));
78
+ let slowHeader = '● Top 5 Slowest Endpoints (Bottlenecks)';
79
+ if (lang) slowHeader = await translateText(slowHeader, lang);
80
+ console.log(chalk.white.bold(`\n ${slowHeader}`));
71
81
  console.log(slowTable.toString());
72
82
  console.log('');
73
83
  }
package/src/storage.js CHANGED
@@ -1,142 +1,223 @@
1
- import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'fs';
2
- import { join } from 'path';
3
-
4
- const KIROO_DIR = '.kiroo';
5
- const INTERACTIONS_DIR = join(KIROO_DIR, 'interactions');
6
- const SNAPSHOTS_DIR = join(KIROO_DIR, 'snapshots');
7
- const ENV_FILE = join(KIROO_DIR, 'env.json');
8
-
9
- export function ensureKirooDir() {
10
- if (!existsSync(KIROO_DIR)) {
11
- mkdirSync(KIROO_DIR);
12
- }
13
- if (!existsSync(INTERACTIONS_DIR)) {
14
- mkdirSync(INTERACTIONS_DIR);
15
- }
16
- if (!existsSync(SNAPSHOTS_DIR)) {
17
- mkdirSync(SNAPSHOTS_DIR);
18
- }
19
- if (!existsSync(ENV_FILE)) {
20
- writeFileSync(ENV_FILE, JSON.stringify({ current: 'default', environments: { default: {} } }, null, 2));
21
- }
22
-
23
- const GITIGNORE_FILE = join(KIROO_DIR, '.gitignore');
24
- if (!existsSync(GITIGNORE_FILE)) {
25
- writeFileSync(GITIGNORE_FILE, 'env.json\n');
26
- }
27
- }
28
-
29
- export async function saveInteraction(interaction) {
30
- ensureKirooDir();
31
-
32
- const timestamp = new Date().toISOString();
33
- const id = timestamp.replace(/[:.]/g, '-');
34
-
35
- const interactionData = {
36
- id,
37
- timestamp,
38
- request: {
39
- method: interaction.method,
40
- url: interaction.url,
41
- headers: interaction.headers,
42
- body: interaction.body,
43
- },
44
- response: interaction.response,
45
- metadata: {
46
- duration_ms: interaction.duration,
47
- saves: interaction.saves || [], // Variables saved from this response
48
- uses: interaction.uses || [], // Variables used in this request
49
- },
50
- };
51
-
52
- const filename = `${id}.json`;
53
- const filepath = join(INTERACTIONS_DIR, filename);
54
-
55
- writeFileSync(filepath, JSON.stringify(interactionData, null, 2));
56
-
57
- return id;
58
- }
59
-
60
- export function loadInteraction(id) {
61
- const filepath = join(INTERACTIONS_DIR, `${id}.json`);
62
-
63
- if (!existsSync(filepath)) {
64
- throw new Error(`Interaction not found: ${id}`);
65
- }
66
-
67
- const data = readFileSync(filepath, 'utf8');
68
- return JSON.parse(data);
69
- }
70
-
71
- export function getAllInteractions() {
72
- ensureKirooDir();
73
-
74
- if (!existsSync(INTERACTIONS_DIR)) {
75
- return [];
76
- }
77
-
78
- const files = readdirSync(INTERACTIONS_DIR)
79
- .filter(f => f.endsWith('.json'))
80
- .sort()
81
- .reverse(); // Most recent first
82
-
83
- return files.map(f => {
84
- const filepath = join(INTERACTIONS_DIR, f);
85
- const data = readFileSync(filepath, 'utf8');
86
- return JSON.parse(data);
87
- });
88
- }
89
-
90
- export function saveSnapshotData(tag, data) {
91
- ensureKirooDir();
92
-
93
- const filename = `${tag}.json`;
94
- const filepath = join(SNAPSHOTS_DIR, filename);
95
-
96
- writeFileSync(filepath, JSON.stringify(data, null, 2));
97
- }
98
-
99
- export function loadSnapshotData(tag) {
100
- const filepath = join(SNAPSHOTS_DIR, `${tag}.json`);
101
-
102
- if (!existsSync(filepath)) {
103
- throw new Error(`Snapshot not found: ${tag}`);
104
- }
105
-
106
- const data = readFileSync(filepath, 'utf8');
107
- return JSON.parse(data);
108
- }
109
-
110
- export function getAllSnapshots() {
111
- ensureKirooDir();
112
-
113
- if (!existsSync(SNAPSHOTS_DIR)) {
114
- return [];
115
- }
116
-
117
- return readdirSync(SNAPSHOTS_DIR)
118
- .filter(f => f.endsWith('.json'))
119
- .map(f => f.replace('.json', ''));
120
- }
121
-
122
- export function clearAllInteractions() {
123
- ensureKirooDir();
124
- if (existsSync(INTERACTIONS_DIR)) {
125
- const files = readdirSync(INTERACTIONS_DIR);
126
- files.forEach(f => {
127
- const filepath = join(INTERACTIONS_DIR, f);
128
- rmSync(filepath, { force: true });
129
- });
130
- }
131
- }
132
-
133
- export function loadEnv() {
134
- ensureKirooDir();
135
- const data = readFileSync(ENV_FILE, 'utf8');
136
- return JSON.parse(data);
137
- }
138
-
139
- export function saveEnv(data) {
140
- ensureKirooDir();
141
- writeFileSync(ENV_FILE, JSON.stringify(data, null, 2));
142
- }
1
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { sanitizeInteractionRecord } from './sanitizer.js';
4
+ import { loadKirooConfig } from './config.js';
5
+ import { stableJSONStringify } from './deterministic.js';
6
+
7
+ const KIROO_DIR = '.kiroo';
8
+ const INTERACTIONS_DIR = join(KIROO_DIR, 'interactions');
9
+ const SNAPSHOTS_DIR = join(KIROO_DIR, 'snapshots');
10
+ const ENV_FILE = join(KIROO_DIR, 'env.json');
11
+
12
+ function getPersistenceSettings() {
13
+ const config = loadKirooConfig();
14
+ const redaction = config.settings?.redaction || {};
15
+ const determinism = config.settings?.determinism || {};
16
+
17
+ return {
18
+ redactEnabled: redaction.enabled !== false,
19
+ redactedValue: redaction.redactedValue || '<REDACTED>',
20
+ sortKeys: determinism.sortKeys !== false
21
+ };
22
+ }
23
+
24
+ function stringifyForPersistence(value, sortKeysEnabled) {
25
+ if (sortKeysEnabled) {
26
+ return stableJSONStringify(value, 2);
27
+ }
28
+ return JSON.stringify(value, null, 2);
29
+ }
30
+
31
+ export function ensureKirooDir() {
32
+ if (!existsSync(KIROO_DIR)) {
33
+ mkdirSync(KIROO_DIR);
34
+ }
35
+ if (!existsSync(INTERACTIONS_DIR)) {
36
+ mkdirSync(INTERACTIONS_DIR);
37
+ }
38
+ if (!existsSync(SNAPSHOTS_DIR)) {
39
+ mkdirSync(SNAPSHOTS_DIR);
40
+ }
41
+ if (!existsSync(ENV_FILE)) {
42
+ writeFileSync(ENV_FILE, JSON.stringify({ current: 'default', environments: { default: {} } }, null, 2));
43
+ }
44
+
45
+ const GITIGNORE_FILE = join(KIROO_DIR, '.gitignore');
46
+ if (!existsSync(GITIGNORE_FILE)) {
47
+ writeFileSync(GITIGNORE_FILE, 'env.json\n');
48
+ }
49
+ }
50
+
51
+ export async function saveInteraction(interaction) {
52
+ ensureKirooDir();
53
+ const settings = getPersistenceSettings();
54
+
55
+ const timestamp = new Date().toISOString();
56
+ const id = timestamp.replace(/[:.]/g, '-');
57
+
58
+ const interactionData = {
59
+ id,
60
+ timestamp,
61
+ request: {
62
+ method: interaction.method,
63
+ url: interaction.url,
64
+ headers: interaction.headers,
65
+ body: interaction.body,
66
+ },
67
+ response: interaction.response,
68
+ metadata: {
69
+ duration_ms: interaction.duration,
70
+ saves: interaction.saves || [], // Variables saved from this response
71
+ uses: interaction.uses || [], // Variables used in this request
72
+ },
73
+ };
74
+
75
+ const sanitizedInteractionData = sanitizeInteractionRecord(interactionData, {
76
+ enabled: settings.redactEnabled,
77
+ redactedValue: settings.redactedValue
78
+ });
79
+
80
+ const filename = `${id}.json`;
81
+ const filepath = join(INTERACTIONS_DIR, filename);
82
+
83
+ writeFileSync(filepath, stringifyForPersistence(sanitizedInteractionData, settings.sortKeys));
84
+
85
+ return id;
86
+ }
87
+
88
+ export function loadInteraction(id) {
89
+ const filepath = join(INTERACTIONS_DIR, `${id}.json`);
90
+
91
+ if (!existsSync(filepath)) {
92
+ throw new Error(`Interaction not found: ${id}`);
93
+ }
94
+
95
+ const data = readFileSync(filepath, 'utf8');
96
+ return JSON.parse(data);
97
+ }
98
+
99
+ export function getAllInteractions() {
100
+ ensureKirooDir();
101
+
102
+ if (!existsSync(INTERACTIONS_DIR)) {
103
+ return [];
104
+ }
105
+
106
+ const files = readdirSync(INTERACTIONS_DIR)
107
+ .filter(f => f.endsWith('.json'))
108
+ .sort()
109
+ .reverse(); // Most recent first
110
+
111
+ return files.map(f => {
112
+ const filepath = join(INTERACTIONS_DIR, f);
113
+ const data = readFileSync(filepath, 'utf8');
114
+ return JSON.parse(data);
115
+ });
116
+ }
117
+
118
+ export function saveSnapshotData(tag, data) {
119
+ ensureKirooDir();
120
+ const settings = getPersistenceSettings();
121
+
122
+ const filename = `${tag}.json`;
123
+ const filepath = join(SNAPSHOTS_DIR, filename);
124
+ const sanitizedSnapshot = sanitizeInteractionRecord(data, {
125
+ enabled: settings.redactEnabled,
126
+ redactedValue: settings.redactedValue
127
+ });
128
+
129
+ writeFileSync(filepath, stringifyForPersistence(sanitizedSnapshot, settings.sortKeys));
130
+ }
131
+
132
+ export function loadSnapshotData(tag) {
133
+ const filepath = join(SNAPSHOTS_DIR, `${tag}.json`);
134
+
135
+ if (!existsSync(filepath)) {
136
+ throw new Error(`Snapshot not found: ${tag}`);
137
+ }
138
+
139
+ const data = readFileSync(filepath, 'utf8');
140
+ return JSON.parse(data);
141
+ }
142
+
143
+ export function getAllSnapshots() {
144
+ ensureKirooDir();
145
+
146
+ if (!existsSync(SNAPSHOTS_DIR)) {
147
+ return [];
148
+ }
149
+
150
+ return readdirSync(SNAPSHOTS_DIR)
151
+ .filter(f => f.endsWith('.json'))
152
+ .sort((a, b) => a.localeCompare(b))
153
+ .map(f => f.replace('.json', ''));
154
+ }
155
+
156
+ export function clearAllInteractions() {
157
+ ensureKirooDir();
158
+ if (existsSync(INTERACTIONS_DIR)) {
159
+ const files = readdirSync(INTERACTIONS_DIR);
160
+ files.forEach(f => {
161
+ const filepath = join(INTERACTIONS_DIR, f);
162
+ rmSync(filepath, { force: true });
163
+ });
164
+ }
165
+ }
166
+
167
+ function scrubDirectory(directoryPath, { dryRun = false } = {}) {
168
+ const settings = getPersistenceSettings();
169
+
170
+ if (!existsSync(directoryPath)) {
171
+ return { scanned: 0, updated: 0 };
172
+ }
173
+
174
+ const files = readdirSync(directoryPath).filter((f) => f.endsWith('.json'));
175
+ let updated = 0;
176
+
177
+ for (const fileName of files) {
178
+ const filePath = join(directoryPath, fileName);
179
+ const originalRaw = readFileSync(filePath, 'utf8');
180
+ const originalData = JSON.parse(originalRaw);
181
+ const sanitizedData = sanitizeInteractionRecord(originalData, {
182
+ enabled: settings.redactEnabled,
183
+ redactedValue: settings.redactedValue
184
+ });
185
+ const sanitizedRaw = stringifyForPersistence(sanitizedData, settings.sortKeys);
186
+
187
+ if (originalRaw !== sanitizedRaw) {
188
+ updated += 1;
189
+ if (!dryRun) {
190
+ writeFileSync(filePath, sanitizedRaw);
191
+ }
192
+ }
193
+ }
194
+
195
+ return { scanned: files.length, updated };
196
+ }
197
+
198
+ export function scrubStoredData(options = {}) {
199
+ ensureKirooDir();
200
+ const { dryRun = false } = options;
201
+
202
+ const interactions = scrubDirectory(INTERACTIONS_DIR, { dryRun });
203
+ const snapshots = scrubDirectory(SNAPSHOTS_DIR, { dryRun });
204
+
205
+ return {
206
+ interactions,
207
+ snapshots,
208
+ totalUpdated: interactions.updated + snapshots.updated,
209
+ dryRun,
210
+ };
211
+ }
212
+
213
+ export function loadEnv() {
214
+ ensureKirooDir();
215
+ const data = readFileSync(ENV_FILE, 'utf8');
216
+ return JSON.parse(data);
217
+ }
218
+
219
+ export function saveEnv(data) {
220
+ ensureKirooDir();
221
+ const settings = getPersistenceSettings();
222
+ writeFileSync(ENV_FILE, stringifyForPersistence(data, settings.sortKeys));
223
+ }