@ridit/lens 0.2.0 → 0.2.2

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,121 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ writeFileSync,
7
+ unlinkSync,
8
+ } from "fs";
9
+ import path from "path";
10
+ import os from "os";
11
+ import type { Message } from "../types/chat";
12
+
13
+ const LENS_DIR = path.join(os.homedir(), ".lens");
14
+ const CHATS_DIR = path.join(LENS_DIR, "chats");
15
+
16
+ export type SavedChat = {
17
+ name: string;
18
+ repoPath: string;
19
+ messages: Message[];
20
+ savedAt: string;
21
+ userMessageCount: number;
22
+ };
23
+
24
+ function ensureChatsDir(): void {
25
+ if (!existsSync(CHATS_DIR)) mkdirSync(CHATS_DIR, { recursive: true });
26
+ }
27
+
28
+ function chatFilePath(name: string): string {
29
+ const safe = name.replace(/[^a-z0-9_-]/gi, "-").toLowerCase();
30
+ return path.join(CHATS_DIR, `${safe}.json`);
31
+ }
32
+
33
+ export function saveChat(
34
+ name: string,
35
+ repoPath: string,
36
+ messages: Message[],
37
+ ): void {
38
+ ensureChatsDir();
39
+ const data: SavedChat = {
40
+ name,
41
+ repoPath,
42
+ messages,
43
+ savedAt: new Date().toISOString(),
44
+ userMessageCount: messages.filter((m) => m.role === "user").length,
45
+ };
46
+ writeFileSync(chatFilePath(name), JSON.stringify(data, null, 2), "utf-8");
47
+ }
48
+
49
+ export function loadChat(name: string): SavedChat | null {
50
+ const filePath = chatFilePath(name);
51
+ if (!existsSync(filePath)) return null;
52
+ try {
53
+ return JSON.parse(readFileSync(filePath, "utf-8")) as SavedChat;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ export function listChats(repoPath?: string): SavedChat[] {
60
+ ensureChatsDir();
61
+ const files = readdirSync(CHATS_DIR).filter((f) => f.endsWith(".json"));
62
+ const chats: SavedChat[] = [];
63
+ for (const file of files) {
64
+ try {
65
+ const data = JSON.parse(
66
+ readFileSync(path.join(CHATS_DIR, file), "utf-8"),
67
+ ) as SavedChat;
68
+ if (!repoPath || data.repoPath === repoPath) chats.push(data);
69
+ } catch {
70
+ // skip corrupt files
71
+ }
72
+ }
73
+ return chats.sort(
74
+ (a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime(),
75
+ );
76
+ }
77
+
78
+ export function deleteChat(name: string): boolean {
79
+ const filePath = chatFilePath(name);
80
+ if (!existsSync(filePath)) return false;
81
+ try {
82
+ unlinkSync(filePath);
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ export function getChatNameSuggestions(messages: Message[]): string[] {
90
+ const userMsgs = messages
91
+ .filter((m) => m.role === "user")
92
+ .map((m) => m.content.toLowerCase().trim());
93
+
94
+ const date = new Date().toISOString().slice(0, 10);
95
+
96
+ if (userMsgs.length === 0) {
97
+ return [`chat-${date}`, `session-${date}`, `new-chat`];
98
+ }
99
+
100
+ const suggestions: string[] = [];
101
+
102
+ const toSlug = (s: string) =>
103
+ s
104
+ .replace(/[^a-z0-9\s]/g, "")
105
+ .split(/\s+/)
106
+ .filter(Boolean)
107
+ .slice(0, 4)
108
+ .join("-");
109
+
110
+ const firstSlug = toSlug(userMsgs[0]!);
111
+ if (firstSlug) suggestions.push(firstSlug);
112
+
113
+ if (userMsgs.length > 1) {
114
+ const lastSlug = toSlug(userMsgs[userMsgs.length - 1]!);
115
+ if (lastSlug && lastSlug !== firstSlug) suggestions.push(lastSlug);
116
+ }
117
+
118
+ suggestions.push(`session-${date}`);
119
+
120
+ return suggestions.slice(0, 3);
121
+ }
@@ -71,6 +71,7 @@ export function readImportantFiles(
71
71
  repoPath: string,
72
72
  files: string[],
73
73
  ): ImportantFile[] {
74
+ if (files.length > 100) return [];
74
75
  return files.filter(isImportantFile).flatMap((filePath) => {
75
76
  try {
76
77
  const content = readFileSync(path.join(repoPath, filePath), "utf-8");
@@ -0,0 +1,137 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+
5
+ export type MemoryEntryKind =
6
+ | "file-written"
7
+ | "file-read"
8
+ | "url-fetched"
9
+ | "shell-run"
10
+ | "code-applied"
11
+ | "code-skipped";
12
+
13
+ export type MemoryEntry = {
14
+ kind: MemoryEntryKind;
15
+ detail: string;
16
+ summary: string;
17
+ timestamp: string;
18
+ repoPath: string;
19
+ };
20
+
21
+ export type Memory = {
22
+ id: string;
23
+ content: string;
24
+ timestamp: string;
25
+ repoPath: string;
26
+ };
27
+
28
+ export type MemoryFile = {
29
+ entries: MemoryEntry[];
30
+ memories: Memory[];
31
+ };
32
+
33
+ const LENS_DIR = path.join(os.homedir(), ".lens");
34
+ const MEMORY_PATH = path.join(LENS_DIR, "memory.json");
35
+
36
+ function loadMemoryFile(): MemoryFile {
37
+ if (!existsSync(MEMORY_PATH)) return { entries: [], memories: [] };
38
+ try {
39
+ const data = JSON.parse(
40
+ readFileSync(MEMORY_PATH, "utf-8"),
41
+ ) as Partial<MemoryFile>;
42
+ return {
43
+ entries: data.entries ?? [],
44
+ memories: data.memories ?? [],
45
+ };
46
+ } catch {
47
+ return { entries: [], memories: [] };
48
+ }
49
+ }
50
+
51
+ function saveMemoryFile(m: MemoryFile): void {
52
+ if (!existsSync(LENS_DIR)) mkdirSync(LENS_DIR, { recursive: true });
53
+ writeFileSync(MEMORY_PATH, JSON.stringify(m, null, 2), "utf-8");
54
+ }
55
+
56
+ // ── Action entries (what the model has done) ──────────────────────────────────
57
+
58
+ export function appendMemory(entry: Omit<MemoryEntry, "timestamp">): void {
59
+ const m = loadMemoryFile();
60
+ m.entries.push({ ...entry, timestamp: new Date().toISOString() });
61
+ if (m.entries.length > 500) m.entries = m.entries.slice(-500);
62
+ saveMemoryFile(m);
63
+ }
64
+
65
+ export function buildMemorySummary(repoPath: string): string {
66
+ const m = loadMemoryFile();
67
+ const relevant = m.entries.filter((e) => e.repoPath === repoPath).slice(-50);
68
+
69
+ const memories = m.memories.filter((mem) => mem.repoPath === repoPath);
70
+
71
+ const parts: string[] = [];
72
+
73
+ if (memories.length > 0) {
74
+ parts.push(
75
+ `## MEMORIES ABOUT THIS REPO\n\n${memories
76
+ .map((mem) => `- [${mem.id}] ${mem.content}`)
77
+ .join("\n")}`,
78
+ );
79
+ }
80
+
81
+ if (relevant.length > 0) {
82
+ const lines = relevant.map((e) => {
83
+ const ts = new Date(e.timestamp).toLocaleString();
84
+ return `[${ts}] ${e.kind}: ${e.detail} — ${e.summary}`;
85
+ });
86
+ parts.push(
87
+ `## WHAT YOU HAVE ALREADY DONE IN THIS REPO\n\nThe following actions have already been completed. Do NOT repeat them unless the user explicitly asks you to redo something:\n\n${lines.join("\n")}`,
88
+ );
89
+ }
90
+
91
+ return parts.join("\n\n");
92
+ }
93
+
94
+ export function getRepoMemory(repoPath: string): MemoryEntry[] {
95
+ return loadMemoryFile().entries.filter((e) => e.repoPath === repoPath);
96
+ }
97
+
98
+ export function clearRepoMemory(repoPath: string): void {
99
+ const m = loadMemoryFile();
100
+ m.entries = m.entries.filter((e) => e.repoPath !== repoPath);
101
+ m.memories = m.memories.filter((mem) => mem.repoPath !== repoPath);
102
+ saveMemoryFile(m);
103
+ }
104
+
105
+ // ── User/model memories ───────────────────────────────────────────────────────
106
+
107
+ function generateId(): string {
108
+ return Math.random().toString(36).slice(2, 8);
109
+ }
110
+
111
+ export function addMemory(content: string, repoPath: string): Memory {
112
+ const m = loadMemoryFile();
113
+ const memory: Memory = {
114
+ id: generateId(),
115
+ content,
116
+ timestamp: new Date().toISOString(),
117
+ repoPath,
118
+ };
119
+ m.memories.push(memory);
120
+ saveMemoryFile(m);
121
+ return memory;
122
+ }
123
+
124
+ export function deleteMemory(id: string, repoPath: string): boolean {
125
+ const m = loadMemoryFile();
126
+ const before = m.memories.length;
127
+ m.memories = m.memories.filter(
128
+ (mem) => !(mem.id === id && mem.repoPath === repoPath),
129
+ );
130
+ if (m.memories.length === before) return false;
131
+ saveMemoryFile(m);
132
+ return true;
133
+ }
134
+
135
+ export function listMemories(repoPath: string): Memory[] {
136
+ return loadMemoryFile().memories.filter((mem) => mem.repoPath === repoPath);
137
+ }
@@ -0,0 +1,324 @@
1
+ import type { Tool, ToolContext, ToolResult } from "./registry";
2
+ import {
3
+ fetchUrl,
4
+ searchWeb,
5
+ runShell,
6
+ openUrl,
7
+ readFile,
8
+ readFolder,
9
+ grepFiles,
10
+ writeFile,
11
+ deleteFile,
12
+ deleteFolder,
13
+ generatePdf,
14
+ } from "../../tools";
15
+
16
+ // ── fetch ─────────────────────────────────────────────────────────────────────
17
+
18
+ export const fetchTool: Tool<string> = {
19
+ name: "fetch",
20
+ description: "load a URL",
21
+ safe: true,
22
+ permissionLabel: "fetch",
23
+ systemPromptEntry: (i) =>
24
+ `### ${i}. fetch — load a URL\n<fetch>https://example.com</fetch>`,
25
+ parseInput: (body) => body.replace(/^<|>$/g, "").trim() || null,
26
+ summariseInput: (url) => url,
27
+ execute: async (url) => {
28
+ try {
29
+ const value = await fetchUrl(url);
30
+ return { kind: "text", value };
31
+ } catch (err) {
32
+ return {
33
+ kind: "error",
34
+ value: `Fetch failed: ${err instanceof Error ? err.message : String(err)}`,
35
+ };
36
+ }
37
+ },
38
+ };
39
+
40
+ // ── shell ─────────────────────────────────────────────────────────────────────
41
+
42
+ export const shellTool: Tool<string> = {
43
+ name: "shell",
44
+ description: "run a terminal command",
45
+ safe: false,
46
+ permissionLabel: "run",
47
+ systemPromptEntry: (i) =>
48
+ `### ${i}. shell — run a terminal command\n<shell>node -v</shell>`,
49
+ parseInput: (body) => body || null,
50
+ summariseInput: (cmd) => cmd,
51
+ execute: async (cmd, ctx) => {
52
+ const value = await runShell(cmd, ctx.repoPath);
53
+ return { kind: "text", value };
54
+ },
55
+ };
56
+
57
+ // ── read-file ─────────────────────────────────────────────────────────────────
58
+
59
+ export const readFileTool: Tool<string> = {
60
+ name: "read-file",
61
+ description: "read a file from the repo",
62
+ safe: true,
63
+ permissionLabel: "read",
64
+ systemPromptEntry: (i) =>
65
+ `### ${i}. read-file — read a file from the repo\n<read-file>src/foo.ts</read-file>`,
66
+ parseInput: (body) => body || null,
67
+ summariseInput: (p) => p,
68
+ execute: (filePath, ctx) => ({
69
+ kind: "text",
70
+ value: readFile(filePath, ctx.repoPath),
71
+ }),
72
+ };
73
+
74
+ // ── read-folder ───────────────────────────────────────────────────────────────
75
+
76
+ export const readFolderTool: Tool<string> = {
77
+ name: "read-folder",
78
+ description: "list contents of a folder (files + subfolders, one level deep)",
79
+ safe: true,
80
+ permissionLabel: "folder",
81
+ systemPromptEntry: (i) =>
82
+ `### ${i}. read-folder — list contents of a folder (files + subfolders, one level deep)\n<read-folder>src/components</read-folder>`,
83
+ parseInput: (body) => body || null,
84
+ summariseInput: (p) => p,
85
+ execute: (folderPath, ctx) => ({
86
+ kind: "text",
87
+ value: readFolder(folderPath, ctx.repoPath),
88
+ }),
89
+ };
90
+
91
+ // ── grep ──────────────────────────────────────────────────────────────────────
92
+
93
+ interface GrepInput {
94
+ pattern: string;
95
+ glob: string;
96
+ }
97
+
98
+ export const grepTool: Tool<GrepInput> = {
99
+ name: "grep",
100
+ description: "search for a pattern across files in the repo",
101
+ safe: true,
102
+ permissionLabel: "grep",
103
+ systemPromptEntry: (i) =>
104
+ `### ${i}. grep — search for a pattern across files in the repo (cross-platform, no shell needed)\n<grep>\n{"pattern": "ChatRunner", "glob": "src/**/*.tsx"}\n</grep>`,
105
+ parseInput: (body) => {
106
+ try {
107
+ const parsed = JSON.parse(body) as { pattern: string; glob?: string };
108
+ return { pattern: parsed.pattern, glob: parsed.glob ?? "**/*" };
109
+ } catch {
110
+ return { pattern: body, glob: "**/*" };
111
+ }
112
+ },
113
+ summariseInput: ({ pattern, glob }) => `${pattern} — ${glob}`,
114
+ execute: ({ pattern, glob }, ctx) => ({
115
+ kind: "text",
116
+ value: grepFiles(pattern, glob, ctx.repoPath),
117
+ }),
118
+ };
119
+
120
+ // ── write-file ────────────────────────────────────────────────────────────────
121
+
122
+ interface WriteFileInput {
123
+ path: string;
124
+ content: string;
125
+ }
126
+
127
+ export const writeFileTool: Tool<WriteFileInput> = {
128
+ name: "write-file",
129
+ description: "create or overwrite a file",
130
+ safe: false,
131
+ permissionLabel: "write",
132
+ systemPromptEntry: (i) =>
133
+ `### ${i}. write-file — create or overwrite a file\n<write-file>\n{"path": "data/output.csv", "content": "col1,col2\\nval1,val2"}\n</write-file>`,
134
+ parseInput: (body) => {
135
+ try {
136
+ const parsed = JSON.parse(body) as { path: string; content: string };
137
+ if (!parsed.path) return null;
138
+ return parsed;
139
+ } catch {
140
+ return null;
141
+ }
142
+ },
143
+ summariseInput: ({ path, content }) => `${path} (${content.length} bytes)`,
144
+ execute: ({ path: filePath, content }, ctx) => ({
145
+ kind: "text",
146
+ value: writeFile(filePath, content, ctx.repoPath),
147
+ }),
148
+ };
149
+
150
+ // ── delete-file ───────────────────────────────────────────────────────────────
151
+
152
+ export const deleteFileTool: Tool<string> = {
153
+ name: "delete-file",
154
+ description: "permanently delete a single file",
155
+ safe: false,
156
+ permissionLabel: "delete",
157
+ systemPromptEntry: (i) =>
158
+ `### ${i}. delete-file — permanently delete a single file\n<delete-file>src/old-component.tsx</delete-file>`,
159
+ parseInput: (body) => body || null,
160
+ summariseInput: (p) => p,
161
+ execute: (filePath, ctx) => ({
162
+ kind: "text",
163
+ value: deleteFile(filePath, ctx.repoPath),
164
+ }),
165
+ };
166
+
167
+ // ── delete-folder ─────────────────────────────────────────────────────────────
168
+
169
+ export const deleteFolderTool: Tool<string> = {
170
+ name: "delete-folder",
171
+ description: "permanently delete a folder and all its contents",
172
+ safe: false,
173
+ permissionLabel: "delete folder",
174
+ systemPromptEntry: (i) =>
175
+ `### ${i}. delete-folder — permanently delete a folder and all its contents\n<delete-folder>src/legacy</delete-folder>`,
176
+ parseInput: (body) => body || null,
177
+ summariseInput: (p) => p,
178
+ execute: (folderPath, ctx) => ({
179
+ kind: "text",
180
+ value: deleteFolder(folderPath, ctx.repoPath),
181
+ }),
182
+ };
183
+
184
+ // ── open-url ──────────────────────────────────────────────────────────────────
185
+
186
+ export const openUrlTool: Tool<string> = {
187
+ name: "open-url",
188
+ description: "open a URL in the user's default browser",
189
+ safe: true,
190
+ permissionLabel: "open",
191
+ systemPromptEntry: (i) =>
192
+ `### ${i}. open-url — open a URL in the user's default browser\n<open-url>https://github.com/owner/repo</open-url>`,
193
+ parseInput: (body) => body.replace(/^<|>$/g, "").trim() || null,
194
+ summariseInput: (url) => url,
195
+ execute: (url) => ({ kind: "text", value: openUrl(url) }),
196
+ };
197
+
198
+ // ── generate-pdf ──────────────────────────────────────────────────────────────
199
+
200
+ interface GeneratePdfInput {
201
+ filePath: string;
202
+ content: string;
203
+ }
204
+
205
+ export const generatePdfTool: Tool<GeneratePdfInput> = {
206
+ name: "generate-pdf",
207
+ description: "generate a PDF file from markdown-style content",
208
+ safe: false,
209
+ permissionLabel: "pdf",
210
+ systemPromptEntry: (i) =>
211
+ `### ${i}. generate-pdf — generate a PDF file from markdown-style content\n<generate-pdf>\n{"path": "output/report.pdf", "content": "# Title\\n\\nSome body text."}\n</generate-pdf>`,
212
+ parseInput: (body) => {
213
+ try {
214
+ const parsed = JSON.parse(body) as {
215
+ path?: string;
216
+ filePath?: string;
217
+ content?: string;
218
+ };
219
+ return {
220
+ filePath: parsed.path ?? parsed.filePath ?? "output.pdf",
221
+ content: parsed.content ?? "",
222
+ };
223
+ } catch {
224
+ return null;
225
+ }
226
+ },
227
+ summariseInput: ({ filePath }) => filePath,
228
+ execute: ({ filePath, content }, ctx) => ({
229
+ kind: "text",
230
+ value: generatePdf(filePath, content, ctx.repoPath),
231
+ }),
232
+ };
233
+
234
+ // ── search ────────────────────────────────────────────────────────────────────
235
+
236
+ export const searchTool: Tool<string> = {
237
+ name: "search",
238
+ description: "search the internet for anything you are unsure about",
239
+ safe: true,
240
+ permissionLabel: "search",
241
+ systemPromptEntry: (i) =>
242
+ `### ${i}. search — search the internet for anything you are unsure about\n<search>how to use React useEffect cleanup function</search>`,
243
+ parseInput: (body) => body || null,
244
+ summariseInput: (q) => `"${q}"`,
245
+ execute: async (query) => {
246
+ try {
247
+ const value = await searchWeb(query);
248
+ return { kind: "text", value };
249
+ } catch (err) {
250
+ return {
251
+ kind: "error",
252
+ value: `Search failed: ${err instanceof Error ? err.message : String(err)}`,
253
+ };
254
+ }
255
+ },
256
+ };
257
+
258
+ // ── clone ─────────────────────────────────────────────────────────────────────
259
+
260
+ export const cloneTool: Tool<string> = {
261
+ name: "clone",
262
+ description: "clone a GitHub repo so you can explore and discuss it",
263
+ safe: false,
264
+ permissionLabel: "clone",
265
+ systemPromptEntry: (i) =>
266
+ `### ${i}. clone — clone a GitHub repo so you can explore and discuss it\n<clone>https://github.com/owner/repo</clone>`,
267
+ parseInput: (body) => body.replace(/^<|>$/g, "").trim() || null,
268
+ summariseInput: (url) => url,
269
+ // Clone is handled specially by ChatRunner (it triggers a UI flow),
270
+ // so execute here is just a fallback that should never run.
271
+ execute: (repoUrl) => ({
272
+ kind: "text",
273
+ value: `Clone of ${repoUrl} was handled by the UI.`,
274
+ }),
275
+ };
276
+
277
+ // ── changes ───────────────────────────────────────────────────────────────────
278
+
279
+ export interface ChangesInput {
280
+ summary: string;
281
+ patches: { path: string; content: string; isNew: boolean }[];
282
+ }
283
+
284
+ export const changesTool: Tool<ChangesInput> = {
285
+ name: "changes",
286
+ description: "propose code edits (shown as a diff for user approval)",
287
+ safe: false,
288
+ permissionLabel: "changes",
289
+ systemPromptEntry: (i) =>
290
+ `### ${i}. changes — propose code edits (shown as a diff for user approval)\n<changes>\n{"summary": "what changed and why", "patches": [{"path": "src/foo.ts", "content": "COMPLETE file content", "isNew": false}]}\n</changes>`,
291
+ parseInput: (body) => {
292
+ try {
293
+ return JSON.parse(body) as ChangesInput;
294
+ } catch {
295
+ return null;
296
+ }
297
+ },
298
+ summariseInput: ({ summary }) => summary,
299
+ // changes is handled specially by ChatRunner (diff preview UI).
300
+ execute: ({ summary }) => ({
301
+ kind: "text",
302
+ value: `Changes proposed: ${summary}`,
303
+ }),
304
+ };
305
+
306
+ // ── registerBuiltins ──────────────────────────────────────────────────────────
307
+
308
+ import { registry } from "./registry";
309
+
310
+ export function registerBuiltins(): void {
311
+ registry.register(fetchTool);
312
+ registry.register(shellTool);
313
+ registry.register(readFileTool);
314
+ registry.register(readFolderTool);
315
+ registry.register(grepTool);
316
+ registry.register(writeFileTool);
317
+ registry.register(deleteFileTool);
318
+ registry.register(deleteFolderTool);
319
+ registry.register(openUrlTool);
320
+ registry.register(generatePdfTool);
321
+ registry.register(searchTool);
322
+ registry.register(cloneTool);
323
+ registry.register(changesTool);
324
+ }