pi-repl-py 0.1.0 → 0.1.1

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,28 @@
1
+ // --- descriptor: collapse the raw line into one readable, safe, width-capped string ---
2
+ export const DESCRIPTOR_MAX_WIDTH = 64;
3
+
4
+ function collapseWhitespace(text: string): string {
5
+ return text.replace(/\s+/g, " ").trim();
6
+ }
7
+
8
+ function truncateDescriptor(text: string): string {
9
+ if (text.length <= DESCRIPTOR_MAX_WIDTH) return text;
10
+ return text.slice(0, DESCRIPTOR_MAX_WIDTH - 1).trimEnd() + "…";
11
+ }
12
+
13
+ // --- strip blobs, secrets, and sk- keys before a line reaches the header ---
14
+ function redactNoise(text: string): string {
15
+ return text
16
+ .replace(/[A-Za-z0-9+/]{80,}={0,2}/g, "<blob>")
17
+ .replace(/\b((?=\w*(?:token|key|secret|password))[A-Za-z_]\w*)\s*[=:]\s*(["'])[^"']*\2/gi, "$1=<redacted>")
18
+ .replace(
19
+ /\b((?=\w*(?:token|key|secret|password))[A-Za-z_]\w*)\s*[=:]\s*(?!<redacted>)(?!["'])\S+/gi,
20
+ "$1=<redacted>",
21
+ )
22
+ .replace(/(["'])sk-[^"']+\1/g, "$1<redacted>$1")
23
+ .replace(/(["']).{160,}\1/g, "$1…$1");
24
+ }
25
+
26
+ export function descriptor(text: string): string {
27
+ return truncateDescriptor(collapseWhitespace(redactNoise(text)));
28
+ }
@@ -0,0 +1,39 @@
1
+ // --- preview entry: score the whole cell for its one truthful line ---
2
+
3
+ import {
4
+ agentCandidates,
5
+ bridgedToolCandidates,
6
+ fileCandidates,
7
+ genericCandidates,
8
+ shellCandidates,
9
+ } from "./candidates.js";
10
+ import { descriptor } from "./descriptor.js";
11
+ import { stringConsts } from "./scan.js";
12
+ import { previewShellCommand } from "./shell.js";
13
+ import type { CellPreview } from "./types.js";
14
+
15
+ export type { CellPreview };
16
+ export { descriptor, previewShellCommand };
17
+
18
+ export function previewCell(code: string): CellPreview {
19
+ const source = code.trimEnd();
20
+ if (!source) return { kind: "ts", text: "" };
21
+ const vars = stringConsts(source);
22
+
23
+ // --- scan order matters: agent masks shell-looking syntax before the shell scan ---
24
+ const agent = agentCandidates(source, vars);
25
+ const shell = shellCandidates(agent.masked, vars);
26
+ const candidates = [
27
+ ...agent.candidates,
28
+ ...shell.candidates,
29
+ ...fileCandidates(shell.masked, vars),
30
+ ...bridgedToolCandidates(shell.masked, vars),
31
+ ...genericCandidates(shell.masked),
32
+ ];
33
+
34
+ let best: { kind: CellPreview["kind"]; text: string; score: number } | undefined;
35
+ for (const candidate of candidates) {
36
+ if (candidate.text && (!best || candidate.score > best.score)) best = candidate;
37
+ }
38
+ return best ?? { kind: "ts", text: "" };
39
+ }
@@ -0,0 +1,59 @@
1
+ // --- scan: tokenizer source → template spans, string constants, and masks ---
2
+ import { BACKTICK, type Span } from "./types.js";
3
+
4
+ // --- capture the template opened at start; tracks escapes + interpolation nesting so a shell command reads whole, and an unclosed template returns the rest (partial is better than none) ---
5
+ export function scanTemplate(source: string, start: number): Span {
6
+ let depth = 0;
7
+ let inNested = false;
8
+ for (let i = start + 1; i < source.length; i++) {
9
+ const ch = source[i];
10
+ if (ch === "\\") {
11
+ i += 1;
12
+ continue;
13
+ }
14
+ if (ch === BACKTICK) {
15
+ if (depth === 0 && !inNested) return { start, end: i + 1, body: source.slice(start + 1, i) };
16
+ inNested = !inNested;
17
+ continue;
18
+ }
19
+ if (!inNested && ch === "$" && source[i + 1] === "{") {
20
+ depth += 1;
21
+ i += 1;
22
+ continue;
23
+ }
24
+ if (!inNested && depth > 0 && ch === "}") depth -= 1;
25
+ }
26
+ return { start, end: source.length, body: source.slice(start + 1) };
27
+ }
28
+
29
+ const CONST_STRING_PATTERN = new RegExp(
30
+ '(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*(?:"([^"\\n]*)"|' +
31
+ "'([^'\\n]*)'|" +
32
+ BACKTICK +
33
+ "([^" +
34
+ BACKTICK +
35
+ "$\\n]*)" +
36
+ BACKTICK +
37
+ ")",
38
+ "g",
39
+ );
40
+
41
+ // --- collected simple string constants, for resolving interpolations and path args ---
42
+ export function stringConsts(source: string): Map<string, string> {
43
+ const vars = new Map<string, string>();
44
+ for (const match of source.matchAll(CONST_STRING_PATTERN)) {
45
+ const name = match[1];
46
+ const value = match[2] ?? match[3] ?? match[4];
47
+ if (name && value !== undefined) vars.set(name, value);
48
+ }
49
+ return vars;
50
+ }
51
+
52
+ export function substituteVars(text: string, vars: ReadonlyMap<string, string>): string {
53
+ return text.replace(/\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g, (whole, name: string) => vars.get(name) ?? whole);
54
+ }
55
+
56
+ // --- blank a claimed span so later detectors don't re-read what an earlier one took ---
57
+ export function maskSpan(source: string, span: Span): string {
58
+ return source.slice(0, span.start) + " ".repeat(span.end - span.start) + source.slice(span.end);
59
+ }
@@ -0,0 +1,156 @@
1
+ // --- shell: resolve the strongest single line of a (possibly chained) shell command ---
2
+ import { descriptor } from "./descriptor.js";
3
+
4
+ const CD_PREFIX_PATTERN = /^\s*cd\s+([^&;|]+?)\s*(?:&&|;)\s*/;
5
+ const SHELL_SETUP_PATTERN = /^(?:export\s+\w+=|set\s+[-+]|source\s+\S+|\.\s+\S+)/;
6
+ const HEREDOC_PATTERN = /<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/;
7
+
8
+ export function shellWords(line: string): string[] {
9
+ const words: string[] = [];
10
+ for (const match of line.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) {
11
+ words.push(match[1] ?? match[2] ?? match[3] ?? "");
12
+ }
13
+ return words;
14
+ }
15
+
16
+ function pathTail(path: string): string {
17
+ const cleaned = path.replace(/\/+$/, "");
18
+ const tail = cleaned.slice(cleaned.lastIndexOf("/") + 1);
19
+ return tail || cleaned;
20
+ }
21
+
22
+ function simplifyRunnerCommand(line: string): string | undefined {
23
+ const words = shellWords(line);
24
+ if (words[0] === "npm" || words[0] === "pnpm") {
25
+ const runIndex = words.indexOf("run");
26
+ if (runIndex >= 0 && words[runIndex + 1]) {
27
+ return (words[0] + " " + words.slice(runIndex + 1).join(" ")).trim();
28
+ }
29
+ }
30
+ if (line.includes("node_modules/.bin/")) {
31
+ return line.replace(/\S*node_modules\/\.bin\//g, "");
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ function simplifyMutationCommand(line: string): string | undefined {
37
+ const words = shellWords(line);
38
+ if (words.length === 0) return undefined;
39
+ if (words[0] === "cat" && words[1] === ">" && words[2]) return "write " + pathTail(words[2]);
40
+ if (words[0] === "tee" && words.at(-1)) {
41
+ return (words.includes("-a") ? "append " : "write ") + pathTail(words.at(-1) ?? "");
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ // --- collapse noisier command forms (runners, writes) down to the intent ---
47
+ function simplifyShellLine(line: string): string {
48
+ return simplifyRunnerCommand(line) ?? simplifyMutationCommand(line) ?? line;
49
+ }
50
+
51
+ // --- commands that prepare the ground; the shell only wins when it is the story ---
52
+ export const SHELL_SETUP_WORDS = new Set([
53
+ "mkdir",
54
+ "cd",
55
+ "export",
56
+ "touch",
57
+ "chmod",
58
+ "chown",
59
+ "ln",
60
+ "echo",
61
+ "true",
62
+ "sleep",
63
+ "which",
64
+ "sync",
65
+ ]);
66
+
67
+ export const SHELL_ACTION_WORDS = new Set([
68
+ "rm",
69
+ "mv",
70
+ "cp",
71
+ "git",
72
+ "npm",
73
+ "pnpm",
74
+ "bun",
75
+ "bunx",
76
+ "npx",
77
+ "make",
78
+ "cargo",
79
+ "docker",
80
+ "curl",
81
+ "gh",
82
+ "pi",
83
+ ]);
84
+
85
+ function shellLineScore(line: string, index: number): number {
86
+ const simplified = simplifyShellLine(line);
87
+ const words = shellWords(line);
88
+ let score = 30;
89
+ if (simplified !== line) score += 40;
90
+ if (SHELL_ACTION_WORDS.has(words[0] ?? "")) score += 20;
91
+ if (/\b(?:rm|mv|cp|git\s+(?:add|commit|push)|sed\s+-i|perl\s+-pi|tee|cat\s*>)\b/.test(line)) score += 40;
92
+ return score + index;
93
+ }
94
+
95
+ function heredocBody(lines: readonly string[], startIndex: number, delimiter: string): string | undefined {
96
+ const body: string[] = [];
97
+ for (let i = startIndex + 1; i < lines.length; i++) {
98
+ if ((lines[i] ?? "").trim() === delimiter) return body.join("\n");
99
+ body.push(lines[i] ?? "");
100
+ }
101
+ return body.length > 0 ? body.join("\n") : undefined;
102
+ }
103
+
104
+ function previewHeredoc(lines: readonly string[]): string | undefined {
105
+ for (let i = 0; i < lines.length; i++) {
106
+ const line = (lines[i] ?? "").trim();
107
+ const delimiter = line.match(HEREDOC_PATTERN)?.[1];
108
+ if (!delimiter) continue;
109
+ const body = heredocBody(lines, i, delimiter);
110
+ if (!body) continue;
111
+ // --- the write target is the story; the body is detail for the expanded view ---
112
+ const catWrite = line.match(/\b(?:cat|tee)\b.*(?:>|\s)(\S+)\s*<<-?/);
113
+ if (catWrite?.[1]) return (line.includes("tee -a") ? "append " : "write ") + pathTail(catWrite[1]);
114
+ return descriptor(body);
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ export function previewShellCommand(command: string): string {
120
+ return previewShellCommandScored(command).text;
121
+ }
122
+
123
+ // --- like previewShellCommand but keeps the winning line's strength so several shell calls can rank ---
124
+ export function previewShellCommandScored(command: string): { text: string; strength: number } {
125
+ const lines = command.split("\n");
126
+ const heredoc = previewHeredoc(lines);
127
+ if (heredoc) return { text: descriptor(heredoc), strength: 90 };
128
+
129
+ let best: { text: string; score: number } | undefined;
130
+ let cwdSuffix: string | undefined;
131
+ let index = 0;
132
+ for (const rawLine of lines) {
133
+ for (const rawPart of rawLine.split(/\s*(?:&&|;)\s*/)) {
134
+ let part = rawPart.trim();
135
+ if (!part || part.startsWith("#") || SHELL_SETUP_PATTERN.test(part)) continue;
136
+ const cd = part.match(CD_PREFIX_PATTERN);
137
+ if (cd?.[1]) {
138
+ cwdSuffix = pathTail(cd[1].trim());
139
+ part = part.replace(CD_PREFIX_PATTERN, "").trim();
140
+ } else if (/^cd\s+\S+$/.test(part)) {
141
+ cwdSuffix = pathTail(part.slice(2).trim());
142
+ continue;
143
+ }
144
+ if (!part) continue;
145
+ const candidate = { text: simplifyShellLine(part), score: shellLineScore(part, index) };
146
+ if (!best || candidate.score > best.score) best = candidate;
147
+ index += 1;
148
+ }
149
+ }
150
+ if (!best) return { text: "", strength: 0 };
151
+ // --- trailing redirections are plumbing, not intent ---
152
+ const cleaned = best.text.replace(/(?:\s*(?:2>&1|[12]?>\s*\/dev\/null|&>\s*\/dev\/null))+\s*$/, "");
153
+ // --- a stripped cd prefix still matters when it names a non-default dir ---
154
+ const text = cwdSuffix && !cleaned.includes(cwdSuffix) ? cleaned + " (" + cwdSuffix + ")" : cleaned;
155
+ return { text: descriptor(text), strength: best.score };
156
+ }
@@ -0,0 +1,23 @@
1
+ // --- shared preview types; tiny module so every consumer imports only the shape it needs ---
2
+ export type CellPreviewKind = "shell" | "agent" | "ts";
3
+
4
+ export interface CellPreview {
5
+ kind: CellPreviewKind;
6
+ text: string;
7
+ }
8
+
9
+ // --- a [start, end) slice of the source with its captured body ---
10
+ export interface Span {
11
+ start: number;
12
+ end: number;
13
+ body: string;
14
+ }
15
+
16
+ // --- the winner of each detector, ranked by score in the orchestration ---
17
+ export interface Candidate {
18
+ kind: CellPreviewKind;
19
+ text: string;
20
+ score: number;
21
+ }
22
+
23
+ export const BACKTICK = "\u0060";