trickle-cli 0.1.213 → 0.1.215
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/dist/commands/hints.d.ts +16 -0
- package/dist/commands/hints.js +334 -0
- package/dist/commands/run.js +2 -2
- package/dist/index.js +9 -0
- package/package.json +1 -1
- package/src/commands/hints.ts +324 -0
- package/src/commands/run.ts +2 -2
- package/src/index.ts +10 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output source code with inline type hints (like VSCode inlay hints).
|
|
3
|
+
*
|
|
4
|
+
* Reads .trickle/variables.jsonl and renders the source with type
|
|
5
|
+
* annotations inserted inline — suitable for AI agents that need
|
|
6
|
+
* to understand runtime types without running the code.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* trickle hints src/app.py # all hints for a file
|
|
10
|
+
* trickle hints src/app.py --values # include sample values
|
|
11
|
+
* trickle hints # all observed files
|
|
12
|
+
*/
|
|
13
|
+
export interface HintsOptions {
|
|
14
|
+
values?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function hintsCommand(targetFile: string | undefined, opts: HintsOptions): Promise<void>;
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Output source code with inline type hints (like VSCode inlay hints).
|
|
4
|
+
*
|
|
5
|
+
* Reads .trickle/variables.jsonl and renders the source with type
|
|
6
|
+
* annotations inserted inline — suitable for AI agents that need
|
|
7
|
+
* to understand runtime types without running the code.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* trickle hints src/app.py # all hints for a file
|
|
11
|
+
* trickle hints src/app.py --values # include sample values
|
|
12
|
+
* trickle hints # all observed files
|
|
13
|
+
*/
|
|
14
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
17
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
18
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(o, k2, desc);
|
|
21
|
+
}) : (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
o[k2] = m[k];
|
|
24
|
+
}));
|
|
25
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
26
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
27
|
+
}) : function(o, v) {
|
|
28
|
+
o["default"] = v;
|
|
29
|
+
});
|
|
30
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
31
|
+
var ownKeys = function(o) {
|
|
32
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
33
|
+
var ar = [];
|
|
34
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35
|
+
return ar;
|
|
36
|
+
};
|
|
37
|
+
return ownKeys(o);
|
|
38
|
+
};
|
|
39
|
+
return function (mod) {
|
|
40
|
+
if (mod && mod.__esModule) return mod;
|
|
41
|
+
var result = {};
|
|
42
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
43
|
+
__setModuleDefault(result, mod);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.hintsCommand = hintsCommand;
|
|
49
|
+
const fs = __importStar(require("fs"));
|
|
50
|
+
const path = __importStar(require("path"));
|
|
51
|
+
function typeToString(node) {
|
|
52
|
+
if (!node)
|
|
53
|
+
return "unknown";
|
|
54
|
+
switch (node.kind) {
|
|
55
|
+
case "primitive": return node.name || "unknown";
|
|
56
|
+
case "object": {
|
|
57
|
+
if (!node.properties)
|
|
58
|
+
return node.class_name || "object";
|
|
59
|
+
if (node.class_name === "Tensor" || node.class_name === "ndarray") {
|
|
60
|
+
const shape = node.properties["shape"]?.name;
|
|
61
|
+
const dtype = node.properties["dtype"]?.name;
|
|
62
|
+
const parts = [];
|
|
63
|
+
if (shape)
|
|
64
|
+
parts.push(`shape=${shape}`);
|
|
65
|
+
if (dtype)
|
|
66
|
+
parts.push(`dtype=${dtype}`);
|
|
67
|
+
return `${node.class_name}(${parts.join(", ")})`;
|
|
68
|
+
}
|
|
69
|
+
if (node.class_name === "DataFrame") {
|
|
70
|
+
const rows = node.properties["rows"]?.name;
|
|
71
|
+
const cols = node.properties["cols"]?.name;
|
|
72
|
+
const parts = [];
|
|
73
|
+
if (rows && cols)
|
|
74
|
+
parts.push(`${rows}x${cols}`);
|
|
75
|
+
return `DataFrame(${parts.join(", ")})`;
|
|
76
|
+
}
|
|
77
|
+
if (node.class_name) {
|
|
78
|
+
const keys = Object.keys(node.properties).slice(0, 4);
|
|
79
|
+
const extra = Object.keys(node.properties).length > 4 ? `, +${Object.keys(node.properties).length - 4}` : "";
|
|
80
|
+
return `${node.class_name}(${keys.join(", ")}${extra})`;
|
|
81
|
+
}
|
|
82
|
+
const props = Object.entries(node.properties).slice(0, 5)
|
|
83
|
+
.map(([k, v]) => `${k}: ${typeToString(v)}`);
|
|
84
|
+
const extra = Object.keys(node.properties).length > 5 ? `, +${Object.keys(node.properties).length - 5}` : "";
|
|
85
|
+
return `{${props.join(", ")}${extra}}`;
|
|
86
|
+
}
|
|
87
|
+
case "array": {
|
|
88
|
+
const elem = node.element;
|
|
89
|
+
if (elem?.kind === "union") {
|
|
90
|
+
const members = elem.elements || elem.members;
|
|
91
|
+
if (members && members.length > 0) {
|
|
92
|
+
const names = new Set(members.map(m => m.class_name).filter(Boolean));
|
|
93
|
+
if (names.size === 1)
|
|
94
|
+
return `${names.values().next().value}[]`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return `${typeToString(elem || { kind: "primitive", name: "unknown" })}[]`;
|
|
98
|
+
}
|
|
99
|
+
case "tuple": return `[${(node.elements || []).map(typeToString).join(", ")}]`;
|
|
100
|
+
case "union": {
|
|
101
|
+
const members = node.elements || node.members;
|
|
102
|
+
if (!members)
|
|
103
|
+
return "unknown";
|
|
104
|
+
const names = new Set(members.map(m => m.class_name).filter(Boolean));
|
|
105
|
+
if (names.size === 1)
|
|
106
|
+
return names.values().next().value;
|
|
107
|
+
return members.map(typeToString).join(" | ");
|
|
108
|
+
}
|
|
109
|
+
case "function": return "Function";
|
|
110
|
+
default: return node.kind;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function formatSample(sample) {
|
|
114
|
+
if (sample === null || sample === undefined)
|
|
115
|
+
return "";
|
|
116
|
+
if (typeof sample === "string") {
|
|
117
|
+
if (sample.length > 40)
|
|
118
|
+
return `"${sample.substring(0, 37)}..."`;
|
|
119
|
+
return `"${sample}"`;
|
|
120
|
+
}
|
|
121
|
+
if (typeof sample === "number") {
|
|
122
|
+
return Number.isInteger(sample) ? String(sample) : sample.toFixed(4);
|
|
123
|
+
}
|
|
124
|
+
if (typeof sample === "boolean")
|
|
125
|
+
return String(sample);
|
|
126
|
+
if (Array.isArray(sample))
|
|
127
|
+
return `[...${sample.length} items]`;
|
|
128
|
+
const s = String(sample);
|
|
129
|
+
return s.length > 40 ? s.substring(0, 37) + "..." : s;
|
|
130
|
+
}
|
|
131
|
+
async function hintsCommand(targetFile, opts) {
|
|
132
|
+
const localDir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), ".trickle");
|
|
133
|
+
const varsFile = path.join(localDir, "variables.jsonl");
|
|
134
|
+
if (!fs.existsSync(varsFile)) {
|
|
135
|
+
console.error("No trickle data found. Run your app with trickle first:");
|
|
136
|
+
console.error(" trickle run python app.py");
|
|
137
|
+
console.error(" trickle run node app.js");
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
// Load and deduplicate variable observations (last wins per file:line:varName)
|
|
141
|
+
const obsMap = new Map();
|
|
142
|
+
for (const line of fs.readFileSync(varsFile, "utf-8").split("\n").filter(Boolean)) {
|
|
143
|
+
try {
|
|
144
|
+
const v = JSON.parse(line);
|
|
145
|
+
if (v.kind !== "variable")
|
|
146
|
+
continue;
|
|
147
|
+
const key = `${v.file}:${v.line}:${v.varName}`;
|
|
148
|
+
obsMap.set(key, v);
|
|
149
|
+
}
|
|
150
|
+
catch { }
|
|
151
|
+
}
|
|
152
|
+
const vars = [...obsMap.values()];
|
|
153
|
+
// Filter by target file
|
|
154
|
+
let filtered = vars;
|
|
155
|
+
if (targetFile) {
|
|
156
|
+
const normalized = targetFile.replace(/^\.\//, "");
|
|
157
|
+
filtered = vars.filter(v => {
|
|
158
|
+
const relPath = path.relative(process.cwd(), v.file);
|
|
159
|
+
return relPath.includes(normalized) || v.file.includes(normalized);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (filtered.length === 0) {
|
|
163
|
+
console.error(targetFile ? `No observations found for "${targetFile}".` : "No observations found.");
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
// Group by file
|
|
167
|
+
const byFile = new Map();
|
|
168
|
+
for (const v of filtered) {
|
|
169
|
+
if (!byFile.has(v.file))
|
|
170
|
+
byFile.set(v.file, []);
|
|
171
|
+
byFile.get(v.file).push(v);
|
|
172
|
+
}
|
|
173
|
+
for (const [absFile, fileVars] of byFile) {
|
|
174
|
+
let sourceLines = [];
|
|
175
|
+
let relPath = "";
|
|
176
|
+
// Handle notebook cell paths: __notebook__cell_N.py
|
|
177
|
+
const cellMatch = absFile.match(/__notebook__cell_(\d+)\.py$/);
|
|
178
|
+
if (cellMatch) {
|
|
179
|
+
// Try to find the notebook .ipynb in the same directory
|
|
180
|
+
const dir = path.dirname(absFile);
|
|
181
|
+
const cellIdx = parseInt(cellMatch[1]);
|
|
182
|
+
const ipynbFiles = fs.existsSync(dir)
|
|
183
|
+
? fs.readdirSync(dir).filter(f => f.endsWith(".ipynb"))
|
|
184
|
+
: [];
|
|
185
|
+
let foundCell = false;
|
|
186
|
+
for (const nbFile of ipynbFiles) {
|
|
187
|
+
const nbPath = path.join(dir, nbFile);
|
|
188
|
+
try {
|
|
189
|
+
const nb = JSON.parse(fs.readFileSync(nbPath, "utf-8"));
|
|
190
|
+
const cells = nb.cells || [];
|
|
191
|
+
// trickle's cellIdx is the Nth code cell execution (1-based).
|
|
192
|
+
// Try direct index first, then count code cells.
|
|
193
|
+
let cellSource;
|
|
194
|
+
// Try: cellIdx maps to 0-based index in all cells
|
|
195
|
+
if (cells[cellIdx - 1]?.cell_type === "code") {
|
|
196
|
+
cellSource = cells[cellIdx - 1].source;
|
|
197
|
+
}
|
|
198
|
+
// Fallback: count code cells
|
|
199
|
+
if (!cellSource) {
|
|
200
|
+
let codeCount = 0;
|
|
201
|
+
for (const c of cells) {
|
|
202
|
+
if (c.cell_type === "code") {
|
|
203
|
+
codeCount++;
|
|
204
|
+
if (codeCount === cellIdx) {
|
|
205
|
+
cellSource = c.source;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (!cellSource)
|
|
212
|
+
continue;
|
|
213
|
+
sourceLines = (Array.isArray(cellSource) ? cellSource.join("") : String(cellSource)).split("\n");
|
|
214
|
+
relPath = `${path.relative(process.cwd(), nbPath)} [cell ${cellIdx}]`;
|
|
215
|
+
foundCell = true;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (!foundCell) {
|
|
223
|
+
// Can't find source — output just the observations in a summary format
|
|
224
|
+
relPath = absFile.replace(/.*__notebook__/, "notebook ").replace(/\.py$/, "");
|
|
225
|
+
const maxLine = Math.max(...fileVars.map(v => v.line));
|
|
226
|
+
sourceLines = Array.from({ length: maxLine }, (_, i) => `# line ${i + 1}`);
|
|
227
|
+
// Place observations as standalone lines
|
|
228
|
+
const lineObs = new Map();
|
|
229
|
+
for (const v of fileVars) {
|
|
230
|
+
if (!lineObs.has(v.line))
|
|
231
|
+
lineObs.set(v.line, new Map());
|
|
232
|
+
lineObs.get(v.line).set(v.varName, v);
|
|
233
|
+
}
|
|
234
|
+
console.log(`# ${relPath}`);
|
|
235
|
+
console.log("```python");
|
|
236
|
+
for (const [lineNo, obs] of [...lineObs.entries()].sort((a, b) => a[0] - b[0])) {
|
|
237
|
+
for (const v of obs.values()) {
|
|
238
|
+
const typeStr = typeToString(v.type);
|
|
239
|
+
const scope = v.funcName ? ` (in ${v.funcName})` : "";
|
|
240
|
+
if (opts.values && v.sample !== undefined) {
|
|
241
|
+
console.log(`${v.varName}: ${typeStr} = ${formatSample(v.sample)}${scope}`);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
console.log(`${v.varName}: ${typeStr}${scope}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
console.log("```");
|
|
249
|
+
console.log("");
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
if (!fs.existsSync(absFile))
|
|
255
|
+
continue;
|
|
256
|
+
sourceLines = fs.readFileSync(absFile, "utf-8").split("\n");
|
|
257
|
+
relPath = path.relative(process.cwd(), absFile);
|
|
258
|
+
}
|
|
259
|
+
// Build line → varName → observation map
|
|
260
|
+
const lineObs = new Map();
|
|
261
|
+
for (const v of fileVars) {
|
|
262
|
+
if (!lineObs.has(v.line))
|
|
263
|
+
lineObs.set(v.line, new Map());
|
|
264
|
+
lineObs.get(v.line).set(v.varName, v);
|
|
265
|
+
}
|
|
266
|
+
console.log(`# ${relPath}`);
|
|
267
|
+
console.log("```python");
|
|
268
|
+
for (let i = 0; i < sourceLines.length; i++) {
|
|
269
|
+
const lineNo = i + 1;
|
|
270
|
+
const src = sourceLines[i];
|
|
271
|
+
const obs = lineObs.get(lineNo);
|
|
272
|
+
if (!obs || obs.size === 0) {
|
|
273
|
+
console.log(src);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
// Insert type hints inline after variable names
|
|
277
|
+
let annotated = src;
|
|
278
|
+
// Sort by position in line (rightmost first so indices don't shift)
|
|
279
|
+
const entries = [...obs.values()].sort((a, b) => {
|
|
280
|
+
const aIdx = src.indexOf(a.varName);
|
|
281
|
+
const bIdx = src.indexOf(b.varName);
|
|
282
|
+
return bIdx - aIdx; // rightmost first
|
|
283
|
+
});
|
|
284
|
+
for (const v of entries) {
|
|
285
|
+
const typeStr = typeToString(v.type);
|
|
286
|
+
// Skip if the type is just "unknown" or not useful
|
|
287
|
+
if (typeStr === "unknown")
|
|
288
|
+
continue;
|
|
289
|
+
// Find variable in the line
|
|
290
|
+
const isAttr = v.varName.includes(".");
|
|
291
|
+
const pattern = isAttr
|
|
292
|
+
? new RegExp(v.varName.replace(/\./g, "\\."))
|
|
293
|
+
: new RegExp(`\\b${v.varName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
294
|
+
const match = pattern.exec(annotated);
|
|
295
|
+
if (!match)
|
|
296
|
+
continue;
|
|
297
|
+
const varEnd = match.index + v.varName.length;
|
|
298
|
+
const afterVar = annotated.substring(varEnd).trimStart();
|
|
299
|
+
// Check this is an assignment/declaration context
|
|
300
|
+
const beforeVar = annotated.substring(0, match.index);
|
|
301
|
+
const isPython = absFile.endsWith(".py");
|
|
302
|
+
if (isPython) {
|
|
303
|
+
const isAssignment = afterVar.startsWith("=") && !afterVar.startsWith("==");
|
|
304
|
+
const isAnnotated = afterVar.startsWith(":");
|
|
305
|
+
const isForVar = /\bfor\s+$/.test(beforeVar) || /\bfor\s+.*,\s*$/.test(beforeVar);
|
|
306
|
+
const isWithAs = /\bas\s+$/.test(beforeVar);
|
|
307
|
+
const isFuncParam = /\b(?:async\s+)?def\s+\w+\s*\(/.test(beforeVar) &&
|
|
308
|
+
(afterVar.startsWith(",") || afterVar.startsWith(")") || afterVar.startsWith("=") || afterVar.startsWith(":"));
|
|
309
|
+
const isBareAssign = /^\s*$/.test(beforeVar) || /,\s*$/.test(beforeVar);
|
|
310
|
+
if (!isForVar && !isWithAs && !isFuncParam && !((isBareAssign) && (isAssignment || isAnnotated)))
|
|
311
|
+
continue;
|
|
312
|
+
if (isAnnotated && !isFuncParam)
|
|
313
|
+
continue;
|
|
314
|
+
if (isFuncParam && afterVar.startsWith(":"))
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
// Build the hint string
|
|
318
|
+
let hint;
|
|
319
|
+
if (opts.values && v.sample !== undefined && v.sample !== null) {
|
|
320
|
+
const sampleStr = formatSample(v.sample);
|
|
321
|
+
hint = sampleStr ? `: ${typeStr} = ${sampleStr}` : `: ${typeStr}`;
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
hint = `: ${typeStr}`;
|
|
325
|
+
}
|
|
326
|
+
// Insert after variable name
|
|
327
|
+
annotated = annotated.substring(0, varEnd) + hint + annotated.substring(varEnd);
|
|
328
|
+
}
|
|
329
|
+
console.log(annotated);
|
|
330
|
+
}
|
|
331
|
+
console.log("```");
|
|
332
|
+
console.log("");
|
|
333
|
+
}
|
|
334
|
+
}
|
package/dist/commands/run.js
CHANGED
|
@@ -409,8 +409,8 @@ async function executeSingleRun(instrumentedCommand, env, opts, singleFile, loca
|
|
|
409
409
|
const localDir = env.TRICKLE_LOCAL_DIR || process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), ".trickle");
|
|
410
410
|
const jsonlPath = path.join(localDir, "observations.jsonl");
|
|
411
411
|
const { generateLocalStubs, generateFromJsonl, readObservations } = await Promise.resolve().then(() => __importStar(require("../local-codegen")));
|
|
412
|
-
// Check if stub generation is enabled (TRICKLE_STUBS=
|
|
413
|
-
const stubsEnabled = (env.TRICKLE_STUBS || process.env.TRICKLE_STUBS || "
|
|
412
|
+
// Check if stub generation is enabled (opt-in: TRICKLE_STUBS=1 enables .pyi/.d.ts files)
|
|
413
|
+
const stubsEnabled = (env.TRICKLE_STUBS || process.env.TRICKLE_STUBS || "0").toLowerCase() !== "0";
|
|
414
414
|
// Start live type generation — types update while the process runs
|
|
415
415
|
let liveTypesStop = null;
|
|
416
416
|
if (singleFile && stubsEnabled) {
|
package/dist/index.js
CHANGED
|
@@ -81,6 +81,7 @@ const mcp_server_1 = require("./commands/mcp-server");
|
|
|
81
81
|
const rn_1 = require("./commands/rn");
|
|
82
82
|
const next_1 = require("./commands/next");
|
|
83
83
|
const python_1 = require("./commands/python");
|
|
84
|
+
const hints_1 = require("./commands/hints");
|
|
84
85
|
const program = new commander_1.Command();
|
|
85
86
|
program
|
|
86
87
|
.name("trickle")
|
|
@@ -533,6 +534,14 @@ program
|
|
|
533
534
|
.action(async (opts) => {
|
|
534
535
|
await (0, vars_1.varsCommand)(opts);
|
|
535
536
|
});
|
|
537
|
+
// trickle hints
|
|
538
|
+
program
|
|
539
|
+
.command("hints [file]")
|
|
540
|
+
.description("Output source code with inline type hints from runtime observations (for AI agents)")
|
|
541
|
+
.option("--values", "Include sample values alongside types")
|
|
542
|
+
.action(async (file, opts) => {
|
|
543
|
+
await (0, hints_1.hintsCommand)(file, opts);
|
|
544
|
+
});
|
|
536
545
|
// trickle layers
|
|
537
546
|
program
|
|
538
547
|
.command("layers")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trickle-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.215",
|
|
4
4
|
"description": "Zero-code runtime observability for JS/Python + AI agent debugging. Traces LangChain, CrewAI, OpenAI, Anthropic, Gemini. Eval, security, compliance, cost tracking. Free, local-first.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"observability",
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output source code with inline type hints (like VSCode inlay hints).
|
|
3
|
+
*
|
|
4
|
+
* Reads .trickle/variables.jsonl and renders the source with type
|
|
5
|
+
* annotations inserted inline — suitable for AI agents that need
|
|
6
|
+
* to understand runtime types without running the code.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* trickle hints src/app.py # all hints for a file
|
|
10
|
+
* trickle hints src/app.py --values # include sample values
|
|
11
|
+
* trickle hints # all observed files
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "fs";
|
|
15
|
+
import * as path from "path";
|
|
16
|
+
|
|
17
|
+
export interface HintsOptions {
|
|
18
|
+
values?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface TypeNode {
|
|
22
|
+
kind: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
elements?: TypeNode[];
|
|
25
|
+
members?: TypeNode[];
|
|
26
|
+
element?: TypeNode;
|
|
27
|
+
properties?: Record<string, TypeNode>;
|
|
28
|
+
class_name?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface VarObservation {
|
|
32
|
+
kind: string;
|
|
33
|
+
varName: string;
|
|
34
|
+
line: number;
|
|
35
|
+
file: string;
|
|
36
|
+
module?: string;
|
|
37
|
+
cellIndex?: number;
|
|
38
|
+
type: TypeNode;
|
|
39
|
+
sample?: unknown;
|
|
40
|
+
funcName?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function typeToString(node: TypeNode): string {
|
|
44
|
+
if (!node) return "unknown";
|
|
45
|
+
switch (node.kind) {
|
|
46
|
+
case "primitive": return node.name || "unknown";
|
|
47
|
+
case "object": {
|
|
48
|
+
if (!node.properties) return node.class_name || "object";
|
|
49
|
+
if (node.class_name === "Tensor" || node.class_name === "ndarray") {
|
|
50
|
+
const shape = node.properties["shape"]?.name;
|
|
51
|
+
const dtype = node.properties["dtype"]?.name;
|
|
52
|
+
const parts: string[] = [];
|
|
53
|
+
if (shape) parts.push(`shape=${shape}`);
|
|
54
|
+
if (dtype) parts.push(`dtype=${dtype}`);
|
|
55
|
+
return `${node.class_name}(${parts.join(", ")})`;
|
|
56
|
+
}
|
|
57
|
+
if (node.class_name === "DataFrame") {
|
|
58
|
+
const rows = node.properties["rows"]?.name;
|
|
59
|
+
const cols = node.properties["cols"]?.name;
|
|
60
|
+
const parts: string[] = [];
|
|
61
|
+
if (rows && cols) parts.push(`${rows}x${cols}`);
|
|
62
|
+
return `DataFrame(${parts.join(", ")})`;
|
|
63
|
+
}
|
|
64
|
+
if (node.class_name) {
|
|
65
|
+
const keys = Object.keys(node.properties).slice(0, 4);
|
|
66
|
+
const extra = Object.keys(node.properties).length > 4 ? `, +${Object.keys(node.properties).length - 4}` : "";
|
|
67
|
+
return `${node.class_name}(${keys.join(", ")}${extra})`;
|
|
68
|
+
}
|
|
69
|
+
const props = Object.entries(node.properties).slice(0, 5)
|
|
70
|
+
.map(([k, v]) => `${k}: ${typeToString(v)}`);
|
|
71
|
+
const extra = Object.keys(node.properties).length > 5 ? `, +${Object.keys(node.properties).length - 5}` : "";
|
|
72
|
+
return `{${props.join(", ")}${extra}}`;
|
|
73
|
+
}
|
|
74
|
+
case "array": {
|
|
75
|
+
const elem = node.element;
|
|
76
|
+
if (elem?.kind === "union") {
|
|
77
|
+
const members = elem.elements || elem.members;
|
|
78
|
+
if (members && members.length > 0) {
|
|
79
|
+
const names = new Set(members.map(m => m.class_name).filter(Boolean));
|
|
80
|
+
if (names.size === 1) return `${names.values().next().value}[]`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return `${typeToString(elem || { kind: "primitive", name: "unknown" })}[]`;
|
|
84
|
+
}
|
|
85
|
+
case "tuple": return `[${(node.elements || []).map(typeToString).join(", ")}]`;
|
|
86
|
+
case "union": {
|
|
87
|
+
const members = node.elements || node.members;
|
|
88
|
+
if (!members) return "unknown";
|
|
89
|
+
const names = new Set(members.map(m => m.class_name).filter(Boolean));
|
|
90
|
+
if (names.size === 1) return names.values().next().value!;
|
|
91
|
+
return members.map(typeToString).join(" | ");
|
|
92
|
+
}
|
|
93
|
+
case "function": return "Function";
|
|
94
|
+
default: return node.kind;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function formatSample(sample: unknown): string {
|
|
99
|
+
if (sample === null || sample === undefined) return "";
|
|
100
|
+
if (typeof sample === "string") {
|
|
101
|
+
if (sample.length > 40) return `"${sample.substring(0, 37)}..."`;
|
|
102
|
+
return `"${sample}"`;
|
|
103
|
+
}
|
|
104
|
+
if (typeof sample === "number") {
|
|
105
|
+
return Number.isInteger(sample) ? String(sample) : sample.toFixed(4);
|
|
106
|
+
}
|
|
107
|
+
if (typeof sample === "boolean") return String(sample);
|
|
108
|
+
if (Array.isArray(sample)) return `[...${sample.length} items]`;
|
|
109
|
+
const s = String(sample);
|
|
110
|
+
return s.length > 40 ? s.substring(0, 37) + "..." : s;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function hintsCommand(
|
|
114
|
+
targetFile: string | undefined,
|
|
115
|
+
opts: HintsOptions,
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
const localDir = process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), ".trickle");
|
|
118
|
+
const varsFile = path.join(localDir, "variables.jsonl");
|
|
119
|
+
|
|
120
|
+
if (!fs.existsSync(varsFile)) {
|
|
121
|
+
console.error("No trickle data found. Run your app with trickle first:");
|
|
122
|
+
console.error(" trickle run python app.py");
|
|
123
|
+
console.error(" trickle run node app.js");
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Load and deduplicate variable observations (last wins per file:line:varName)
|
|
128
|
+
const obsMap = new Map<string, VarObservation>();
|
|
129
|
+
for (const line of fs.readFileSync(varsFile, "utf-8").split("\n").filter(Boolean)) {
|
|
130
|
+
try {
|
|
131
|
+
const v = JSON.parse(line) as VarObservation;
|
|
132
|
+
if (v.kind !== "variable") continue;
|
|
133
|
+
const key = `${v.file}:${v.line}:${v.varName}`;
|
|
134
|
+
obsMap.set(key, v);
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
const vars = [...obsMap.values()];
|
|
138
|
+
|
|
139
|
+
// Filter by target file
|
|
140
|
+
let filtered = vars;
|
|
141
|
+
if (targetFile) {
|
|
142
|
+
const normalized = targetFile.replace(/^\.\//, "");
|
|
143
|
+
filtered = vars.filter(v => {
|
|
144
|
+
const relPath = path.relative(process.cwd(), v.file);
|
|
145
|
+
return relPath.includes(normalized) || v.file.includes(normalized);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (filtered.length === 0) {
|
|
150
|
+
console.error(targetFile ? `No observations found for "${targetFile}".` : "No observations found.");
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Group by file
|
|
155
|
+
const byFile = new Map<string, VarObservation[]>();
|
|
156
|
+
for (const v of filtered) {
|
|
157
|
+
if (!byFile.has(v.file)) byFile.set(v.file, []);
|
|
158
|
+
byFile.get(v.file)!.push(v);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const [absFile, fileVars] of byFile) {
|
|
162
|
+
let sourceLines: string[] = [];
|
|
163
|
+
let relPath: string = "";
|
|
164
|
+
|
|
165
|
+
// Handle notebook cell paths: __notebook__cell_N.py
|
|
166
|
+
const cellMatch = absFile.match(/__notebook__cell_(\d+)\.py$/);
|
|
167
|
+
if (cellMatch) {
|
|
168
|
+
// Try to find the notebook .ipynb in the same directory
|
|
169
|
+
const dir = path.dirname(absFile);
|
|
170
|
+
const cellIdx = parseInt(cellMatch[1]);
|
|
171
|
+
const ipynbFiles = fs.existsSync(dir)
|
|
172
|
+
? fs.readdirSync(dir).filter(f => f.endsWith(".ipynb"))
|
|
173
|
+
: [];
|
|
174
|
+
let foundCell = false;
|
|
175
|
+
for (const nbFile of ipynbFiles) {
|
|
176
|
+
const nbPath = path.join(dir, nbFile);
|
|
177
|
+
try {
|
|
178
|
+
const nb = JSON.parse(fs.readFileSync(nbPath, "utf-8"));
|
|
179
|
+
const cells = nb.cells || [];
|
|
180
|
+
// trickle's cellIdx is the Nth code cell execution (1-based).
|
|
181
|
+
// Try direct index first, then count code cells.
|
|
182
|
+
let cellSource: string[] | undefined;
|
|
183
|
+
// Try: cellIdx maps to 0-based index in all cells
|
|
184
|
+
if (cells[cellIdx - 1]?.cell_type === "code") {
|
|
185
|
+
cellSource = cells[cellIdx - 1].source;
|
|
186
|
+
}
|
|
187
|
+
// Fallback: count code cells
|
|
188
|
+
if (!cellSource) {
|
|
189
|
+
let codeCount = 0;
|
|
190
|
+
for (const c of cells) {
|
|
191
|
+
if (c.cell_type === "code") {
|
|
192
|
+
codeCount++;
|
|
193
|
+
if (codeCount === cellIdx) {
|
|
194
|
+
cellSource = c.source;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (!cellSource) continue;
|
|
201
|
+
sourceLines = (Array.isArray(cellSource) ? cellSource.join("") : String(cellSource)).split("\n");
|
|
202
|
+
relPath = `${path.relative(process.cwd(), nbPath)} [cell ${cellIdx}]`;
|
|
203
|
+
foundCell = true;
|
|
204
|
+
break;
|
|
205
|
+
} catch {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (!foundCell) {
|
|
210
|
+
// Can't find source — output just the observations in a summary format
|
|
211
|
+
relPath = absFile.replace(/.*__notebook__/, "notebook ").replace(/\.py$/, "");
|
|
212
|
+
const maxLine = Math.max(...fileVars.map(v => v.line));
|
|
213
|
+
sourceLines = Array.from({ length: maxLine }, (_, i) => `# line ${i + 1}`);
|
|
214
|
+
// Place observations as standalone lines
|
|
215
|
+
const lineObs = new Map<number, Map<string, VarObservation>>();
|
|
216
|
+
for (const v of fileVars) {
|
|
217
|
+
if (!lineObs.has(v.line)) lineObs.set(v.line, new Map());
|
|
218
|
+
lineObs.get(v.line)!.set(v.varName, v);
|
|
219
|
+
}
|
|
220
|
+
console.log(`# ${relPath}`);
|
|
221
|
+
console.log("```python");
|
|
222
|
+
for (const [lineNo, obs] of [...lineObs.entries()].sort((a, b) => a[0] - b[0])) {
|
|
223
|
+
for (const v of obs.values()) {
|
|
224
|
+
const typeStr = typeToString(v.type);
|
|
225
|
+
const scope = v.funcName ? ` (in ${v.funcName})` : "";
|
|
226
|
+
if (opts.values && v.sample !== undefined) {
|
|
227
|
+
console.log(`${v.varName}: ${typeStr} = ${formatSample(v.sample)}${scope}`);
|
|
228
|
+
} else {
|
|
229
|
+
console.log(`${v.varName}: ${typeStr}${scope}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
console.log("```");
|
|
234
|
+
console.log("");
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
if (!fs.existsSync(absFile)) continue;
|
|
239
|
+
sourceLines = fs.readFileSync(absFile, "utf-8").split("\n");
|
|
240
|
+
relPath = path.relative(process.cwd(), absFile);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Build line → varName → observation map
|
|
244
|
+
const lineObs = new Map<number, Map<string, VarObservation>>();
|
|
245
|
+
for (const v of fileVars) {
|
|
246
|
+
if (!lineObs.has(v.line)) lineObs.set(v.line, new Map());
|
|
247
|
+
lineObs.get(v.line)!.set(v.varName, v);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
console.log(`# ${relPath}`);
|
|
251
|
+
console.log("```python");
|
|
252
|
+
|
|
253
|
+
for (let i = 0; i < sourceLines.length; i++) {
|
|
254
|
+
const lineNo = i + 1;
|
|
255
|
+
const src = sourceLines[i];
|
|
256
|
+
const obs = lineObs.get(lineNo);
|
|
257
|
+
|
|
258
|
+
if (!obs || obs.size === 0) {
|
|
259
|
+
console.log(src);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Insert type hints inline after variable names
|
|
264
|
+
let annotated = src;
|
|
265
|
+
// Sort by position in line (rightmost first so indices don't shift)
|
|
266
|
+
const entries = [...obs.values()].sort((a, b) => {
|
|
267
|
+
const aIdx = src.indexOf(a.varName);
|
|
268
|
+
const bIdx = src.indexOf(b.varName);
|
|
269
|
+
return bIdx - aIdx; // rightmost first
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
for (const v of entries) {
|
|
273
|
+
const typeStr = typeToString(v.type);
|
|
274
|
+
// Skip if the type is just "unknown" or not useful
|
|
275
|
+
if (typeStr === "unknown") continue;
|
|
276
|
+
|
|
277
|
+
// Find variable in the line
|
|
278
|
+
const isAttr = v.varName.includes(".");
|
|
279
|
+
const pattern = isAttr
|
|
280
|
+
? new RegExp(v.varName.replace(/\./g, "\\."))
|
|
281
|
+
: new RegExp(`\\b${v.varName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
282
|
+
const match = pattern.exec(annotated);
|
|
283
|
+
if (!match) continue;
|
|
284
|
+
|
|
285
|
+
const varEnd = match.index + v.varName.length;
|
|
286
|
+
const afterVar = annotated.substring(varEnd).trimStart();
|
|
287
|
+
|
|
288
|
+
// Check this is an assignment/declaration context
|
|
289
|
+
const beforeVar = annotated.substring(0, match.index);
|
|
290
|
+
const isPython = absFile.endsWith(".py");
|
|
291
|
+
if (isPython) {
|
|
292
|
+
const isAssignment = afterVar.startsWith("=") && !afterVar.startsWith("==");
|
|
293
|
+
const isAnnotated = afterVar.startsWith(":");
|
|
294
|
+
const isForVar = /\bfor\s+$/.test(beforeVar) || /\bfor\s+.*,\s*$/.test(beforeVar);
|
|
295
|
+
const isWithAs = /\bas\s+$/.test(beforeVar);
|
|
296
|
+
const isFuncParam = /\b(?:async\s+)?def\s+\w+\s*\(/.test(beforeVar) &&
|
|
297
|
+
(afterVar.startsWith(",") || afterVar.startsWith(")") || afterVar.startsWith("=") || afterVar.startsWith(":"));
|
|
298
|
+
const isBareAssign = /^\s*$/.test(beforeVar) || /,\s*$/.test(beforeVar);
|
|
299
|
+
|
|
300
|
+
if (!isForVar && !isWithAs && !isFuncParam && !((isBareAssign) && (isAssignment || isAnnotated))) continue;
|
|
301
|
+
if (isAnnotated && !isFuncParam) continue;
|
|
302
|
+
if (isFuncParam && afterVar.startsWith(":")) continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Build the hint string
|
|
306
|
+
let hint: string;
|
|
307
|
+
if (opts.values && v.sample !== undefined && v.sample !== null) {
|
|
308
|
+
const sampleStr = formatSample(v.sample);
|
|
309
|
+
hint = sampleStr ? `: ${typeStr} = ${sampleStr}` : `: ${typeStr}`;
|
|
310
|
+
} else {
|
|
311
|
+
hint = `: ${typeStr}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Insert after variable name
|
|
315
|
+
annotated = annotated.substring(0, varEnd) + hint + annotated.substring(varEnd);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
console.log(annotated);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
console.log("```");
|
|
322
|
+
console.log("");
|
|
323
|
+
}
|
|
324
|
+
}
|
package/src/commands/run.ts
CHANGED
|
@@ -459,8 +459,8 @@ async function executeSingleRun(
|
|
|
459
459
|
|
|
460
460
|
const { generateLocalStubs, generateFromJsonl, readObservations } = await import("../local-codegen");
|
|
461
461
|
|
|
462
|
-
// Check if stub generation is enabled (TRICKLE_STUBS=
|
|
463
|
-
const stubsEnabled = (env.TRICKLE_STUBS || process.env.TRICKLE_STUBS || "
|
|
462
|
+
// Check if stub generation is enabled (opt-in: TRICKLE_STUBS=1 enables .pyi/.d.ts files)
|
|
463
|
+
const stubsEnabled = (env.TRICKLE_STUBS || process.env.TRICKLE_STUBS || "0").toLowerCase() !== "0";
|
|
464
464
|
|
|
465
465
|
// Start live type generation — types update while the process runs
|
|
466
466
|
let liveTypesStop: (() => void) | null = null;
|
package/src/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ import { mcpServerCommand } from "./commands/mcp-server";
|
|
|
44
44
|
import { rnCommand } from "./commands/rn";
|
|
45
45
|
import { nextCommand } from "./commands/next";
|
|
46
46
|
import { pythonCommand } from "./commands/python";
|
|
47
|
+
import { hintsCommand } from "./commands/hints";
|
|
47
48
|
|
|
48
49
|
const program = new Command();
|
|
49
50
|
|
|
@@ -534,6 +535,15 @@ program
|
|
|
534
535
|
await varsCommand(opts);
|
|
535
536
|
});
|
|
536
537
|
|
|
538
|
+
// trickle hints
|
|
539
|
+
program
|
|
540
|
+
.command("hints [file]")
|
|
541
|
+
.description("Output source code with inline type hints from runtime observations (for AI agents)")
|
|
542
|
+
.option("--values", "Include sample values alongside types")
|
|
543
|
+
.action(async (file: string | undefined, opts: { values?: boolean }) => {
|
|
544
|
+
await hintsCommand(file, opts);
|
|
545
|
+
});
|
|
546
|
+
|
|
537
547
|
// trickle layers
|
|
538
548
|
program
|
|
539
549
|
.command("layers")
|