@ridit/lens 0.3.5 → 0.3.7

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.
@@ -15,16 +15,18 @@ export type MemoryEntry = {
15
15
  detail: string;
16
16
  summary: string;
17
17
  timestamp: string;
18
+ repoPath?: string;
18
19
  };
19
20
 
20
21
  export type Memory = {
21
22
  id: string;
22
23
  content: string;
23
24
  timestamp: string;
25
+ repoPath?: string;
26
+ scope: "repo" | "global";
24
27
  };
25
28
 
26
29
  export type MemoryFile = {
27
- entries: MemoryEntry[];
28
30
  memories: Memory[];
29
31
  };
30
32
 
@@ -32,17 +34,14 @@ const LENS_DIR = path.join(os.homedir(), ".lens");
32
34
  const MEMORY_PATH = path.join(LENS_DIR, "memory.json");
33
35
 
34
36
  function loadMemoryFile(): MemoryFile {
35
- if (!existsSync(MEMORY_PATH)) return { entries: [], memories: [] };
37
+ if (!existsSync(MEMORY_PATH)) return { memories: [] };
36
38
  try {
37
39
  const data = JSON.parse(
38
40
  readFileSync(MEMORY_PATH, "utf-8"),
39
41
  ) as Partial<MemoryFile>;
40
- return {
41
- entries: data.entries ?? [],
42
- memories: data.memories ?? [],
43
- };
42
+ return { memories: data.memories ?? [] };
44
43
  } catch {
45
- return { entries: [], memories: [] };
44
+ return { memories: [] };
46
45
  }
47
46
  }
48
47
 
@@ -51,26 +50,48 @@ function saveMemoryFile(m: MemoryFile): void {
51
50
  writeFileSync(MEMORY_PATH, JSON.stringify(m, null, 2), "utf-8");
52
51
  }
53
52
 
54
- // ── Action entries (what the model has done) ──────────────────────────────────
53
+ // ── Session-only action entries (in-memory, never written to disk) ────────────
55
54
 
56
- export function appendMemory(entry: Omit<MemoryEntry, "timestamp">): void {
57
- const m = loadMemoryFile();
58
- m.entries.push({ ...entry, timestamp: new Date().toISOString() });
59
- if (m.entries.length > 500) m.entries = m.entries.slice(-500);
60
- saveMemoryFile(m);
55
+ const sessionEntries: MemoryEntry[] = [];
56
+
57
+ export function appendMemory(
58
+ entry: Omit<MemoryEntry, "timestamp">,
59
+ repoPath?: string,
60
+ ): void {
61
+ sessionEntries.push({
62
+ ...entry,
63
+ repoPath,
64
+ timestamp: new Date().toISOString(),
65
+ });
66
+ if (sessionEntries.length > 200)
67
+ sessionEntries.splice(0, sessionEntries.length - 200);
61
68
  }
62
69
 
63
70
  export function buildMemorySummary(repoPath: string): string {
64
71
  const m = loadMemoryFile();
65
- const relevant = m.entries.slice(-50);
66
72
 
67
- const memories = m.memories;
73
+ const globalMemories = m.memories.filter((mem) => mem.scope === "global");
74
+ const repoMemories = m.memories.filter(
75
+ (mem) => mem.scope === "repo" && mem.repoPath === repoPath,
76
+ );
77
+
78
+ const relevant = sessionEntries
79
+ .filter((e) => !e.repoPath || e.repoPath === repoPath)
80
+ .slice(-50);
68
81
 
69
82
  const parts: string[] = [];
70
83
 
71
- if (memories.length > 0) {
84
+ if (globalMemories.length > 0) {
85
+ parts.push(
86
+ `## GLOBAL MEMORIES (apply to all repos)\n\n${globalMemories
87
+ .map((mem) => `- [${mem.id}] ${mem.content}`)
88
+ .join("\n")}`,
89
+ );
90
+ }
91
+
92
+ if (repoMemories.length > 0) {
72
93
  parts.push(
73
- `## MEMORIES ABOUT THIS REPO\n\n${memories
94
+ `## MEMORIES ABOUT THIS REPO\n\n${repoMemories
74
95
  .map((mem) => `- [${mem.id}] ${mem.content}`)
75
96
  .join("\n")}`,
76
97
  );
@@ -82,7 +103,7 @@ export function buildMemorySummary(repoPath: string): string {
82
103
  return `[${ts}] ${e.kind}: ${e.detail} — ${e.summary}`;
83
104
  });
84
105
  parts.push(
85
- `## 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")}`,
106
+ `## WHAT YOU HAVE ALREADY DONE THIS SESSION\n\nThe following actions have already been completed. Do NOT repeat them unless the user explicitly asks:\n\n${lines.join("\n")}`,
86
107
  );
87
108
  }
88
109
 
@@ -90,27 +111,42 @@ export function buildMemorySummary(repoPath: string): string {
90
111
  }
91
112
 
92
113
  export function getRepoMemory(repoPath: string): MemoryEntry[] {
93
- return loadMemoryFile().entries;
114
+ return sessionEntries.filter((e) => !e.repoPath || e.repoPath === repoPath);
94
115
  }
95
116
 
96
117
  export function clearRepoMemory(repoPath: string): void {
118
+ // clear session entries for this repo
119
+ const toRemove = sessionEntries
120
+ .map((e, i) => (e.repoPath === repoPath ? i : -1))
121
+ .filter((i) => i >= 0)
122
+ .reverse();
123
+ for (const i of toRemove) sessionEntries.splice(i, 1);
124
+
125
+ // clear persisted memories for this repo (keep global)
97
126
  const m = loadMemoryFile();
98
- m.entries = m.entries = [];
99
- m.memories = m.memories = [];
127
+ m.memories = m.memories.filter(
128
+ (mem) => mem.scope === "global" || mem.repoPath !== repoPath,
129
+ );
100
130
  saveMemoryFile(m);
101
131
  }
102
132
 
103
- // ── User/model memories ───────────────────────────────────────────────────────
104
-
105
133
  function generateId(): string {
106
134
  return Math.random().toString(36).slice(2, 8);
107
135
  }
108
136
 
109
137
  export function addMemory(content: string, repoPath: string): Memory {
110
138
  const m = loadMemoryFile();
139
+
140
+ const isGlobal = content.startsWith("[global]");
141
+ const cleanContent = isGlobal
142
+ ? content.replace("[global]", "").trim()
143
+ : content;
144
+
111
145
  const memory: Memory = {
112
146
  id: generateId(),
113
- content,
147
+ content: cleanContent,
148
+ repoPath: isGlobal ? undefined : repoPath,
149
+ scope: isGlobal ? "global" : "repo",
114
150
  timestamp: new Date().toISOString(),
115
151
  };
116
152
  m.memories.push(memory);
@@ -121,12 +157,53 @@ export function addMemory(content: string, repoPath: string): Memory {
121
157
  export function deleteMemory(id: string, repoPath: string): boolean {
122
158
  const m = loadMemoryFile();
123
159
  const before = m.memories.length;
124
- m.memories = m.memories.filter((mem) => !(mem.id === id));
160
+ m.memories = m.memories.filter((mem) => mem.id !== id);
125
161
  if (m.memories.length === before) return false;
126
162
  saveMemoryFile(m);
127
163
  return true;
128
164
  }
129
165
 
130
166
  export function listMemories(repoPath: string): Memory[] {
131
- return loadMemoryFile().memories;
167
+ return loadMemoryFile().memories.filter(
168
+ (mem) => mem.scope === "global" || mem.repoPath === repoPath,
169
+ );
170
+ }
171
+
172
+ type SessionToolLog = {
173
+ toolName: string;
174
+ input: string;
175
+ resultPreview: string;
176
+ timestamp: string;
177
+ };
178
+
179
+ const sessionToolLog: SessionToolLog[] = [];
180
+
181
+ export function logToolCall(
182
+ toolName: string,
183
+ input: string,
184
+ result: string,
185
+ repoPath?: string,
186
+ ): void {
187
+ sessionToolLog.push({
188
+ toolName,
189
+ input,
190
+ resultPreview: result.slice(0, 120),
191
+ timestamp: new Date().toISOString(),
192
+ });
193
+ if (sessionToolLog.length > 100)
194
+ sessionToolLog.splice(0, sessionToolLog.length - 100);
195
+ }
196
+
197
+ export function getSessionToolSummary(repoPath: string): string {
198
+ if (sessionToolLog.length === 0) return "";
199
+ const recent = sessionToolLog.slice(-30);
200
+ const lines = recent.map((e) => {
201
+ const input = e.input.length > 60 ? e.input.slice(0, 60) + "…" : e.input;
202
+ return `- ${e.toolName}: ${input}`;
203
+ });
204
+ return `## TOOLS ALREADY USED THIS SESSION\n\nDo NOT call these again unless the user explicitly asks:\n\n${lines.join("\n")}`;
205
+ }
206
+
207
+ export function clearSessionLog(): void {
208
+ sessionToolLog.splice(0, sessionToolLog.length);
132
209
  }
@@ -1,4 +1,5 @@
1
1
  import type { Tool, ToolContext } from "@ridit/lens-sdk";
2
+ import { TOOL_TAGS } from "@ridit/lens-sdk";
2
3
  import {
3
4
  fetchUrl,
4
5
  searchWeb,
@@ -23,6 +24,7 @@ export const fetchTool: Tool<string> = {
23
24
  name: "fetch",
24
25
  description: "load a URL",
25
26
  safe: true,
27
+ tag: TOOL_TAGS.net,
26
28
  permissionLabel: "fetch",
27
29
  systemPromptEntry: (i) =>
28
30
  `### ${i}. fetch — load a URL\n<fetch>https://example.com</fetch>`,
@@ -45,6 +47,7 @@ export const shellTool: Tool<string> = {
45
47
  name: "shell",
46
48
  description: "run a terminal command",
47
49
  safe: false,
50
+ tag: TOOL_TAGS.shell,
48
51
  permissionLabel: "run",
49
52
  systemPromptEntry: (i) =>
50
53
  `### ${i}. shell — run a terminal command\n<shell>node -v</shell>`,
@@ -60,6 +63,7 @@ export const readFileTool: Tool<string> = {
60
63
  name: "read-file",
61
64
  description: "read a file from the repo",
62
65
  safe: true,
66
+ tag: TOOL_TAGS.read,
63
67
  permissionLabel: "read",
64
68
  systemPromptEntry: (i) =>
65
69
  `### ${i}. read-file — read a file from the repo\n<read-file>src/foo.ts</read-file>`,
@@ -74,6 +78,7 @@ export const readFileTool: Tool<string> = {
74
78
  export const readFolderTool: Tool<string> = {
75
79
  name: "read-folder",
76
80
  description: "list contents of a folder (files + subfolders, one level deep)",
81
+ tag: TOOL_TAGS.read,
77
82
  safe: true,
78
83
  permissionLabel: "folder",
79
84
  systemPromptEntry: (i) =>
@@ -94,6 +99,7 @@ interface GrepInput {
94
99
  export const grepTool: Tool<GrepInput> = {
95
100
  name: "grep",
96
101
  description: "search for a pattern across files in the repo",
102
+ tag: TOOL_TAGS.find,
97
103
  safe: true,
98
104
  permissionLabel: "grep",
99
105
  systemPromptEntry: (i) =>
@@ -124,18 +130,45 @@ interface WriteFileInput {
124
130
  export const writeFileTool: Tool<WriteFileInput> = {
125
131
  name: "write-file",
126
132
  description: "create or overwrite a file",
133
+ tag: TOOL_TAGS.write,
127
134
  safe: false,
128
135
  permissionLabel: "write",
129
136
  systemPromptEntry: (i) =>
130
137
  `### ${i}. write-file — create or overwrite a file\n<write-file>\n{"path": "data/output.csv", "content": "col1,col2\\nval1,val2"}\n</write-file>`,
131
138
  parseInput: (body) => {
139
+ const tryParse = (s: string) => {
140
+ try {
141
+ const parsed = JSON.parse(s) as { path: string; content: string };
142
+ if (!parsed.path || parsed.content === undefined) return null;
143
+ return { ...parsed, path: parsed.path.replace(/\\/g, "/") };
144
+ } catch {
145
+ return null;
146
+ }
147
+ };
148
+
149
+ const first = tryParse(body.trim());
150
+ if (first) return first;
151
+
132
152
  try {
133
- const parsed = JSON.parse(body) as { path: string; content: string };
134
- if (!parsed.path) return null;
135
- return { ...parsed, path: parsed.path.replace(/\\/g, "/") };
136
- } catch {
137
- return null;
153
+ const sanitized = body
154
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "")
155
+ .replace(/\n/g, "\\n")
156
+ .replace(/\r/g, "\\r")
157
+ .replace(/\t/g, "\\t");
158
+ const second = tryParse(sanitized);
159
+ if (second) return second;
160
+ } catch {}
161
+
162
+ const pathMatch = body.match(/"path"\s*:\s*"([^"]+)"/);
163
+ const contentMatch = body.match(/"content"\s*:\s*"([\s\S]*)"\s*}?\s*$/);
164
+ if (pathMatch && contentMatch && contentMatch[1] !== undefined) {
165
+ return {
166
+ path: pathMatch[1]!.replace(/\\/g, "/"),
167
+ content: contentMatch[1]!.replace(/\\n/g, "\n").replace(/\\t/g, "\t"),
168
+ };
138
169
  }
170
+
171
+ return null;
139
172
  },
140
173
  summariseInput: ({ path, content }) => `${path} (${content.length} bytes)`,
141
174
  execute: ({ path: filePath, content }, ctx) => ({
@@ -147,6 +180,7 @@ export const writeFileTool: Tool<WriteFileInput> = {
147
180
  export const deleteFileTool: Tool<string> = {
148
181
  name: "delete-file",
149
182
  description: "permanently delete a single file",
183
+ tag: TOOL_TAGS.delete,
150
184
  safe: false,
151
185
  permissionLabel: "delete",
152
186
  systemPromptEntry: (i) =>
@@ -162,6 +196,7 @@ export const deleteFileTool: Tool<string> = {
162
196
  export const deleteFolderTool: Tool<string> = {
163
197
  name: "delete-folder",
164
198
  description: "permanently delete a folder and all its contents",
199
+ tag: TOOL_TAGS.delete,
165
200
  safe: false,
166
201
  permissionLabel: "delete folder",
167
202
  systemPromptEntry: (i) =>
@@ -177,6 +212,7 @@ export const deleteFolderTool: Tool<string> = {
177
212
  export const openUrlTool: Tool<string> = {
178
213
  name: "open-url",
179
214
  description: "open a URL in the user's default browser",
215
+ tag: TOOL_TAGS.net,
180
216
  safe: true,
181
217
  permissionLabel: "open",
182
218
  systemPromptEntry: (i) =>
@@ -194,6 +230,7 @@ interface GeneratePdfInput {
194
230
  export const generatePdfTool: Tool<GeneratePdfInput> = {
195
231
  name: "generate-pdf",
196
232
  description: "generate a PDF file from markdown-style content",
233
+ tag: TOOL_TAGS.write,
197
234
  safe: false,
198
235
  permissionLabel: "pdf",
199
236
  systemPromptEntry: (i) =>
@@ -222,6 +259,7 @@ export const generatePdfTool: Tool<GeneratePdfInput> = {
222
259
 
223
260
  export const searchTool: Tool<string> = {
224
261
  name: "search",
262
+ tag: TOOL_TAGS.net,
225
263
  description: "search the internet for anything you are unsure about",
226
264
  safe: true,
227
265
  permissionLabel: "search",
@@ -245,6 +283,7 @@ export const searchTool: Tool<string> = {
245
283
  export const cloneTool: Tool<string> = {
246
284
  name: "clone",
247
285
  description: "clone a GitHub repo so you can explore and discuss it",
286
+ tag: TOOL_TAGS.write,
248
287
  safe: false,
249
288
  permissionLabel: "clone",
250
289
  systemPromptEntry: (i) =>
@@ -265,6 +304,7 @@ export interface ChangesInput {
265
304
  export const changesTool: Tool<ChangesInput> = {
266
305
  name: "changes",
267
306
  description: "propose code edits (shown as a diff for user approval)",
307
+ tag: TOOL_TAGS.write,
268
308
  safe: false,
269
309
  permissionLabel: "changes",
270
310
  systemPromptEntry: (i) =>
@@ -290,6 +330,7 @@ interface ReadFilesInput {
290
330
  export const readFilesTool: Tool<ReadFilesInput> = {
291
331
  name: "read-files",
292
332
  description: "read multiple files from the repo at once",
333
+ tag: TOOL_TAGS.read,
293
334
  safe: true,
294
335
  permissionLabel: "read",
295
336
  systemPromptEntry: (i) =>
@@ -1,4 +1,23 @@
1
- import type { Tool } from "@ridit/lens-sdk";
1
+ import type { Tool, ToolTag } from "@ridit/lens-sdk";
2
+ import type { Intent } from "../intentClassifier";
3
+
4
+ /**
5
+ * Broad capability category for a tool.
6
+ * Used to filter the system prompt based on classified user intent.
7
+ *
8
+ * "read" — safe, purely observational (read-file, read-folder, grep, etc.)
9
+ * "net" — outbound network (fetch, search, clone, open-url)
10
+ * "write" — creates or overwrites file content (write-file, changes, generate-pdf)
11
+ * "delete" — destructive removal (delete-file, delete-folder)
12
+ * "shell" — arbitrary shell execution
13
+ */
14
+
15
+ /** Tools allowed for each intent level */
16
+ const INTENT_ALLOWED: Record<Intent, ToolTag[]> = {
17
+ readonly: ["read", "net"],
18
+ mutating: ["read", "net", "write", "delete", "shell"],
19
+ any: ["read", "net", "write", "delete", "shell"],
20
+ };
2
21
 
3
22
  class ToolRegistry {
4
23
  private tools = new Map<string, Tool<unknown>>();
@@ -27,17 +46,54 @@ class ToolRegistry {
27
46
  }
28
47
 
29
48
  /**
30
- * Build the TOOLS section of the system prompt from all registered tools.
49
+ * Returns tool names that are allowed for the given intent.
50
+ * Falls back to all names when a tool has no tag (legacy / addons).
51
+ */
52
+ namesForIntent(intent: Intent): string[] {
53
+ const allowed = new Set(INTENT_ALLOWED[intent]);
54
+ return Array.from(this.tools.values())
55
+ .filter((t) => {
56
+ const tag = (t as any).tag as ToolTag | undefined;
57
+ // No tag = addon / unknown → always allow (conservative)
58
+ if (!tag) return true;
59
+ return allowed.has(tag);
60
+ })
61
+ .map((t) => t.name);
62
+ }
63
+
64
+ /**
65
+ * Build the TOOLS section of the system prompt from all registered tools,
66
+ * optionally scoped to a specific intent.
67
+ *
68
+ * When intent is "readonly", write/delete/shell tools are omitted entirely
69
+ * so the LLM never sees them and can't hallucinate calls to them.
31
70
  */
32
- buildSystemPromptSection(): string {
71
+ buildSystemPromptSection(intent: Intent = "any"): string {
72
+ const allowed = new Set(INTENT_ALLOWED[intent]);
73
+
74
+ const visible = Array.from(this.tools.values()).filter((t) => {
75
+ const tag = (t as any).tag as ToolTag | undefined;
76
+ if (!tag) return true; // addon without tag → always show
77
+ return allowed.has(tag);
78
+ });
79
+
33
80
  const lines: string[] = ["## TOOLS\n"];
34
- lines.push(
35
- "You have exactly " +
36
- this.tools.size +
37
- " tools. To use a tool you MUST wrap it in the exact XML tags shown below — no other format will work.\n",
38
- );
81
+
82
+ if (intent === "readonly") {
83
+ lines.push(
84
+ `You have ${visible.length} tools available for this read-only request. ` +
85
+ `Do NOT attempt to write, delete, or run shell commands — ` +
86
+ `those tools are not available right now.\n`,
87
+ );
88
+ } else {
89
+ lines.push(
90
+ `You have exactly ${visible.length} tools. To use a tool you MUST wrap it ` +
91
+ `in the exact XML tags shown below — no other format will work.\n`,
92
+ );
93
+ }
94
+
39
95
  let i = 1;
40
- for (const tool of this.tools.values()) {
96
+ for (const tool of visible) {
41
97
  lines.push(tool.systemPromptEntry(i++));
42
98
  }
43
99
  return lines.join("\n");