@rmrdeveloper/sideroom-pi 8.12.0 → 8.13.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.
Files changed (31) hide show
  1. package/README.md +16 -0
  2. package/assets/visual-context/browser/INSTALL.md +69 -0
  3. package/assets/visual-context/browser/background.js +578 -0
  4. package/assets/visual-context/browser/content.js +198 -0
  5. package/assets/visual-context/browser/icons/README.md +9 -0
  6. package/assets/visual-context/browser/icons/icon-128.png +0 -0
  7. package/assets/visual-context/browser/icons/icon-16.png +0 -0
  8. package/assets/visual-context/browser/icons/icon-32.png +0 -0
  9. package/assets/visual-context/browser/icons/icon-48.png +0 -0
  10. package/assets/visual-context/browser/manifest.json +20 -0
  11. package/assets/visual-context/browser/popup.css +19 -0
  12. package/assets/visual-context/browser/popup.html +27 -0
  13. package/assets/visual-context/browser/popup.js +141 -0
  14. package/extensions/visual-context/bridge.ts +240 -0
  15. package/extensions/visual-context/broker.ts +292 -0
  16. package/extensions/visual-context/envelope.ts +34 -0
  17. package/extensions/visual-context/index.ts +328 -0
  18. package/extensions/visual-context/literal.ts +34 -0
  19. package/extensions/visual-context/markup.ts +104 -0
  20. package/extensions/visual-context/match.ts +222 -0
  21. package/extensions/visual-context/model.ts +180 -0
  22. package/extensions/visual-context/pairing.ts +114 -0
  23. package/extensions/visual-context/parse-astro.ts +166 -0
  24. package/extensions/visual-context/parse-html.ts +72 -0
  25. package/extensions/visual-context/parse-jsx.ts +157 -0
  26. package/extensions/visual-context/parse-svelte.ts +128 -0
  27. package/extensions/visual-context/parse-vue.ts +133 -0
  28. package/extensions/visual-context/session.ts +192 -0
  29. package/extensions/visual-context/source.ts +197 -0
  30. package/extensions/visual-context/ui.ts +57 -0
  31. package/package.json +16 -7
@@ -0,0 +1,192 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from '@earendil-works/pi-coding-agent';
5
+ import { type Static, Type } from 'typebox';
6
+ import { Check } from 'typebox/value';
7
+ import {
8
+ addSelection,
9
+ assertSelectionCapacity,
10
+ MAX_CANDIDATES,
11
+ MAX_SELECTIONS,
12
+ type PendingSelection,
13
+ parseSelection,
14
+ SelectionSchema,
15
+ selectionContext,
16
+ VISUAL_SNAPSHOT,
17
+ type VisualSelection,
18
+ } from './model.ts';
19
+ import { candidateIsCurrent, resolveSelection } from './source.ts';
20
+
21
+ const CandidateSchema = Type.Object(
22
+ {
23
+ path: Type.String({
24
+ minLength: 1,
25
+ maxLength: 2_048,
26
+ pattern: '^[^\\x00-\\x1f\\x7f]*$',
27
+ }),
28
+ startLine: Type.Integer({ minimum: 1 }),
29
+ endLine: Type.Integer({ minimum: 1 }),
30
+ fingerprint: Type.String({ pattern: '^[a-f0-9]{64}$' }),
31
+ score: Type.Integer({ minimum: 0 }),
32
+ reasons: Type.Array(Type.String({ maxLength: 200 }), { maxItems: 40 }),
33
+ },
34
+ { additionalProperties: false },
35
+ );
36
+ const SnapshotSchema = Type.Object(
37
+ {
38
+ pending: Type.Array(
39
+ Type.Object(
40
+ {
41
+ selection: SelectionSchema,
42
+ resolution: Type.Object(
43
+ {
44
+ candidates: Type.Array(CandidateSchema, {
45
+ maxItems: MAX_CANDIDATES,
46
+ }),
47
+ truncated: Type.Boolean(),
48
+ capped: Type.Boolean(),
49
+ },
50
+ { additionalProperties: false },
51
+ ),
52
+ chosen: Type.Optional(CandidateSchema),
53
+ },
54
+ { additionalProperties: false },
55
+ ),
56
+ { maxItems: MAX_SELECTIONS },
57
+ ),
58
+ },
59
+ { additionalProperties: false },
60
+ );
61
+
62
+ export interface VisualStore {
63
+ pending: readonly PendingSelection[];
64
+ }
65
+
66
+ export function createVisualStore(): VisualStore {
67
+ return { pending: [] };
68
+ }
69
+
70
+ export function commitPending({
71
+ store,
72
+ pi,
73
+ pending,
74
+ }: {
75
+ readonly store: VisualStore;
76
+ readonly pi: Pick<ExtensionAPI, 'appendEntry'>;
77
+ readonly pending: readonly PendingSelection[];
78
+ }): void {
79
+ store.pending = pending;
80
+ pi.appendEntry(VISUAL_SNAPSHOT, { pending });
81
+ }
82
+
83
+ export function receiveSelection({
84
+ store,
85
+ pi,
86
+ cwd,
87
+ selection,
88
+ }: {
89
+ readonly store: VisualStore;
90
+ readonly pi: Pick<ExtensionAPI, 'appendEntry'>;
91
+ readonly cwd: string;
92
+ readonly selection: VisualSelection;
93
+ }): { readonly candidates: number } {
94
+ const existing = store.pending.find(
95
+ (entry) => entry.selection.id === selection.id,
96
+ );
97
+ if (existing) {
98
+ return { candidates: existing.resolution.candidates.length };
99
+ }
100
+ assertSelectionCapacity(store.pending);
101
+ const resolution = resolveSelection(cwd, selection);
102
+ commitPending({
103
+ store,
104
+ pi,
105
+ pending: addSelection(store.pending, { selection, resolution }),
106
+ });
107
+ return { candidates: resolution.candidates.length };
108
+ }
109
+
110
+ export function restorePending({
111
+ store,
112
+ ctx,
113
+ }: {
114
+ readonly store: VisualStore;
115
+ readonly ctx: Pick<ExtensionContext, 'sessionManager' | 'cwd'>;
116
+ }): void {
117
+ store.pending = [];
118
+ const branch = ctx.sessionManager.getBranch();
119
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
120
+ const entry = branch[index];
121
+ if (entry?.type !== 'custom' || entry.customType !== VISUAL_SNAPSHOT) {
122
+ continue;
123
+ }
124
+ if (!Check(SnapshotSchema, entry.data)) {
125
+ return;
126
+ }
127
+ const snapshot: Static<typeof SnapshotSchema> = entry.data;
128
+ store.pending = snapshot.pending.flatMap((pending) => {
129
+ const candidates = pending.resolution.candidates.filter(
130
+ (candidate) =>
131
+ candidate.startLine <= candidate.endLine &&
132
+ candidateIsCurrent(ctx.cwd, candidate),
133
+ );
134
+ let selection: VisualSelection;
135
+ try {
136
+ selection = parseSelection(pending.selection);
137
+ } catch (cause) {
138
+ throw new Error(
139
+ 'Cannot restore browser context: invalid page address in the session.',
140
+ { cause },
141
+ );
142
+ }
143
+ const chosen = candidates.find(
144
+ (candidate) =>
145
+ candidate.path === pending.chosen?.path &&
146
+ candidate.startLine === pending.chosen.startLine &&
147
+ candidate.fingerprint === pending.chosen.fingerprint,
148
+ );
149
+ return [
150
+ {
151
+ selection,
152
+ resolution: { ...pending.resolution, candidates },
153
+ chosen,
154
+ },
155
+ ];
156
+ });
157
+ return;
158
+ }
159
+ }
160
+
161
+ export function prepareContext({
162
+ store,
163
+ cwd,
164
+ }: {
165
+ readonly store: VisualStore;
166
+ readonly cwd: string;
167
+ }):
168
+ | { readonly text: string; readonly selectionIds: readonly string[] }
169
+ | undefined {
170
+ if (store.pending.length === 0) {
171
+ return undefined;
172
+ }
173
+ const pending = store.pending.map((entry) => {
174
+ const candidates = entry.resolution.candidates.filter((candidate) =>
175
+ candidateIsCurrent(cwd, candidate),
176
+ );
177
+ const chosen =
178
+ entry.chosen && candidates.includes(entry.chosen)
179
+ ? entry.chosen
180
+ : undefined;
181
+ return {
182
+ ...entry,
183
+ resolution: { ...entry.resolution, candidates },
184
+ chosen,
185
+ };
186
+ });
187
+ const context = selectionContext(pending);
188
+ return {
189
+ text: context,
190
+ selectionIds: pending.map((entry) => entry.selection.id),
191
+ };
192
+ }
@@ -0,0 +1,197 @@
1
+ import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import { extname, join, relative, sep } from 'node:path';
3
+ import ignore from 'ignore';
4
+ import { projectRelativePath } from '../shared/file-path.ts';
5
+ import { isMissingFile } from '../shared/read-file.ts';
6
+ import { InvalidMarkup } from './markup.ts';
7
+ import { matchSource, sourceFingerprint } from './match.ts';
8
+ import {
9
+ MAX_CANDIDATES,
10
+ type SourceCandidate,
11
+ type SourceResolution,
12
+ type VisualSelection,
13
+ } from './model.ts';
14
+
15
+ const SOURCE_EXTENSIONS = new Set([
16
+ '.html',
17
+ '.htm',
18
+ '.vue',
19
+ '.svelte',
20
+ '.astro',
21
+ '.jsx',
22
+ '.tsx',
23
+ ]);
24
+ const SKIP_DIRECTORIES = new Set([
25
+ 'node_modules',
26
+ 'dist',
27
+ 'build',
28
+ 'coverage',
29
+ 'vendor',
30
+ ]);
31
+ const IGNORE_FILES = ['.gitignore', '.ignore', '.fdignore'];
32
+ const MAX_ENTRIES = 4_096;
33
+ const MAX_FILE_BYTES = 262_144;
34
+ const MAX_SOURCE_BYTES = 8_388_608;
35
+ const MAX_DEPTH = 16;
36
+ const SENSITIVE_NAME =
37
+ /secret|credential|password|private.?key|(?:^|[._-])env(?:[._-]|$)/iu;
38
+
39
+ export function resolveSelection(
40
+ cwd: string,
41
+ selection: VisualSelection,
42
+ ): SourceResolution {
43
+ const root = realpathSync(cwd);
44
+ const candidates: SourceCandidate[] = [];
45
+ let entriesRead = 0;
46
+ let sourceBytes = 0;
47
+ let truncated = false;
48
+ function walk({
49
+ directory,
50
+ patterns,
51
+ depth,
52
+ }: {
53
+ readonly directory: string;
54
+ readonly patterns: readonly string[];
55
+ readonly depth: number;
56
+ }): void {
57
+ if (depth > MAX_DEPTH) {
58
+ truncated = true;
59
+ return;
60
+ }
61
+ const localPatterns = [...patterns, ...ignorePatterns(directory, root)];
62
+ const matcher = ignore().add(localPatterns);
63
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort(
64
+ (left, right) => left.name.localeCompare(right.name),
65
+ )) {
66
+ entriesRead += 1;
67
+ if (entriesRead > MAX_ENTRIES || sourceBytes >= MAX_SOURCE_BYTES) {
68
+ truncated = true;
69
+ return;
70
+ }
71
+ if (
72
+ entry.name.startsWith('.') ||
73
+ [...entry.name].some(
74
+ (character) =>
75
+ character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
76
+ ) ||
77
+ SENSITIVE_NAME.test(entry.name) ||
78
+ entry.isSymbolicLink()
79
+ ) {
80
+ continue;
81
+ }
82
+ const absolutePath = join(directory, entry.name);
83
+ const path = relative(root, absolutePath).split(sep).join('/');
84
+ if (matcher.ignores(entry.isDirectory() ? `${path}/` : path)) {
85
+ continue;
86
+ }
87
+ if (entry.isDirectory()) {
88
+ if (!SKIP_DIRECTORIES.has(entry.name)) {
89
+ walk({
90
+ directory: absolutePath,
91
+ patterns: localPatterns,
92
+ depth: depth + 1,
93
+ });
94
+ }
95
+ continue;
96
+ }
97
+ if (
98
+ !entry.isFile() ||
99
+ !SOURCE_EXTENSIONS.has(extname(entry.name).toLowerCase())
100
+ ) {
101
+ continue;
102
+ }
103
+ const size = statSync(absolutePath).size;
104
+ if (size > MAX_FILE_BYTES || sourceBytes + size > MAX_SOURCE_BYTES) {
105
+ truncated = true;
106
+ continue;
107
+ }
108
+ sourceBytes += size;
109
+ const source = readFileSync(absolutePath, 'utf8');
110
+ try {
111
+ candidates.push(...matchSource({ path, source, selection }));
112
+ } catch (cause) {
113
+ if (cause instanceof InvalidMarkup) {
114
+ truncated = true;
115
+ continue;
116
+ }
117
+ throw new Error(`Cannot inspect visual source ${path}`, { cause });
118
+ }
119
+ }
120
+ }
121
+ walk({ directory: root, patterns: [], depth: 0 });
122
+ candidates.sort(
123
+ (left, right) =>
124
+ right.score - left.score ||
125
+ left.path.localeCompare(right.path) ||
126
+ left.startLine - right.startLine,
127
+ );
128
+ return {
129
+ candidates: candidates.slice(0, MAX_CANDIDATES),
130
+ truncated,
131
+ capped: candidates.length > MAX_CANDIDATES,
132
+ };
133
+ }
134
+
135
+ function ignorePatterns(directory: string, root: string): readonly string[] {
136
+ const prefix = relative(root, directory).split(sep).join('/');
137
+ const patterns: string[] = [];
138
+ for (const file of IGNORE_FILES) {
139
+ let source: string;
140
+ try {
141
+ source = readFileSync(join(directory, file), 'utf8');
142
+ } catch (cause) {
143
+ if (isMissingFile(cause)) {
144
+ continue;
145
+ }
146
+ throw new Error(`Cannot read ignore file ${file}`, { cause });
147
+ }
148
+ for (const line of source.split(/\r?\n/u)) {
149
+ if (!line || line.startsWith('#')) {
150
+ continue;
151
+ }
152
+ const negated = line.startsWith('!');
153
+ const pattern = negated ? line.slice(1) : line;
154
+ const nested = prefix
155
+ ? `${prefix}/${pattern.startsWith('/') ? pattern.slice(1) : pattern.includes('/') ? pattern : `**/${pattern}`}`
156
+ : pattern;
157
+ patterns.push(`${negated ? '!' : ''}${nested}`);
158
+ }
159
+ }
160
+ return patterns;
161
+ }
162
+
163
+ export function candidateIsCurrent(
164
+ cwd: string,
165
+ candidate: SourceCandidate,
166
+ ): boolean {
167
+ try {
168
+ const absolutePath = realpathSync(join(cwd, candidate.path));
169
+ if (
170
+ projectRelativePath(cwd, absolutePath) === undefined ||
171
+ statSync(absolutePath).size > MAX_FILE_BYTES
172
+ ) {
173
+ return false;
174
+ }
175
+ const source = readFileSync(absolutePath, 'utf8');
176
+ return (
177
+ sourceFingerprint(source) === candidate.fingerprint &&
178
+ candidate.endLine <= source.split('\n').length
179
+ );
180
+ } catch (cause) {
181
+ if (isGone(cause)) {
182
+ return false;
183
+ }
184
+ throw new Error(`Cannot verify selected source ${candidate.path}`, {
185
+ cause,
186
+ });
187
+ }
188
+ }
189
+
190
+ // A path that turned into a file on the way reports ENOTDIR, which means the
191
+ // remembered source is gone rather than unreadable.
192
+ function isGone(cause: unknown): boolean {
193
+ return (
194
+ isMissingFile(cause) ||
195
+ (cause instanceof Error && 'code' in cause && cause.code === 'ENOTDIR')
196
+ );
197
+ }
@@ -0,0 +1,57 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import type { ExtensionContext, Theme } from '@earendil-works/pi-coding-agent';
3
+ import {
4
+ candidateLabel,
5
+ elementLabel,
6
+ type PendingSelection,
7
+ VISUAL_WIDGET,
8
+ } from './model.ts';
9
+
10
+ export const BROWSER_EXTENSION_DIRECTORY = fileURLToPath(
11
+ new URL('../../assets/visual-context/browser/', import.meta.url),
12
+ );
13
+
14
+ function pendingLabel({ resolution, chosen }: PendingSelection): string {
15
+ if (chosen) {
16
+ return `${candidateLabel(chosen)} (selected candidate)`;
17
+ }
18
+ const truncated = resolution.truncated ? ' · search truncated' : '';
19
+ const capped = resolution.capped ? ' · more candidates' : '';
20
+ return `${String(resolution.candidates.length)} candidates${truncated}${capped}`;
21
+ }
22
+
23
+ export function renderVisualWidget(
24
+ pending: readonly PendingSelection[],
25
+ theme: Theme,
26
+ ): readonly string[] {
27
+ return [
28
+ '',
29
+ theme.fg(
30
+ 'dim',
31
+ `Browser elements (${String(pending.length)}) · F7: review`,
32
+ ),
33
+ ...pending.map((entry) =>
34
+ theme.fg(
35
+ entry.chosen ? 'accent' : 'muted',
36
+ `${elementLabel(entry.selection)} — ${pendingLabel(entry)}`,
37
+ ),
38
+ ),
39
+ ];
40
+ }
41
+
42
+ export function refreshVisualWidget(
43
+ pending: readonly PendingSelection[],
44
+ ctx: Pick<ExtensionContext, 'ui' | 'mode'>,
45
+ ): void {
46
+ if (ctx.mode !== 'tui') {
47
+ return;
48
+ }
49
+ if (pending.length === 0) {
50
+ ctx.ui.setWidget(VISUAL_WIDGET, undefined);
51
+ return;
52
+ }
53
+ ctx.ui.setWidget(VISUAL_WIDGET, (_tui, theme) => ({
54
+ render: () => [...renderVisualWidget(pending, theme)],
55
+ invalidate() {},
56
+ }));
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmrdeveloper/sideroom-pi",
3
- "version": "8.12.0",
3
+ "version": "8.13.0",
4
4
  "description": "Pi coding-agent package that asks clarifying questions, shows a live todo board, enforces coding guidelines, and will not call it done while your checks are red.",
5
5
  "type": "module",
6
6
  "files": [
@@ -17,14 +17,15 @@
17
17
  "commitlint": "commitlint",
18
18
  "types": "tsc --noEmit",
19
19
  "test": "node --test 'extensions/**/*.test.ts' 'scripts/**/*.test.mjs'",
20
- "lint": "biome lint extensions scripts commitlint.config.mjs",
21
- "lint:fix": "biome lint --write extensions scripts commitlint.config.mjs",
22
- "format": "biome format --write extensions scripts package.json biome.json commitlint.config.mjs .changeset/config.json",
23
- "format:check": "biome format extensions scripts package.json biome.json commitlint.config.mjs .changeset/config.json",
20
+ "lint": "biome lint extensions assets/visual-context/browser scripts commitlint.config.mjs",
21
+ "lint:fix": "biome lint --write extensions assets/visual-context/browser scripts commitlint.config.mjs",
22
+ "format": "biome format --write extensions assets/visual-context/browser scripts package.json biome.json commitlint.config.mjs .changeset/config.json",
23
+ "format:check": "biome format extensions assets/visual-context/browser scripts package.json biome.json commitlint.config.mjs .changeset/config.json",
24
24
  "preview": "node scripts/render-preview.mjs",
25
25
  "cache:report": "node scripts/cache-report.mjs",
26
+ "package:browser": "node scripts/package-browser.mjs",
26
27
  "check:if-blocks": "node scripts/check-if-blocks.mjs",
27
- "check": "npm run types && npm run check:if-blocks && biome check extensions scripts package.json biome.json commitlint.config.mjs .changeset/config.json && npm test"
28
+ "check": "npm run types && npm run check:if-blocks && biome check extensions assets/visual-context/browser scripts package.json biome.json commitlint.config.mjs .changeset/config.json && npm test"
28
29
  },
29
30
  "engines": {
30
31
  "node": ">=22.19.0"
@@ -92,10 +93,18 @@
92
93
  "@earendil-works/pi-coding-agent": "0.87.1",
93
94
  "@earendil-works/pi-tui": "0.87.1",
94
95
  "@types/node": "^22.20.1",
96
+ "@types/ws": "^8.18.2",
97
+ "fflate": "0.8.3",
95
98
  "typebox": "1.3.7",
96
99
  "typescript": "^5.6.0"
97
100
  },
98
101
  "dependencies": {
99
- "ignore": "7.0.5"
102
+ "@astrojs/compiler": "^4.0.0",
103
+ "@babel/parser": "^7.29.9",
104
+ "@vue/compiler-dom": "^3.5.43",
105
+ "ignore": "7.0.5",
106
+ "parse5": "^8.0.1",
107
+ "svelte": "^5.57.2",
108
+ "ws": "^8.22.0"
100
109
  }
101
110
  }