getprismo 0.1.22 → 0.1.23

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/README.md CHANGED
@@ -864,8 +864,11 @@ lib/prismo-dev/fixes.js safe ignore/template generation
864
864
  lib/prismo-dev/mcp.js local MCP server and Prismo tool bindings
865
865
  lib/prismo-dev/report.js terminal, markdown, ci reports
866
866
  lib/prismo-dev/scan.js repo scanning, scoring, readiness
867
+ lib/prismo-dev/scan-path-utils.js scan ignore/path helper logic
867
868
  lib/prismo-dev/shield.js local command shield and searchable output index
869
+ lib/prismo-dev/usage-log-utils.js local session log parsing helpers
868
870
  lib/prismo-dev/usage-watch.js local logs, watch, cost, timeline
871
+ lib/prismo-dev/utils.js shared terminal/file/token helpers
869
872
  ```
870
873
 
871
874
  ---
@@ -0,0 +1,203 @@
1
+ module.exports = function createScanPathUtils(deps) {
2
+ const { fs, path } = deps;
3
+
4
+ function normalizeRel(value) {
5
+ return value.split(path.sep).join("/");
6
+ }
7
+
8
+ function readIgnoreFile(root, fileName) {
9
+ const filePath = path.join(root, fileName);
10
+ if (!fs.existsSync(filePath)) return [];
11
+ const text = fs.readFileSync(filePath, "utf8");
12
+ return text
13
+ .split(/\r?\n/)
14
+ .map((line) => line.trim())
15
+ .filter((line) => line && !line.startsWith("#"))
16
+ .map((line) => line.replace(/^!/, ""));
17
+ }
18
+
19
+ function patternMatches(pattern, relPath, isDir = false) {
20
+ const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
21
+ const normalizedRel = normalizeRel(relPath);
22
+ const dirRel = isDir && !normalizedRel.endsWith("/") ? `${normalizedRel}/` : normalizedRel;
23
+
24
+ if (!normalizedPattern) return false;
25
+ if (normalizedPattern.endsWith("/")) {
26
+ const base = normalizedPattern.slice(0, -1);
27
+ return (
28
+ normalizedRel === base ||
29
+ normalizedRel.startsWith(`${base}/`) ||
30
+ normalizedRel.endsWith(`/${base}`) ||
31
+ normalizedRel.includes(`/${base}/`) ||
32
+ dirRel.includes(`/${base}/`)
33
+ );
34
+ }
35
+ if (normalizedPattern.startsWith("*.")) {
36
+ return normalizedRel.endsWith(normalizedPattern.slice(1));
37
+ }
38
+ if (normalizedPattern.includes("*")) {
39
+ const escaped = normalizedPattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
40
+ return new RegExp(`(^|/)${escaped}$`).test(normalizedRel);
41
+ }
42
+ return (
43
+ normalizedRel === normalizedPattern ||
44
+ dirRel === normalizedPattern ||
45
+ normalizedRel.startsWith(`${normalizedPattern}/`) ||
46
+ normalizedRel.endsWith(`/${normalizedPattern}`)
47
+ );
48
+ }
49
+
50
+ function isIgnored(relPath, patterns, isDir = false) {
51
+ return patterns.some((pattern) => patternMatches(pattern, relPath, isDir));
52
+ }
53
+
54
+ function ignoreSuggestionCovered(pattern, existingPatterns) {
55
+ if (!pattern) return true;
56
+ if (existingPatterns.includes(pattern)) return true;
57
+ const sample = pattern
58
+ .replace(/^\*\//, "")
59
+ .replace(/^\*\*/, "sample")
60
+ .replace(/\*/g, "sample")
61
+ .replace(/\/$/, "");
62
+ const isDir = pattern.endsWith("/") || pattern.endsWith("/**");
63
+ return existingPatterns.some((existing) => {
64
+ if (existing === pattern) return true;
65
+ if (existing.endsWith("/") && pattern.startsWith(existing)) return true;
66
+ return patternMatches(existing, sample, isDir);
67
+ });
68
+ }
69
+
70
+ function missingIgnoreSuggestions(recommended, existingPatterns) {
71
+ return recommended.filter((pattern) => !ignoreSuggestionCovered(pattern, existingPatterns));
72
+ }
73
+
74
+ const SESSION_NOISE_DIRS = new Set([
75
+ ".next",
76
+ ".nuxt",
77
+ ".prismo",
78
+ ".pytest_cache",
79
+ ".turbo",
80
+ "__pycache__",
81
+ "build",
82
+ "calendar-dumps",
83
+ "coverage",
84
+ "dist",
85
+ "event-dumps",
86
+ "events",
87
+ "exports",
88
+ "htmlcov",
89
+ "inbox-dumps",
90
+ "logs",
91
+ "models",
92
+ "node_modules",
93
+ "out",
94
+ "playwright-report",
95
+ "session-dumps",
96
+ "source-streams",
97
+ "state-backups",
98
+ "test-results",
99
+ "tmp",
100
+ "temp",
101
+ ]);
102
+
103
+ const SESSION_NOISE_FILE_NAMES = new Set([
104
+ "package-lock.json",
105
+ "pnpm-lock.yaml",
106
+ "yarn.lock",
107
+ "bun.lockb",
108
+ "coverage-final.json",
109
+ "lcov.info",
110
+ ]);
111
+
112
+ const SESSION_NOISE_EXTENSIONS = new Set([
113
+ ".db",
114
+ ".jsonl",
115
+ ".lock",
116
+ ".log",
117
+ ".sqlite",
118
+ ".sqlite3",
119
+ ]);
120
+
121
+ function cleanSessionPath(value) {
122
+ const text = String(value || "").trim().replace(/\\/g, "/");
123
+ if (!text || /^https?:\/\//.test(text)) return null;
124
+ const withoutQuotes = text.replace(/^["'`]+|["'`.,:;)\]]+$/g, "");
125
+ if (!withoutQuotes || withoutQuotes.includes("\n")) return null;
126
+ const markerIndex = withoutQuotes.indexOf("/Users/");
127
+ if (markerIndex > 0) return withoutQuotes.slice(markerIndex);
128
+ return withoutQuotes;
129
+ }
130
+
131
+ function sessionIgnorePatternForPath(value, root) {
132
+ const cleaned = cleanSessionPath(value);
133
+ if (!cleaned) return null;
134
+ const rootNormalized = normalizeRel(root);
135
+ let rel = cleaned;
136
+ if (path.isAbsolute(cleaned)) {
137
+ const normalized = normalizeRel(cleaned);
138
+ if (!normalized.startsWith(`${rootNormalized}/`)) return null;
139
+ rel = normalizeRel(path.relative(root, cleaned));
140
+ }
141
+ rel = normalizeRel(rel).replace(/^\.\//, "");
142
+ if (!rel || rel === "." || rel.startsWith("../") || rel.includes("..")) return null;
143
+
144
+ const segments = rel.split("/").filter(Boolean);
145
+ if (!segments.length) return null;
146
+ for (let index = 0; index < segments.length; index += 1) {
147
+ const segment = segments[index];
148
+ if (SESSION_NOISE_DIRS.has(segment)) {
149
+ return `${segments.slice(0, index + 1).join("/")}/`;
150
+ }
151
+ }
152
+
153
+ const fileName = segments[segments.length - 1];
154
+ const lowerName = fileName.toLowerCase();
155
+ const ext = path.extname(lowerName);
156
+ if (SESSION_NOISE_FILE_NAMES.has(lowerName)) return fileName;
157
+ if (SESSION_NOISE_EXTENSIONS.has(ext)) return rel;
158
+ if (/_state\.json$/i.test(fileName)) return "*_state.json";
159
+ if (/_tokens\.json$/i.test(fileName)) return "*_tokens.json";
160
+ if (/_export\.json$/i.test(fileName)) return "*_export.json";
161
+ if (/secret|credential|token/i.test(fileName) && /\.json$/i.test(fileName)) return rel;
162
+ return null;
163
+ }
164
+
165
+ function buildSessionIgnoreSuggestions(realUsage, root) {
166
+ if (!realUsage || !Array.isArray(realUsage.sessions)) return [];
167
+ const byPattern = new Map();
168
+ const add = (pattern, item, source, reason) => {
169
+ if (!pattern) return;
170
+ const existing = byPattern.get(pattern) || {
171
+ pattern,
172
+ source,
173
+ reason,
174
+ count: 0,
175
+ examples: [],
176
+ };
177
+ existing.count += Number(item?.count || 1);
178
+ const example = item?.value || item?.path || pattern;
179
+ if (example && !existing.examples.includes(example) && existing.examples.length < 3) existing.examples.push(example);
180
+ byPattern.set(pattern, existing);
181
+ };
182
+
183
+ for (const session of realUsage.sessions) {
184
+ for (const item of session.generatedArtifacts || []) {
185
+ add(sessionIgnorePatternForPath(item.value, root), item, session.tool || "session", "Generated artifact entered local session context.");
186
+ }
187
+ for (const item of session.repeatedPathMentions || []) {
188
+ add(sessionIgnorePatternForPath(item.value, root), item, session.tool || "session", "Noisy path appeared repeatedly in local session context.");
189
+ }
190
+ }
191
+ return Array.from(byPattern.values())
192
+ .sort((a, b) => b.count - a.count || a.pattern.localeCompare(b.pattern))
193
+ .slice(0, 25);
194
+ }
195
+
196
+ return {
197
+ buildSessionIgnoreSuggestions,
198
+ isIgnored,
199
+ missingIgnoreSuggestions,
200
+ normalizeRel,
201
+ readIgnoreFile,
202
+ };
203
+ };
@@ -27,197 +27,13 @@ const ASSUMED_TURNS_PER_AI_SESSION = 40;
27
27
  const ASSUMED_INPUT_COST_PER_1K_TOKENS = 0.003;
28
28
  const ASSUMED_SESSIONS_PER_MONTH = 30;
29
29
 
30
- function normalizeRel(value) {
31
- return value.split(path.sep).join("/");
32
- }
33
-
34
- function readIgnoreFile(root, fileName) {
35
- const filePath = path.join(root, fileName);
36
- if (!fs.existsSync(filePath)) return [];
37
- const text = fs.readFileSync(filePath, "utf8");
38
- return text
39
- .split(/\r?\n/)
40
- .map((line) => line.trim())
41
- .filter((line) => line && !line.startsWith("#"))
42
- .map((line) => line.replace(/^!/, ""));
43
- }
44
-
45
- function patternMatches(pattern, relPath, isDir = false) {
46
- const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
47
- const normalizedRel = normalizeRel(relPath);
48
- const dirRel = isDir && !normalizedRel.endsWith("/") ? `${normalizedRel}/` : normalizedRel;
49
-
50
- if (!normalizedPattern) return false;
51
- if (normalizedPattern.endsWith("/")) {
52
- const base = normalizedPattern.slice(0, -1);
53
- return (
54
- normalizedRel === base ||
55
- normalizedRel.startsWith(`${base}/`) ||
56
- normalizedRel.endsWith(`/${base}`) ||
57
- normalizedRel.includes(`/${base}/`) ||
58
- dirRel.includes(`/${base}/`)
59
- );
60
- }
61
- if (normalizedPattern.startsWith("*.")) {
62
- return normalizedRel.endsWith(normalizedPattern.slice(1));
63
- }
64
- if (normalizedPattern.includes("*")) {
65
- const escaped = normalizedPattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
66
- return new RegExp(`(^|/)${escaped}$`).test(normalizedRel);
67
- }
68
- return (
69
- normalizedRel === normalizedPattern ||
70
- dirRel === normalizedPattern ||
71
- normalizedRel.startsWith(`${normalizedPattern}/`) ||
72
- normalizedRel.endsWith(`/${normalizedPattern}`)
73
- );
74
- }
75
-
76
- function isIgnored(relPath, patterns, isDir = false) {
77
- return patterns.some((pattern) => patternMatches(pattern, relPath, isDir));
78
- }
79
-
80
- function ignoreSuggestionCovered(pattern, existingPatterns) {
81
- if (!pattern) return true;
82
- if (existingPatterns.includes(pattern)) return true;
83
- const sample = pattern
84
- .replace(/^\*\//, "")
85
- .replace(/^\*\*/, "sample")
86
- .replace(/\*/g, "sample")
87
- .replace(/\/$/, "");
88
- const isDir = pattern.endsWith("/") || pattern.endsWith("/**");
89
- return existingPatterns.some((existing) => {
90
- if (existing === pattern) return true;
91
- if (existing.endsWith("/") && pattern.startsWith(existing)) return true;
92
- return patternMatches(existing, sample, isDir);
93
- });
94
- }
95
-
96
- function missingIgnoreSuggestions(recommended, existingPatterns) {
97
- return recommended.filter((pattern) => !ignoreSuggestionCovered(pattern, existingPatterns));
98
- }
99
-
100
- const SESSION_NOISE_DIRS = new Set([
101
- ".next",
102
- ".nuxt",
103
- ".prismo",
104
- ".pytest_cache",
105
- ".turbo",
106
- "__pycache__",
107
- "build",
108
- "calendar-dumps",
109
- "coverage",
110
- "dist",
111
- "event-dumps",
112
- "events",
113
- "exports",
114
- "htmlcov",
115
- "inbox-dumps",
116
- "logs",
117
- "models",
118
- "node_modules",
119
- "out",
120
- "playwright-report",
121
- "session-dumps",
122
- "source-streams",
123
- "state-backups",
124
- "test-results",
125
- "tmp",
126
- "temp",
127
- ]);
128
-
129
- const SESSION_NOISE_FILE_NAMES = new Set([
130
- "package-lock.json",
131
- "pnpm-lock.yaml",
132
- "yarn.lock",
133
- "bun.lockb",
134
- "coverage-final.json",
135
- "lcov.info",
136
- ]);
137
-
138
- const SESSION_NOISE_EXTENSIONS = new Set([
139
- ".db",
140
- ".jsonl",
141
- ".lock",
142
- ".log",
143
- ".sqlite",
144
- ".sqlite3",
145
- ]);
146
-
147
- function cleanSessionPath(value) {
148
- const text = String(value || "").trim().replace(/\\/g, "/");
149
- if (!text || /^https?:\/\//.test(text)) return null;
150
- const withoutQuotes = text.replace(/^["'`]+|["'`.,:;)\]]+$/g, "");
151
- if (!withoutQuotes || withoutQuotes.includes("\n")) return null;
152
- const markerIndex = withoutQuotes.indexOf("/Users/");
153
- if (markerIndex > 0) return withoutQuotes.slice(markerIndex);
154
- return withoutQuotes;
155
- }
156
-
157
- function sessionIgnorePatternForPath(value, root) {
158
- const cleaned = cleanSessionPath(value);
159
- if (!cleaned) return null;
160
- const rootNormalized = normalizeRel(root);
161
- let rel = cleaned;
162
- if (path.isAbsolute(cleaned)) {
163
- const normalized = normalizeRel(cleaned);
164
- if (!normalized.startsWith(`${rootNormalized}/`)) return null;
165
- rel = normalizeRel(path.relative(root, cleaned));
166
- }
167
- rel = normalizeRel(rel).replace(/^\.\//, "");
168
- if (!rel || rel === "." || rel.startsWith("../") || rel.includes("..")) return null;
169
-
170
- const segments = rel.split("/").filter(Boolean);
171
- if (!segments.length) return null;
172
- for (let index = 0; index < segments.length; index += 1) {
173
- const segment = segments[index];
174
- if (SESSION_NOISE_DIRS.has(segment)) {
175
- return `${segments.slice(0, index + 1).join("/")}/`;
176
- }
177
- }
178
-
179
- const fileName = segments[segments.length - 1];
180
- const lowerName = fileName.toLowerCase();
181
- const ext = path.extname(lowerName);
182
- if (SESSION_NOISE_FILE_NAMES.has(lowerName)) return fileName;
183
- if (SESSION_NOISE_EXTENSIONS.has(ext)) return rel;
184
- if (/_state\.json$/i.test(fileName)) return "*_state.json";
185
- if (/_tokens\.json$/i.test(fileName)) return "*_tokens.json";
186
- if (/_export\.json$/i.test(fileName)) return "*_export.json";
187
- if (/secret|credential|token/i.test(fileName) && /\.json$/i.test(fileName)) return rel;
188
- return null;
189
- }
190
-
191
- function buildSessionIgnoreSuggestions(realUsage, root) {
192
- if (!realUsage || !Array.isArray(realUsage.sessions)) return [];
193
- const byPattern = new Map();
194
- const add = (pattern, item, source, reason) => {
195
- if (!pattern) return;
196
- const existing = byPattern.get(pattern) || {
197
- pattern,
198
- source,
199
- reason,
200
- count: 0,
201
- examples: [],
202
- };
203
- existing.count += Number(item?.count || 1);
204
- const example = item?.value || item?.path || pattern;
205
- if (example && !existing.examples.includes(example) && existing.examples.length < 3) existing.examples.push(example);
206
- byPattern.set(pattern, existing);
207
- };
208
-
209
- for (const session of realUsage.sessions) {
210
- for (const item of session.generatedArtifacts || []) {
211
- add(sessionIgnorePatternForPath(item.value, root), item, session.tool || "session", "Generated artifact entered local session context.");
212
- }
213
- for (const item of session.repeatedPathMentions || []) {
214
- add(sessionIgnorePatternForPath(item.value, root), item, session.tool || "session", "Noisy path appeared repeatedly in local session context.");
215
- }
216
- }
217
- return Array.from(byPattern.values())
218
- .sort((a, b) => b.count - a.count || a.pattern.localeCompare(b.pattern))
219
- .slice(0, 25);
220
- }
30
+ const {
31
+ buildSessionIgnoreSuggestions,
32
+ isIgnored,
33
+ missingIgnoreSuggestions,
34
+ normalizeRel,
35
+ readIgnoreFile,
36
+ } = require("./scan-path-utils")({ fs, path });
221
37
 
222
38
  function getFileKind(filePath) {
223
39
  const ext = path.extname(filePath).toLowerCase();
@@ -0,0 +1,251 @@
1
+ module.exports = function createUsageLogUtils(deps) {
2
+ const {
3
+ fs,
4
+ path,
5
+ GENERATED_ARTIFACT_PATTERNS,
6
+ readIfText,
7
+ } = deps;
8
+
9
+ function listFilesRecursive(root, predicate = () => true, limit = 300) {
10
+ const files = [];
11
+ if (!fs.existsSync(root)) return files;
12
+ const stack = [root];
13
+ while (stack.length && files.length < limit) {
14
+ const current = stack.pop();
15
+ let entries;
16
+ try {
17
+ entries = fs.readdirSync(current, { withFileTypes: true });
18
+ } catch {
19
+ continue;
20
+ }
21
+ for (const entry of entries) {
22
+ const fullPath = path.join(current, entry.name);
23
+ if (entry.isDirectory()) {
24
+ stack.push(fullPath);
25
+ } else if (entry.isFile() && predicate(fullPath)) {
26
+ files.push(fullPath);
27
+ }
28
+ }
29
+ }
30
+ return files.sort((a, b) => {
31
+ try {
32
+ return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
33
+ } catch {
34
+ return 0;
35
+ }
36
+ });
37
+ }
38
+
39
+ function parseJsonl(filePath, maxLines = 20000) {
40
+ const text = readIfText(filePath, 30 * 1024 * 1024);
41
+ if (!text) return [];
42
+ const rows = [];
43
+ const lines = text.split(/\r?\n/).filter(Boolean);
44
+ for (const line of lines.slice(Math.max(0, lines.length - maxLines))) {
45
+ try {
46
+ rows.push(JSON.parse(line));
47
+ } catch {
48
+ // Local tool logs can contain partial writes while a session is active.
49
+ }
50
+ }
51
+ return rows;
52
+ }
53
+
54
+ function collectText(value, options = {}, depth = 0) {
55
+ if (value == null || depth > 8) return "";
56
+ if (typeof value === "string") return value;
57
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
58
+ if (Array.isArray(value)) return value.map((item) => collectText(item, options, depth + 1)).join("\n");
59
+ if (typeof value !== "object") return "";
60
+
61
+ const skipKeys = new Set(["signature", "encrypted_content", "image_url", "data", "auth", "api_key", "token"]);
62
+ const parts = [];
63
+ for (const [key, child] of Object.entries(value)) {
64
+ if (skipKeys.has(key)) continue;
65
+ parts.push(collectText(child, options, depth + 1));
66
+ }
67
+ return parts.filter(Boolean).join("\n");
68
+ }
69
+
70
+ function addUsage(target, usage) {
71
+ if (!usage || typeof usage !== "object") return;
72
+ target.inputTokens += Number(usage.input_tokens || usage.prompt_tokens || 0);
73
+ target.outputTokens += Number(usage.output_tokens || usage.completion_tokens || 0);
74
+ target.cacheReadTokens += Number(usage.cache_read_input_tokens || 0);
75
+ target.cacheCreationTokens += Number(usage.cache_creation_input_tokens || 0);
76
+ }
77
+
78
+ function totalUsageTokens(usage) {
79
+ if (!usage) return 0;
80
+ return (
81
+ Number(usage.input_tokens || usage.prompt_tokens || 0) +
82
+ Number(usage.output_tokens || usage.completion_tokens || 0) +
83
+ Number(usage.cache_read_input_tokens || 0) +
84
+ Number(usage.cache_creation_input_tokens || 0)
85
+ );
86
+ }
87
+
88
+ function incrementMap(map, key, amount = 1) {
89
+ if (!key) return;
90
+ map[key] = (map[key] || 0) + amount;
91
+ }
92
+
93
+ function normalizeMentionedPath(value, cwd = "") {
94
+ let normalized = String(value || "")
95
+ .replace(/\\/g, "/")
96
+ .replace(/^[`'"]+|[`'",:;)\]}]+$/g, "")
97
+ .trim();
98
+ normalized = normalized.replace(/^[ MADRCU?!]{1,4}\s+(?=\/|Users\/|home\/)/, "");
99
+ const normalizedCwd = String(cwd || "").replace(/\\/g, "/");
100
+ const wasAbsolute = normalized.startsWith("/");
101
+ if (wasAbsolute && normalizedCwd && !normalized.startsWith(`${normalizedCwd}/`) && normalized !== normalizedCwd) {
102
+ return "";
103
+ }
104
+ if (normalizedCwd && normalized.startsWith(normalizedCwd)) {
105
+ normalized = normalized.slice(normalizedCwd.length);
106
+ }
107
+ normalized = normalized.replace(/^\.?\//, "");
108
+ if (normalizedCwd) {
109
+ const repoName = path.basename(normalizedCwd);
110
+ const repoIndex = normalized.indexOf(`${repoName}/`);
111
+ if (repoIndex >= 0) normalized = normalized.slice(repoIndex + repoName.length + 1);
112
+ }
113
+ return normalized;
114
+ }
115
+
116
+ function isGeneratedArtifactPath(relPath) {
117
+ const normalized = normalizeMentionedPath(relPath);
118
+ return GENERATED_ARTIFACT_PATTERNS.some((pattern) => pattern.test(normalized));
119
+ }
120
+
121
+ function looksLikeUsefulPath(relPath) {
122
+ const normalized = normalizeMentionedPath(relPath);
123
+ if (!normalized || normalized.startsWith("http") || normalized.includes("://")) return false;
124
+ if (normalized.length < 3 || normalized.split("/").some((part) => !part || part.length > 120)) return false;
125
+ if (/^(Users|home|var|tmp|private|Volumes)\//i.test(normalized)) return false;
126
+ if (/^(Users|home|var|tmp|Downloads|Code|Projects)$/i.test(normalized)) return false;
127
+ if (isGeneratedArtifactPath(normalized)) return true;
128
+ if (/\.[A-Za-z0-9]{1,12}$/.test(normalized)) return true;
129
+ return /(^|\/)(src|app|lib|backend|frontend|tests|docs|scripts|components|pages|routes|api)\//.test(normalized);
130
+ }
131
+
132
+ function extractMentionedPaths(text, cwd = "") {
133
+ const found = new Set();
134
+ const source = String(text || "");
135
+ const pathPattern = /(?:^|[\s"'`])((?:\.{0,2}\/)?(?:[\w.@-]+\/)+[\w.@+-]+\.[A-Za-z0-9]{1,12})/g;
136
+ const filePattern = /(?:^|[\s"'`])((?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|coverage-final\.json|tsconfig\.json|pyproject\.toml|requirements\.txt|README\.md|CLAUDE\.md|AGENTS\.md))/g;
137
+ for (const pattern of [pathPattern, filePattern]) {
138
+ let match;
139
+ while ((match = pattern.exec(source))) {
140
+ const rel = normalizeMentionedPath(match[1], cwd);
141
+ if (!looksLikeUsefulPath(rel)) continue;
142
+ if (cwd && !isGeneratedArtifactPath(rel) && !fs.existsSync(path.join(cwd, rel))) continue;
143
+ found.add(rel);
144
+ }
145
+ }
146
+ return Array.from(found);
147
+ }
148
+
149
+ function normalizeCommand(value) {
150
+ return String(value || "")
151
+ .replace(/\s+/g, " ")
152
+ .replace(/[;|&]+$/g, "")
153
+ .trim()
154
+ .slice(0, 160);
155
+ }
156
+
157
+ function isShellCommand(value) {
158
+ return /^(npm|pnpm|yarn|bun|pytest|python3?|node|npx|uv|ruff|cargo|go|make|git|cd|rm|cp|mv|sed|rg|grep|find|cat)\b/.test(String(value || "").trim());
159
+ }
160
+
161
+ function extractCommandCandidates(row, text) {
162
+ const commands = [];
163
+ const directInputs = [
164
+ row.payload?.input,
165
+ row.payload?.arguments,
166
+ row.message?.input,
167
+ row.message?.arguments,
168
+ ];
169
+ for (const input of directInputs) {
170
+ if (typeof input === "string") commands.push(input);
171
+ else if (input && typeof input === "object") {
172
+ for (const value of Object.values(input)) {
173
+ if (typeof value === "string") commands.push(value);
174
+ }
175
+ }
176
+ }
177
+ const toolItems = Array.isArray(row.message?.content) ? row.message.content : [];
178
+ for (const item of toolItems) {
179
+ if (!item || typeof item !== "object") continue;
180
+ if (typeof item.input === "string") commands.push(item.input);
181
+ if (item.input && typeof item.input === "object") {
182
+ for (const value of Object.values(item.input)) {
183
+ if (typeof value === "string") commands.push(value);
184
+ }
185
+ }
186
+ }
187
+ if (/tool_use|function_call/i.test(row.type || row.payload?.type || "")) {
188
+ const commandPattern = /\b(?:npm|pnpm|yarn|bun|pytest|python3?|node|npx|uv|ruff|cargo|go test|make|git)\b[^\n\r"`']{0,140}/g;
189
+ for (const match of String(text || "").matchAll(commandPattern)) {
190
+ commands.push(match[0]);
191
+ }
192
+ }
193
+ return Array.from(new Set(commands.map(normalizeCommand).filter((cmd) => cmd.length >= 3 && /\s/.test(cmd) && isShellCommand(cmd))));
194
+ }
195
+
196
+ function topCountEntries(map, limit = 5, minCount = 2) {
197
+ return Object.entries(map || {})
198
+ .filter(([, count]) => count >= minCount)
199
+ .sort((a, b) => b[1] - a[1])
200
+ .slice(0, limit)
201
+ .map(([value, count]) => ({ value, count }));
202
+ }
203
+
204
+ function isExpectedRepeatedPath(value) {
205
+ const normalized = normalizeMentionedPath(value).toLowerCase();
206
+ return ["claude.md", "agents.md", "readme.md"].includes(normalized) || normalized.endsWith("/readme.md");
207
+ }
208
+
209
+ function getActionableRepeatedPaths(session, limit = 3) {
210
+ return (session.repeatedPathMentions || [])
211
+ .filter((item) => !isExpectedRepeatedPath(item.value))
212
+ .filter((item) => !isGeneratedArtifactPath(item.value))
213
+ .slice(0, limit);
214
+ }
215
+
216
+ function summarizeGeneratedArtifacts(items = [], limit = 4) {
217
+ const groups = new Map();
218
+ for (const item of items) {
219
+ const value = normalizeMentionedPath(item.value);
220
+ let key = "generated files";
221
+ if (value.includes("__pycache__/") || value.endsWith(".pyc")) key = "__pycache__";
222
+ else if (value.includes("node_modules/")) key = "node_modules";
223
+ else if (/package-lock\.json|pnpm-lock\.yaml|yarn\.lock$/i.test(value)) key = "lockfiles";
224
+ else if (value.includes("/dist/") || value.startsWith("dist/")) key = "dist";
225
+ else if (value.includes("/build/") || value.startsWith("build/")) key = "build";
226
+ else if (value.includes("/coverage/") || value.startsWith("coverage/")) key = "coverage";
227
+ else if (/(^|\/)assets\/[^/]+-[A-Za-z0-9_-]{6,}\.(js|css|map)$/i.test(value)) key = "hashed assets";
228
+ const current = groups.get(key) || { type: key, count: 0, examples: [] };
229
+ current.count += Number(item.count || 1);
230
+ if (current.examples.length < 2) current.examples.push(value);
231
+ groups.set(key, current);
232
+ }
233
+ return Array.from(groups.values()).sort((a, b) => b.count - a.count).slice(0, limit);
234
+ }
235
+
236
+ return {
237
+ addUsage,
238
+ collectText,
239
+ extractCommandCandidates,
240
+ extractMentionedPaths,
241
+ getActionableRepeatedPaths,
242
+ incrementMap,
243
+ isGeneratedArtifactPath,
244
+ listFilesRecursive,
245
+ normalizeMentionedPath,
246
+ parseJsonl,
247
+ summarizeGeneratedArtifacts,
248
+ topCountEntries,
249
+ totalUsageTokens,
250
+ };
251
+ };
@@ -13,84 +13,26 @@ module.exports = function createUsageWatch(deps) {
13
13
  writeGeneratedFile,
14
14
  } = deps;
15
15
 
16
- function listFilesRecursive(root, predicate = () => true, limit = 300) {
17
- const files = [];
18
- if (!fs.existsSync(root)) return files;
19
- const stack = [root];
20
- while (stack.length && files.length < limit) {
21
- const current = stack.pop();
22
- let entries;
23
- try {
24
- entries = fs.readdirSync(current, { withFileTypes: true });
25
- } catch {
26
- continue;
27
- }
28
- for (const entry of entries) {
29
- const fullPath = path.join(current, entry.name);
30
- if (entry.isDirectory()) {
31
- stack.push(fullPath);
32
- } else if (entry.isFile() && predicate(fullPath)) {
33
- files.push(fullPath);
34
- }
35
- }
36
- }
37
- return files.sort((a, b) => {
38
- try {
39
- return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
40
- } catch {
41
- return 0;
42
- }
43
- });
44
- }
45
-
46
- function parseJsonl(filePath, maxLines = 20000) {
47
- const text = readIfText(filePath, 30 * 1024 * 1024);
48
- if (!text) return [];
49
- const rows = [];
50
- const lines = text.split(/\r?\n/).filter(Boolean);
51
- for (const line of lines.slice(Math.max(0, lines.length - maxLines))) {
52
- try {
53
- rows.push(JSON.parse(line));
54
- } catch {
55
- // Local tool logs can contain partial writes while a session is active.
56
- }
57
- }
58
- return rows;
59
- }
60
-
61
- function collectText(value, options = {}, depth = 0) {
62
- if (value == null || depth > 8) return "";
63
- if (typeof value === "string") return value;
64
- if (typeof value === "number" || typeof value === "boolean") return String(value);
65
- if (Array.isArray(value)) return value.map((item) => collectText(item, options, depth + 1)).join("\n");
66
- if (typeof value !== "object") return "";
67
-
68
- const skipKeys = new Set(["signature", "encrypted_content", "image_url", "data", "auth", "api_key", "token"]);
69
- const parts = [];
70
- for (const [key, child] of Object.entries(value)) {
71
- if (skipKeys.has(key)) continue;
72
- parts.push(collectText(child, options, depth + 1));
73
- }
74
- return parts.filter(Boolean).join("\n");
75
- }
76
-
77
- function addUsage(target, usage) {
78
- if (!usage || typeof usage !== "object") return;
79
- target.inputTokens += Number(usage.input_tokens || usage.prompt_tokens || 0);
80
- target.outputTokens += Number(usage.output_tokens || usage.completion_tokens || 0);
81
- target.cacheReadTokens += Number(usage.cache_read_input_tokens || 0);
82
- target.cacheCreationTokens += Number(usage.cache_creation_input_tokens || 0);
83
- }
84
-
85
- function totalUsageTokens(usage) {
86
- if (!usage) return 0;
87
- return (
88
- Number(usage.input_tokens || usage.prompt_tokens || 0) +
89
- Number(usage.output_tokens || usage.completion_tokens || 0) +
90
- Number(usage.cache_read_input_tokens || 0) +
91
- Number(usage.cache_creation_input_tokens || 0)
92
- );
93
- }
16
+ const {
17
+ addUsage,
18
+ collectText,
19
+ extractCommandCandidates,
20
+ extractMentionedPaths,
21
+ getActionableRepeatedPaths,
22
+ incrementMap,
23
+ isGeneratedArtifactPath,
24
+ listFilesRecursive,
25
+ normalizeMentionedPath,
26
+ parseJsonl,
27
+ summarizeGeneratedArtifacts,
28
+ topCountEntries,
29
+ totalUsageTokens,
30
+ } = require("./usage-log-utils")({
31
+ fs,
32
+ path,
33
+ GENERATED_ARTIFACT_PATTERNS,
34
+ readIfText,
35
+ });
94
36
 
95
37
  function inferClaudePricingKey(model) {
96
38
  const normalized = String(model || "").toLowerCase();
@@ -139,154 +81,6 @@ function getSessionRisk(tokens, toolTokens) {
139
81
  return "Low";
140
82
  }
141
83
 
142
- function incrementMap(map, key, amount = 1) {
143
- if (!key) return;
144
- map[key] = (map[key] || 0) + amount;
145
- }
146
-
147
- function normalizeMentionedPath(value, cwd = "") {
148
- let normalized = String(value || "")
149
- .replace(/\\/g, "/")
150
- .replace(/^[`'"]+|[`'",:;)\]}]+$/g, "")
151
- .trim();
152
- normalized = normalized.replace(/^[ MADRCU?!]{1,4}\s+(?=\/|Users\/|home\/)/, "");
153
- const normalizedCwd = String(cwd || "").replace(/\\/g, "/");
154
- const wasAbsolute = normalized.startsWith("/");
155
- if (wasAbsolute && normalizedCwd && !normalized.startsWith(`${normalizedCwd}/`) && normalized !== normalizedCwd) {
156
- return "";
157
- }
158
- if (normalizedCwd && normalized.startsWith(normalizedCwd)) {
159
- normalized = normalized.slice(normalizedCwd.length);
160
- }
161
- normalized = normalized.replace(/^\.?\//, "");
162
- if (normalizedCwd) {
163
- const repoName = path.basename(normalizedCwd);
164
- const repoIndex = normalized.indexOf(`${repoName}/`);
165
- if (repoIndex >= 0) normalized = normalized.slice(repoIndex + repoName.length + 1);
166
- }
167
- return normalized;
168
- }
169
-
170
- function isGeneratedArtifactPath(relPath) {
171
- const normalized = normalizeMentionedPath(relPath);
172
- return GENERATED_ARTIFACT_PATTERNS.some((pattern) => pattern.test(normalized));
173
- }
174
-
175
- function looksLikeUsefulPath(relPath) {
176
- const normalized = normalizeMentionedPath(relPath);
177
- if (!normalized || normalized.startsWith("http") || normalized.includes("://")) return false;
178
- if (normalized.length < 3 || normalized.split("/").some((part) => !part || part.length > 120)) return false;
179
- if (/^(Users|home|var|tmp|private|Volumes)\//i.test(normalized)) return false;
180
- if (/^(Users|home|var|tmp|Downloads|Code|Projects)$/i.test(normalized)) return false;
181
- if (isGeneratedArtifactPath(normalized)) return true;
182
- if (/\.[A-Za-z0-9]{1,12}$/.test(normalized)) return true;
183
- return /(^|\/)(src|app|lib|backend|frontend|tests|docs|scripts|components|pages|routes|api)\//.test(normalized);
184
- }
185
-
186
- function extractMentionedPaths(text, cwd = "") {
187
- const found = new Set();
188
- const source = String(text || "");
189
- const pathPattern = /(?:^|[\s"'`])((?:\.{0,2}\/)?(?:[\w.@-]+\/)+[\w.@+-]+\.[A-Za-z0-9]{1,12})/g;
190
- const filePattern = /(?:^|[\s"'`])((?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|coverage-final\.json|tsconfig\.json|pyproject\.toml|requirements\.txt|README\.md|CLAUDE\.md|AGENTS\.md))/g;
191
- for (const pattern of [pathPattern, filePattern]) {
192
- let match;
193
- while ((match = pattern.exec(source))) {
194
- const rel = normalizeMentionedPath(match[1], cwd);
195
- if (!looksLikeUsefulPath(rel)) continue;
196
- if (cwd && !isGeneratedArtifactPath(rel) && !fs.existsSync(path.join(cwd, rel))) continue;
197
- found.add(rel);
198
- }
199
- }
200
- return Array.from(found);
201
- }
202
-
203
- function normalizeCommand(value) {
204
- return String(value || "")
205
- .replace(/\s+/g, " ")
206
- .replace(/[;|&]+$/g, "")
207
- .trim()
208
- .slice(0, 160);
209
- }
210
-
211
- function isShellCommand(value) {
212
- return /^(npm|pnpm|yarn|bun|pytest|python3?|node|npx|uv|ruff|cargo|go|make|git|cd|rm|cp|mv|sed|rg|grep|find|cat)\b/.test(String(value || "").trim());
213
- }
214
-
215
- function extractCommandCandidates(row, text) {
216
- const commands = [];
217
- const directInputs = [
218
- row.payload?.input,
219
- row.payload?.arguments,
220
- row.message?.input,
221
- row.message?.arguments,
222
- ];
223
- for (const input of directInputs) {
224
- if (typeof input === "string") commands.push(input);
225
- else if (input && typeof input === "object") {
226
- for (const value of Object.values(input)) {
227
- if (typeof value === "string") commands.push(value);
228
- }
229
- }
230
- }
231
- const toolItems = Array.isArray(row.message?.content) ? row.message.content : [];
232
- for (const item of toolItems) {
233
- if (!item || typeof item !== "object") continue;
234
- if (typeof item.input === "string") commands.push(item.input);
235
- if (item.input && typeof item.input === "object") {
236
- for (const value of Object.values(item.input)) {
237
- if (typeof value === "string") commands.push(value);
238
- }
239
- }
240
- }
241
- if (/tool_use|function_call/i.test(row.type || row.payload?.type || "")) {
242
- const commandPattern = /\b(?:npm|pnpm|yarn|bun|pytest|python3?|node|npx|uv|ruff|cargo|go test|make|git)\b[^\n\r"`']{0,140}/g;
243
- for (const match of String(text || "").matchAll(commandPattern)) {
244
- commands.push(match[0]);
245
- }
246
- }
247
- return Array.from(new Set(commands.map(normalizeCommand).filter((cmd) => cmd.length >= 3 && /\s/.test(cmd) && isShellCommand(cmd))));
248
- }
249
-
250
- function topCountEntries(map, limit = 5, minCount = 2) {
251
- return Object.entries(map || {})
252
- .filter(([, count]) => count >= minCount)
253
- .sort((a, b) => b[1] - a[1])
254
- .slice(0, limit)
255
- .map(([value, count]) => ({ value, count }));
256
- }
257
-
258
- function isExpectedRepeatedPath(value) {
259
- const normalized = normalizeMentionedPath(value).toLowerCase();
260
- return ["claude.md", "agents.md", "readme.md"].includes(normalized) || normalized.endsWith("/readme.md");
261
- }
262
-
263
- function getActionableRepeatedPaths(session, limit = 3) {
264
- return (session.repeatedPathMentions || [])
265
- .filter((item) => !isExpectedRepeatedPath(item.value))
266
- .filter((item) => !isGeneratedArtifactPath(item.value))
267
- .slice(0, limit);
268
- }
269
-
270
- function summarizeGeneratedArtifacts(items = [], limit = 4) {
271
- const groups = new Map();
272
- for (const item of items) {
273
- const value = normalizeMentionedPath(item.value);
274
- let key = "generated files";
275
- if (value.includes("__pycache__/") || value.endsWith(".pyc")) key = "__pycache__";
276
- else if (value.includes("node_modules/")) key = "node_modules";
277
- else if (/package-lock\.json|pnpm-lock\.yaml|yarn\.lock$/i.test(value)) key = "lockfiles";
278
- else if (value.includes("/dist/") || value.startsWith("dist/")) key = "dist";
279
- else if (value.includes("/build/") || value.startsWith("build/")) key = "build";
280
- else if (value.includes("/coverage/") || value.startsWith("coverage/")) key = "coverage";
281
- else if (/(^|\/)assets\/[^/]+-[A-Za-z0-9_-]{6,}\.(js|css|map)$/i.test(value)) key = "hashed assets";
282
- const current = groups.get(key) || { type: key, count: 0, examples: [] };
283
- current.count += Number(item.count || 1);
284
- if (current.examples.length < 2) current.examples.push(value);
285
- groups.set(key, current);
286
- }
287
- return Array.from(groups.values()).sort((a, b) => b.count - a.count).slice(0, limit);
288
- }
289
-
290
84
  function analyzeSessionFile(filePath, tool) {
291
85
  const rows = parseJsonl(filePath);
292
86
  const stat = fs.existsSync(filePath) ? fs.statSync(filePath) : null;
@@ -0,0 +1,85 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ const { BINARY_EXTENSIONS } = require("./constants");
5
+
6
+ function shouldUseColor() {
7
+ return process.stdout.isTTY && !process.env.NO_COLOR;
8
+ }
9
+
10
+ const colorCodes = {
11
+ reset: "\x1b[0m",
12
+ bold: "\x1b[1m",
13
+ dim: "\x1b[2m",
14
+ red: "\x1b[31m",
15
+ yellow: "\x1b[33m",
16
+ green: "\x1b[32m",
17
+ cyan: "\x1b[36m",
18
+ };
19
+
20
+ function color(text, tone, enabled = shouldUseColor()) {
21
+ if (!enabled || !colorCodes[tone]) return text;
22
+ return `${colorCodes[tone]}${text}${colorCodes.reset}`;
23
+ }
24
+
25
+ function severityIcon(severity) {
26
+ if (severity === "critical") return "[critical]";
27
+ if (severity === "high") return "[high]";
28
+ if (severity === "medium") return "[medium]";
29
+ return "[low]";
30
+ }
31
+
32
+ function severityColor(severity) {
33
+ if (severity === "critical" || severity === "high") return "red";
34
+ if (severity === "medium") return "yellow";
35
+ return "cyan";
36
+ }
37
+
38
+ function printStep(label, json = false) {
39
+ if (json) return () => {};
40
+ process.stderr.write(`${color("...", "cyan")} ${label}`);
41
+ return (status = "done") => {
42
+ process.stderr.write(` ${color(`[${status}]`, status === "done" ? "green" : "yellow")}\n`);
43
+ };
44
+ }
45
+
46
+ function estimateTokens(textOrBytes) {
47
+ const length = typeof textOrBytes === "string" ? textOrBytes.length : Number(textOrBytes || 0);
48
+ return Math.ceil(length / 4);
49
+ }
50
+
51
+ function readIfText(filePath, maxBytes = 2 * 1024 * 1024) {
52
+ const ext = path.extname(filePath).toLowerCase();
53
+ if (BINARY_EXTENSIONS.has(ext)) return null;
54
+
55
+ let stat;
56
+ try {
57
+ stat = fs.statSync(filePath);
58
+ } catch {
59
+ return null;
60
+ }
61
+ if (!stat.isFile() || stat.size > maxBytes) return null;
62
+
63
+ const buffer = fs.readFileSync(filePath);
64
+ if (buffer.includes(0)) return null;
65
+ return buffer.toString("utf8");
66
+ }
67
+
68
+ function safeReadJson(filePath) {
69
+ try {
70
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ module.exports = {
77
+ color,
78
+ estimateTokens,
79
+ printStep,
80
+ readIfText,
81
+ safeReadJson,
82
+ severityColor,
83
+ severityIcon,
84
+ shouldUseColor,
85
+ };
@@ -19,76 +19,15 @@ const {
19
19
  GENERATED_ARTIFACT_PATTERNS,
20
20
  } = require("./prismo-dev/constants");
21
21
 
22
- function shouldUseColor() {
23
- return process.stdout.isTTY && !process.env.NO_COLOR;
24
- }
25
-
26
- const colorCodes = {
27
- reset: "\x1b[0m",
28
- bold: "\x1b[1m",
29
- dim: "\x1b[2m",
30
- red: "\x1b[31m",
31
- yellow: "\x1b[33m",
32
- green: "\x1b[32m",
33
- cyan: "\x1b[36m",
34
- };
35
-
36
- function color(text, tone, enabled = shouldUseColor()) {
37
- if (!enabled || !colorCodes[tone]) return text;
38
- return `${colorCodes[tone]}${text}${colorCodes.reset}`;
39
- }
40
-
41
- function severityIcon(severity) {
42
- if (severity === "critical") return "[critical]";
43
- if (severity === "high") return "[high]";
44
- if (severity === "medium") return "[medium]";
45
- return "[low]";
46
- }
47
-
48
- function severityColor(severity) {
49
- if (severity === "critical" || severity === "high") return "red";
50
- if (severity === "medium") return "yellow";
51
- return "cyan";
52
- }
53
-
54
- function printStep(label, json = false) {
55
- if (json) return () => {};
56
- process.stderr.write(`${color("...", "cyan")} ${label}`);
57
- return (status = "done") => {
58
- process.stderr.write(` ${color(`[${status}]`, status === "done" ? "green" : "yellow")}\n`);
59
- };
60
- }
61
-
62
- function estimateTokens(textOrBytes) {
63
- const length = typeof textOrBytes === "string" ? textOrBytes.length : Number(textOrBytes || 0);
64
- return Math.ceil(length / 4);
65
- }
66
-
67
- function readIfText(filePath, maxBytes = 2 * 1024 * 1024) {
68
- const ext = path.extname(filePath).toLowerCase();
69
- if (BINARY_EXTENSIONS.has(ext)) return null;
70
-
71
- let stat;
72
- try {
73
- stat = fs.statSync(filePath);
74
- } catch {
75
- return null;
76
- }
77
- if (!stat.isFile() || stat.size > maxBytes) return null;
78
-
79
- const buffer = fs.readFileSync(filePath);
80
- if (buffer.includes(0)) return null;
81
- return buffer.toString("utf8");
82
- }
83
-
84
-
85
- function safeReadJson(filePath) {
86
- try {
87
- return JSON.parse(fs.readFileSync(filePath, "utf8"));
88
- } catch {
89
- return null;
90
- }
91
- }
22
+ const {
23
+ color,
24
+ estimateTokens,
25
+ printStep,
26
+ readIfText,
27
+ safeReadJson,
28
+ severityColor,
29
+ severityIcon,
30
+ } = require("./prismo-dev/utils");
92
31
 
93
32
  let scanRepo;
94
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getprismo",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "Local AI coding workflow scanner for Codex, Claude Code, Cursor, and token-waste diagnostics.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/shanirsh/prismodev#readme",