jules-ink 0.0.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.
- package/README.md +115 -0
- package/assets/fonts/GoogleSans-Bold.ttf +0 -0
- package/assets/fonts/GoogleSans-Regular.ttf +0 -0
- package/assets/fonts/GoogleSansMono-Regular.ttf +0 -0
- package/assets/labels/grumpy-cat.png +0 -0
- package/assets/template.png +0 -0
- package/dist/analyzer.d.ts +6 -0
- package/dist/analyzer.js +55 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +39 -0
- package/dist/label-generator.d.ts +12 -0
- package/dist/label-generator.js +190 -0
- package/dist/pipeline.d.ts +10 -0
- package/dist/pipeline.js +87 -0
- package/dist/print.d.ts +20 -0
- package/dist/print.js +100 -0
- package/dist/processor.d.ts +21 -0
- package/dist/processor.js +26 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +57 -0
- package/dist/summarizer.d.ts +32 -0
- package/dist/summarizer.js +241 -0
- package/dist/types.d.ts +23 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +17 -0
- package/dist/utils.js +138 -0
- package/package.json +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Jules Ink 🖨️
|
|
2
|
+
|
|
3
|
+
**Print physical labels of your AI coding sessions.** Jules Ink connects to [Jules](https://jules.google.com) sessions, generates AI-powered summaries, and prints them to thermal label printers.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Why?
|
|
8
|
+
|
|
9
|
+
Because watching an AI write code is cool. But having a **physical artifact** of what it did? That's cooler.
|
|
10
|
+
|
|
11
|
+
- 📝 **Document your AI pair programming** — Keep a tangible record of what Jules built
|
|
12
|
+
- 🎉 **Make it fun** — Print summaries as a pirate, Shakespeare, or a noir detective
|
|
13
|
+
- 🏷️ **Label your commits** — Stick them on your monitor, notebook, or fridge
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Install dependencies
|
|
19
|
+
npm install
|
|
20
|
+
|
|
21
|
+
# Set your API key
|
|
22
|
+
export GEMINI_API_KEY="your-key"
|
|
23
|
+
export JULES_API_KEY="jules-key"
|
|
24
|
+
|
|
25
|
+
jules-ink print --session <SESSION_ID> -t haiku
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## CLI Options
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
Usage: jules-ink print [options]
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
-s, --session <id> Session ID to process (required)
|
|
35
|
+
-m, --model <name> Gemini model (default: "gemini-2.5-flash-lite")
|
|
36
|
+
-t, --tone <preset> Tone for summaries (default: "professional")
|
|
37
|
+
-p, --printer <name> Printer name (auto-discovers if not set)
|
|
38
|
+
-h, --help Display help
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Tone Presets
|
|
42
|
+
|
|
43
|
+
Make your labels fun with built-in tone presets:
|
|
44
|
+
|
|
45
|
+
| Tone | Example Output |
|
|
46
|
+
|------|----------------|
|
|
47
|
+
| `professional` | "Refactoring `SessionClient` to support new handshake protocol." |
|
|
48
|
+
| `pirate` | "Arr! We be refactorin' the `SessionClient`, matey!" |
|
|
49
|
+
| `shakespearean` | "Hark! The `SessionClient` doth receive new methods most fair." |
|
|
50
|
+
| `excited` | "OMG!! 🎉 Just refactored `SessionClient`!!! SO EXCITING!!! 🚀" |
|
|
51
|
+
| `haiku` | "Code flows like a stream / SessionClient transforms / Bugs fade to nothing" |
|
|
52
|
+
| `noir` | "The function had seen better days. I gave it a new life." |
|
|
53
|
+
|
|
54
|
+
### Custom Tones
|
|
55
|
+
|
|
56
|
+
Pass any string to `-t` for a custom tone:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# Characters
|
|
60
|
+
jules-ink print -s 123456 -t "Respond as if you're a grumpy cat"
|
|
61
|
+
jules-ink print -s 123456 -t "Write like a sports commentator at a tied match in extra time"
|
|
62
|
+
jules-ink print -s 123456 -t "Write like a nature documentary narrator observing code in its natural habitat"
|
|
63
|
+
jules-ink print -s 123456 -t "Respond like a dramatic movie trailer voiceover"
|
|
64
|
+
jules-ink print -s 123456 -t "Write as a medieval herald announcing royal decrees"
|
|
65
|
+
|
|
66
|
+
# Professions & styles
|
|
67
|
+
jules-ink print -s 123456 -t "Write like a sports commentator at a tied match in extra time"
|
|
68
|
+
jules-ink print -s 123456 -t "Respond as a surfer dude who just discovered coding"
|
|
69
|
+
jules-ink print -s 123456 -t "Write like a food critic reviewing a gourmet meal"
|
|
70
|
+
jules-ink print -s 123456 -t "Respond as an overly enthusiastic infomercial host"
|
|
71
|
+
jules-ink print -s 123456 -t "Respond like a soap opera actor"
|
|
72
|
+
|
|
73
|
+
# Moods & vibes
|
|
74
|
+
jules-ink print -s 123456 -t "Write with the energy of someone who just had 5 espressos"
|
|
75
|
+
jules-ink print -s 123456 -t "Respond like a wise grandparent telling stories by the fire"
|
|
76
|
+
jules-ink print -s 123456 -t "Write as if you're whispering secrets at a library"
|
|
77
|
+
jules-ink print -s 123456 -t "Respond with the dramatic flair of a telenovela narrator"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Use Cases
|
|
81
|
+
|
|
82
|
+
### 1. Physical Commit Log
|
|
83
|
+
|
|
84
|
+
Print a label for each coding session and stick them in a notebook:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
jules-ink print -s 561934200180369816 at a tied match in extra time9
|
|
88
|
+
jules-ink print -s 5619342001803698169
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 2. Fun Team Activity
|
|
92
|
+
|
|
93
|
+
Have Jules write code, then print the summary as a haiku:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
jules-ink print -s 5619342001803698169 -t haik at a tied match in extra timeu
|
|
97
|
+
jules-ink print -s 5619342001803698169 -t haiku
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Printer Setup
|
|
101
|
+
|
|
102
|
+
Jules Ink works with thermal label printers via CUPS. The default target is `PM-241-BT` (a common Bluetooth thermal printer).
|
|
103
|
+
|
|
104
|
+
If no printer is found, labels are saved to `output/<session-id>/` as PNG files.
|
|
105
|
+
|
|
106
|
+
## Requirements
|
|
107
|
+
|
|
108
|
+
- Node.js 18+
|
|
109
|
+
- `GEMINI_API_KEY` environment variable
|
|
110
|
+
- `JULES_API_KEY` environment variable
|
|
111
|
+
- (Optional) Thermal label printer
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/analyzer.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import parseDiff from 'parse-diff';
|
|
2
|
+
import micromatch from 'micromatch';
|
|
3
|
+
// 1. Define the same Ignore Patterns used in your Summarizer
|
|
4
|
+
const IGNORE_PATTERNS = [
|
|
5
|
+
'**/package-lock.json',
|
|
6
|
+
'**/yarn.lock',
|
|
7
|
+
'**/*.map',
|
|
8
|
+
'**/dist/**',
|
|
9
|
+
'**/*.min.js',
|
|
10
|
+
'**/.DS_Store'
|
|
11
|
+
];
|
|
12
|
+
/**
|
|
13
|
+
* Transforms a raw Unidiff string into structured stats,
|
|
14
|
+
* filtering out noise to match the Summarizer's logic.
|
|
15
|
+
*/
|
|
16
|
+
export function analyzeChangeSet(unidiffPatch) {
|
|
17
|
+
// parse-diff handles the heavy lifting
|
|
18
|
+
const parsedFiles = parseDiff(unidiffPatch);
|
|
19
|
+
const files = [];
|
|
20
|
+
let totalInsertions = 0;
|
|
21
|
+
let totalDeletions = 0;
|
|
22
|
+
for (const file of parsedFiles) {
|
|
23
|
+
// Prioritize 'to' path (new file), fallback to 'from' (deleted file)
|
|
24
|
+
const path = file.to || file.from || 'unknown';
|
|
25
|
+
// 2. APPLY FILTER LOGIC (Match simplifyActivity)
|
|
26
|
+
// If it's a lockfile or noise, skip it completely.
|
|
27
|
+
// We don't want these taking up space on the physical label.
|
|
28
|
+
if (micromatch.isMatch(path, IGNORE_PATTERNS)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
// Handle binary files or renames where stats might be ambiguous
|
|
32
|
+
const additions = file.additions || 0;
|
|
33
|
+
const deletions = file.deletions || 0;
|
|
34
|
+
totalInsertions += additions;
|
|
35
|
+
totalDeletions += deletions;
|
|
36
|
+
// 3. REMOVED GRAPH LOGIC
|
|
37
|
+
// We no longer calculate block chars. We just pass the raw stats.
|
|
38
|
+
files.push({
|
|
39
|
+
path,
|
|
40
|
+
additions,
|
|
41
|
+
deletions,
|
|
42
|
+
totalChanges: additions + deletions
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// Sort files by "impact" (most changes first) so the label shows the important stuff
|
|
46
|
+
files.sort((a, b) => b.totalChanges - a.totalChanges);
|
|
47
|
+
return {
|
|
48
|
+
files,
|
|
49
|
+
totalFiles: files.length,
|
|
50
|
+
totalInsertions,
|
|
51
|
+
totalDeletions,
|
|
52
|
+
// Updated summary string to match the new numeric style
|
|
53
|
+
summaryString: `${files.length} files | +${totalInsertions} / -${totalDeletions}`
|
|
54
|
+
};
|
|
55
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { processSessionAndPrint } from './pipeline.js';
|
|
4
|
+
const program = new Command();
|
|
5
|
+
program
|
|
6
|
+
.name('jules-ink')
|
|
7
|
+
.description('Label Pipeline CLI for processing Jules sessions')
|
|
8
|
+
.version('0.0.0');
|
|
9
|
+
program
|
|
10
|
+
.command('print')
|
|
11
|
+
.description('Print labels for a Jules session')
|
|
12
|
+
.requiredOption('-s, --session <id>', 'The Session ID to process')
|
|
13
|
+
.option('-m, --model <name>', 'Gemini model to use for summarization', 'gemini-2.5-flash-lite')
|
|
14
|
+
.option('-t, --tone <preset>', 'Tone preset for summaries (professional, pirate, shakespearean, excited, haiku, noir)', 'professional')
|
|
15
|
+
.option('-p, --printer <name>', 'Printer name (auto-discovers if not set)')
|
|
16
|
+
.action(async (options) => {
|
|
17
|
+
const sessionId = options.session;
|
|
18
|
+
const model = options.model;
|
|
19
|
+
const tone = options.tone;
|
|
20
|
+
const printer = options.printer;
|
|
21
|
+
console.log(`\n🚀 Starting Label Pipeline for Session: ${sessionId}`);
|
|
22
|
+
console.log(`📦 Using model: ${model}`);
|
|
23
|
+
console.log(`🎭 Tone: ${tone}`);
|
|
24
|
+
console.log(`===================================================\n`);
|
|
25
|
+
try {
|
|
26
|
+
const generator = processSessionAndPrint(sessionId, { model, tone, printer });
|
|
27
|
+
for await (const result of generator) {
|
|
28
|
+
console.log(`✓ [${result.activity.type}] Processed`);
|
|
29
|
+
console.log(` └─ Summary: "${result.summary.substring(0, 60)}..."`);
|
|
30
|
+
console.log(` └─ Label: ${result.labelPath}\n`);
|
|
31
|
+
}
|
|
32
|
+
console.log(`✅ Session ${sessionId} processing complete.`);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
console.error('\n❌ Fatal Error processing session:', error);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
program.parse();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface FileStat {
|
|
2
|
+
path: string;
|
|
3
|
+
additions: number;
|
|
4
|
+
deletions: number;
|
|
5
|
+
}
|
|
6
|
+
export interface LabelData {
|
|
7
|
+
repo: string;
|
|
8
|
+
sessionId: string;
|
|
9
|
+
summary: string;
|
|
10
|
+
files: FileStat[];
|
|
11
|
+
}
|
|
12
|
+
export declare function generateLabel(data: LabelData): Promise<Buffer>;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { createCanvas, loadImage, GlobalFonts } from '@napi-rs/canvas';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { truncateMiddle } from './utils.js';
|
|
5
|
+
import { parseMarkdownSegments, calculateWrappedSegments } from './utils.js';
|
|
6
|
+
// --- Font Registration ---
|
|
7
|
+
const FONT_DIR = path.resolve('./assets/fonts');
|
|
8
|
+
function registerLocalFont(filename, family) {
|
|
9
|
+
const filePath = path.join(FONT_DIR, filename);
|
|
10
|
+
if (fs.existsSync(filePath))
|
|
11
|
+
GlobalFonts.registerFromPath(filePath, family);
|
|
12
|
+
}
|
|
13
|
+
registerLocalFont('GoogleSans-Bold.ttf', 'Google Sans');
|
|
14
|
+
registerLocalFont('GoogleSans-Regular.ttf', 'Google Sans');
|
|
15
|
+
registerLocalFont('GoogleSansMono-Regular.ttf', 'Google Sans Mono');
|
|
16
|
+
// Emoji font fallback (uses system fonts)
|
|
17
|
+
const EMOJI_FALLBACK = ', "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"';
|
|
18
|
+
// --- Configuration ---
|
|
19
|
+
const CONFIG = {
|
|
20
|
+
width: 1200,
|
|
21
|
+
height: 1800,
|
|
22
|
+
padding: 64,
|
|
23
|
+
fonts: {
|
|
24
|
+
header: `36px "Google Sans Mono", monospace${EMOJI_FALLBACK}`,
|
|
25
|
+
stats: `42px "Google Sans Mono", monospace${EMOJI_FALLBACK}`,
|
|
26
|
+
},
|
|
27
|
+
layout: {
|
|
28
|
+
headerY: 120,
|
|
29
|
+
// ANCHOR 1: Logo Bottom
|
|
30
|
+
logoBottomY: 520,
|
|
31
|
+
// ANCHOR 2: Body Start
|
|
32
|
+
bodyGap: 24,
|
|
33
|
+
// ANCHOR 3: Stats Start
|
|
34
|
+
statsY: 1350,
|
|
35
|
+
// NEW: The "DMZ"
|
|
36
|
+
// The text body is forced to stop this many pixels BEFORE statsY.
|
|
37
|
+
// Increased from implicit 40px to explicit 100px.
|
|
38
|
+
minGapBetweenBodyAndStats: 100,
|
|
39
|
+
footerLineHeight: 65
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
export async function generateLabel(data) {
|
|
43
|
+
const canvas = createCanvas(CONFIG.width, CONFIG.height);
|
|
44
|
+
const ctx = canvas.getContext('2d');
|
|
45
|
+
const templatePath = path.resolve('./assets/template.png');
|
|
46
|
+
if (fs.existsSync(templatePath)) {
|
|
47
|
+
const template = await loadImage(templatePath);
|
|
48
|
+
ctx.drawImage(template, 0, 0, CONFIG.width, CONFIG.height);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
ctx.fillStyle = 'white';
|
|
52
|
+
ctx.fillRect(0, 0, CONFIG.width, CONFIG.height);
|
|
53
|
+
}
|
|
54
|
+
ctx.fillStyle = 'black';
|
|
55
|
+
drawHeader(ctx, data.repo, data.sessionId);
|
|
56
|
+
// 1. Calculate Body Start
|
|
57
|
+
const bodyStartY = CONFIG.layout.logoBottomY + CONFIG.layout.bodyGap;
|
|
58
|
+
// 2. Calculate Max Height with DMZ
|
|
59
|
+
// Logic: Stats Start - Start Y - The Mandatory Gap
|
|
60
|
+
const maxBodyHeight = CONFIG.layout.statsY - bodyStartY - CONFIG.layout.minGapBetweenBodyAndStats;
|
|
61
|
+
// 3. Draw Body
|
|
62
|
+
// The text will shrink itself until it fits nicely inside this smaller box
|
|
63
|
+
drawBodyAnchored(ctx, data.summary, bodyStartY, maxBodyHeight);
|
|
64
|
+
// 4. Draw Stats
|
|
65
|
+
if (data.files && data.files.length > 0) {
|
|
66
|
+
drawStatsFixed(ctx, data.files, CONFIG.layout.statsY);
|
|
67
|
+
}
|
|
68
|
+
return canvas.toBuffer('image/png');
|
|
69
|
+
}
|
|
70
|
+
function drawHeader(ctx, repo, sessionId) {
|
|
71
|
+
const { width, padding } = CONFIG;
|
|
72
|
+
ctx.font = CONFIG.fonts.header;
|
|
73
|
+
ctx.textAlign = 'right';
|
|
74
|
+
ctx.fillText(sessionId, width - padding, CONFIG.layout.headerY);
|
|
75
|
+
const sessionWidth = ctx.measureText(sessionId).width;
|
|
76
|
+
const maxRepoWidth = width - (padding * 2) - sessionWidth - 40;
|
|
77
|
+
let fontSize = 36;
|
|
78
|
+
ctx.font = `${fontSize}px "Google Sans Mono", monospace`;
|
|
79
|
+
while (ctx.measureText(repo).width > maxRepoWidth && fontSize > 20) {
|
|
80
|
+
fontSize -= 2;
|
|
81
|
+
ctx.font = `${fontSize}px "Google Sans Mono", monospace`;
|
|
82
|
+
}
|
|
83
|
+
ctx.textAlign = 'left';
|
|
84
|
+
ctx.fillText(repo, padding, CONFIG.layout.headerY);
|
|
85
|
+
}
|
|
86
|
+
function drawBodyAnchored(ctx, text, fixedY, maxHeight) {
|
|
87
|
+
const maxWidth = CONFIG.width - (CONFIG.padding * 2);
|
|
88
|
+
// 1. Parse raw text into typed segments
|
|
89
|
+
const allSegments = parseMarkdownSegments(text);
|
|
90
|
+
// Constraints
|
|
91
|
+
let fontSize = 80;
|
|
92
|
+
const minFontSize = 38;
|
|
93
|
+
const lineHeightMultiplier = 1.4;
|
|
94
|
+
let wrappedLines = [];
|
|
95
|
+
let lineHeight = 0;
|
|
96
|
+
let totalTextHeight = 0;
|
|
97
|
+
let weight = 'bold';
|
|
98
|
+
let normalFontStr = '';
|
|
99
|
+
// Monospace font size should match body size roughly
|
|
100
|
+
let codeFontStr = '';
|
|
101
|
+
// 2. Shrink Loop (Now uses segment wrapper)
|
|
102
|
+
do {
|
|
103
|
+
weight = fontSize > 60 ? 'bold' : 'normal';
|
|
104
|
+
normalFontStr = `${weight} ${fontSize}px "Google Sans"${EMOJI_FALLBACK}`;
|
|
105
|
+
// Use slightly smaller font for mono so it doesn't overpower the text
|
|
106
|
+
codeFontStr = `normal ${fontSize - 4}px "Google Sans Mono"${EMOJI_FALLBACK}`;
|
|
107
|
+
// Use new wrapper that understands mixed fonts
|
|
108
|
+
wrappedLines = calculateWrappedSegments(ctx, allSegments, maxWidth, normalFontStr, codeFontStr);
|
|
109
|
+
lineHeight = Math.floor(fontSize * lineHeightMultiplier);
|
|
110
|
+
totalTextHeight = wrappedLines.length * lineHeight;
|
|
111
|
+
if (totalTextHeight > maxHeight) {
|
|
112
|
+
fontSize -= 4;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
} while (fontSize >= minFontSize);
|
|
118
|
+
// (Truncation logic omitted for brevity, but would need updating for segments)
|
|
119
|
+
// 3. Drawing Phase
|
|
120
|
+
ctx.textAlign = 'left';
|
|
121
|
+
ctx.fillStyle = 'black';
|
|
122
|
+
let currentY = fixedY;
|
|
123
|
+
// Iterate over each line
|
|
124
|
+
for (const lineSegments of wrappedLines) {
|
|
125
|
+
let currentX = CONFIG.padding;
|
|
126
|
+
// Iterate over segments within the line
|
|
127
|
+
for (const segment of lineSegments) {
|
|
128
|
+
if (segment.isCode) {
|
|
129
|
+
// --- DRAW CODE SPAN ---
|
|
130
|
+
ctx.font = codeFontStr;
|
|
131
|
+
// Recalculate width just to be safe for drawing
|
|
132
|
+
const metric = ctx.measureText(segment.text);
|
|
133
|
+
const textWidth = metric.width;
|
|
134
|
+
const boxWidth = textWidth + 12; // 6px padding each side
|
|
135
|
+
// Calculate box height relative to font size
|
|
136
|
+
const boxHeight = fontSize * 1.1;
|
|
137
|
+
// Offset y up slightly to center text vertically in box
|
|
138
|
+
const boxYOffset = fontSize * 0.9;
|
|
139
|
+
// Draw Background Box
|
|
140
|
+
ctx.fillStyle = '#e0e0e0'; // Light gray
|
|
141
|
+
// Note: roundRect requires recent canvas version or polyfill
|
|
142
|
+
if (ctx.roundRect) {
|
|
143
|
+
ctx.beginPath();
|
|
144
|
+
// Adjust X and Y to frame the text
|
|
145
|
+
ctx.roundRect(currentX - 2, currentY + fontSize - boxYOffset, boxWidth, boxHeight, 8);
|
|
146
|
+
ctx.fill();
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
// Fallback for older environments
|
|
150
|
+
ctx.fillRect(currentX - 2, currentY + fontSize - boxYOffset, boxWidth, boxHeight);
|
|
151
|
+
}
|
|
152
|
+
// Draw Text on top
|
|
153
|
+
ctx.fillStyle = '#000000';
|
|
154
|
+
// Draw text with padding offset
|
|
155
|
+
ctx.fillText(segment.text, currentX + 4, currentY + fontSize);
|
|
156
|
+
// Advance X cursor including padding
|
|
157
|
+
currentX += boxWidth - 4;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
// --- DRAW NORMAL TEXT ---
|
|
161
|
+
ctx.font = normalFontStr;
|
|
162
|
+
ctx.fillStyle = 'black';
|
|
163
|
+
ctx.fillText(segment.text, currentX, currentY + fontSize);
|
|
164
|
+
// Advance X cursor based on pre-calculated width from wrapper
|
|
165
|
+
currentX += segment.width || ctx.measureText(segment.text).width;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Move to next line
|
|
169
|
+
currentY += lineHeight;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function drawStatsFixed(ctx, files, fixedY) {
|
|
173
|
+
ctx.font = CONFIG.fonts.stats;
|
|
174
|
+
let currentY = fixedY;
|
|
175
|
+
const visibleFiles = files.slice(0, 5);
|
|
176
|
+
const hiddenCount = files.length - visibleFiles.length;
|
|
177
|
+
for (const file of visibleFiles) {
|
|
178
|
+
ctx.textAlign = 'left';
|
|
179
|
+
ctx.fillText(truncateMiddle(file.path, 28), CONFIG.padding, currentY);
|
|
180
|
+
const statsText = `+${file.additions} / -${file.deletions}`;
|
|
181
|
+
ctx.textAlign = 'right';
|
|
182
|
+
ctx.fillText(statsText, CONFIG.width - CONFIG.padding, currentY);
|
|
183
|
+
currentY += CONFIG.layout.footerLineHeight;
|
|
184
|
+
}
|
|
185
|
+
if (hiddenCount > 0) {
|
|
186
|
+
ctx.textAlign = 'center';
|
|
187
|
+
ctx.font = `italic 32px "Google Sans", sans-serif`;
|
|
188
|
+
ctx.fillText(`+ ${hiddenCount} more files...`, CONFIG.width / 2, currentY + 20);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface ProcessOptions {
|
|
2
|
+
model?: string;
|
|
3
|
+
tone?: string;
|
|
4
|
+
printer?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function processSessionAndPrint(sessionId: string, options?: ProcessOptions): AsyncGenerator<{
|
|
7
|
+
activity: import("@google/jules-sdk").Activity;
|
|
8
|
+
summary: string;
|
|
9
|
+
labelPath: string;
|
|
10
|
+
}, void, unknown>;
|
package/dist/pipeline.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { jules } from '@google/jules-sdk';
|
|
2
|
+
import { SessionSummarizer } from './summarizer.js';
|
|
3
|
+
import { generateLabel } from './label-generator.js';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import thermal from './print.js';
|
|
7
|
+
export async function* processSessionAndPrint(sessionId, options = {}) {
|
|
8
|
+
// 1. Initialize Printer Hardware
|
|
9
|
+
const hw = thermal();
|
|
10
|
+
// Find printer: use specified name, or auto-discover
|
|
11
|
+
let printer = null;
|
|
12
|
+
if (options.printer) {
|
|
13
|
+
const printers = await hw.scan();
|
|
14
|
+
printer = printers.find(p => p.name === options.printer) || null;
|
|
15
|
+
if (!printer) {
|
|
16
|
+
console.warn(`⚠️ Printer "${options.printer}" not found. Labels will be saved to disk only.`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
printer = await hw.find();
|
|
21
|
+
}
|
|
22
|
+
if (printer) {
|
|
23
|
+
console.log(`🖨️ Found printer: ${printer.name} (${printer.stat})`);
|
|
24
|
+
await hw.fix(printer.name);
|
|
25
|
+
}
|
|
26
|
+
else if (!options.printer) {
|
|
27
|
+
console.warn('⚠️ No printer found. Labels will be saved to disk only.');
|
|
28
|
+
}
|
|
29
|
+
const summarizer = new SessionSummarizer({
|
|
30
|
+
backend: 'cloud',
|
|
31
|
+
apiKey: process.env.GEMINI_API_KEY,
|
|
32
|
+
cloudModelName: options.model,
|
|
33
|
+
tone: options.tone,
|
|
34
|
+
});
|
|
35
|
+
let rollingSummary = "";
|
|
36
|
+
const session = jules.session(sessionId);
|
|
37
|
+
// Fetch session info to get the actual repo
|
|
38
|
+
const sessionInfo = await session.info();
|
|
39
|
+
const repo = sessionInfo.sourceContext?.source?.replace('sources/github/', '') || 'unknown/repo';
|
|
40
|
+
console.log(`📦 Repository: ${repo}`);
|
|
41
|
+
const outDir = path.resolve('output', sessionId);
|
|
42
|
+
if (!fs.existsSync(outDir))
|
|
43
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
44
|
+
let count = 0;
|
|
45
|
+
for await (const activity of session.stream()) {
|
|
46
|
+
console.log(`Processing Activity ${count + 1}: ${activity.type}`);
|
|
47
|
+
// 1. Generate Summary
|
|
48
|
+
rollingSummary = await summarizer.generateRollingSummary(rollingSummary, activity);
|
|
49
|
+
console.log(`> Summary: ${rollingSummary}`);
|
|
50
|
+
// 2. Extract Stats
|
|
51
|
+
const filesForLabel = summarizer.getLabelData(activity);
|
|
52
|
+
// 3. Generate Label Image
|
|
53
|
+
const labelData = {
|
|
54
|
+
repo,
|
|
55
|
+
sessionId: sessionId,
|
|
56
|
+
summary: rollingSummary,
|
|
57
|
+
files: filesForLabel
|
|
58
|
+
};
|
|
59
|
+
const buffer = await generateLabel(labelData);
|
|
60
|
+
// 4. Save to Disk (Backup)
|
|
61
|
+
const filename = `${count.toString().padStart(3, '0')}_${activity.type}.png`;
|
|
62
|
+
const filePath = path.join(outDir, filename);
|
|
63
|
+
fs.writeFileSync(filePath, buffer);
|
|
64
|
+
// 5. Print if printer available
|
|
65
|
+
if (printer) {
|
|
66
|
+
try {
|
|
67
|
+
console.log(`🖨️ Sending to ${printer.name}...`);
|
|
68
|
+
// Auto-heal the printer queue if it got paused
|
|
69
|
+
await hw.fix(printer.name);
|
|
70
|
+
const jobId = await hw.print(printer.name, buffer, {
|
|
71
|
+
fit: true,
|
|
72
|
+
media: 'w288h432'
|
|
73
|
+
});
|
|
74
|
+
console.log(`✅ Job ID: ${jobId}`);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
console.error(`❌ Print failed:`, err);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
count++;
|
|
81
|
+
yield {
|
|
82
|
+
activity,
|
|
83
|
+
summary: rollingSummary,
|
|
84
|
+
labelPath: filePath
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
package/dist/print.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type device = {
|
|
2
|
+
name: string;
|
|
3
|
+
usb: boolean;
|
|
4
|
+
def: boolean;
|
|
5
|
+
stat: string;
|
|
6
|
+
};
|
|
7
|
+
export type config = {
|
|
8
|
+
copies?: number;
|
|
9
|
+
media?: string;
|
|
10
|
+
fit?: boolean;
|
|
11
|
+
gray?: boolean;
|
|
12
|
+
flags?: Record<string, string>;
|
|
13
|
+
};
|
|
14
|
+
export default function thermal(): {
|
|
15
|
+
scan: () => Promise<device[]>;
|
|
16
|
+
find: () => Promise<device | null>;
|
|
17
|
+
fix: (name: string) => Promise<void>;
|
|
18
|
+
print: (target: string, input: Buffer | string, opts?: config) => Promise<string>;
|
|
19
|
+
watch: (signal: AbortSignal, delay?: number) => Promise<void>;
|
|
20
|
+
};
|
package/dist/print.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { writeFile, unlink, access, constants } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
const run = promisify(exec);
|
|
8
|
+
// --- internals ---
|
|
9
|
+
const file = async (buffer, fn) => {
|
|
10
|
+
const path = join(tmpdir(), `sticker-${randomUUID()}.png`);
|
|
11
|
+
await writeFile(path, buffer);
|
|
12
|
+
try {
|
|
13
|
+
return await fn(path);
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
unlink(path).catch(() => { });
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const parse = (list, devices) => {
|
|
20
|
+
const lines = devices.split("\n");
|
|
21
|
+
const fallback = list.match(/system default destination: (.+)/)?.[1];
|
|
22
|
+
return list
|
|
23
|
+
.split("\n")
|
|
24
|
+
.map((line) => line.match(/printer (.+?) (.*)/))
|
|
25
|
+
.filter((m) => !!m)
|
|
26
|
+
.map(([, name, stat]) => {
|
|
27
|
+
const dev = lines.find((d) => d.includes(name));
|
|
28
|
+
return {
|
|
29
|
+
name,
|
|
30
|
+
stat,
|
|
31
|
+
usb: dev?.toLowerCase().includes("usb") ?? false,
|
|
32
|
+
def: name === fallback,
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
// --- module ---
|
|
37
|
+
export default function thermal() {
|
|
38
|
+
const scan = async () => {
|
|
39
|
+
try {
|
|
40
|
+
const [ls, devs] = await Promise.all([
|
|
41
|
+
run("lpstat -p -d"),
|
|
42
|
+
run("lpstat -v"),
|
|
43
|
+
]);
|
|
44
|
+
return parse(ls.stdout, devs.stdout);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const find = async () => {
|
|
51
|
+
const devs = await scan();
|
|
52
|
+
return (devs.find((d) => d.usb && d.def) ||
|
|
53
|
+
devs.find((d) => d.usb) ||
|
|
54
|
+
null);
|
|
55
|
+
};
|
|
56
|
+
const fix = async (name) => {
|
|
57
|
+
const { stdout } = await run(`lpstat -p "${name}"`);
|
|
58
|
+
const bad = ["disabled", "paused"].some((s) => stdout.toLowerCase().includes(s));
|
|
59
|
+
if (bad) {
|
|
60
|
+
await Promise.all([
|
|
61
|
+
run(`cupsenable "${name}"`),
|
|
62
|
+
run(`cupsaccept "${name}"`),
|
|
63
|
+
]);
|
|
64
|
+
console.log(`✨ healed: ${name}`);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const print = async (target, input, opts = {}) => {
|
|
68
|
+
const task = async (path) => {
|
|
69
|
+
const args = [
|
|
70
|
+
`-d "${target}"`,
|
|
71
|
+
opts.copies && `-n ${opts.copies}`,
|
|
72
|
+
opts.media && `-o media=${opts.media}`,
|
|
73
|
+
opts.gray && "-o ColorModel=Gray",
|
|
74
|
+
opts.fit && "-o fit-to-page",
|
|
75
|
+
...Object.entries(opts.flags || {}).map(([k, v]) => `-o ${k}=${v}`),
|
|
76
|
+
`"${path}"`,
|
|
77
|
+
].filter(Boolean);
|
|
78
|
+
const { stdout } = await run(`lp ${args.join(" ")}`);
|
|
79
|
+
return stdout.match(/request id is .+-(\d+)/)?.[1] || "unknown";
|
|
80
|
+
};
|
|
81
|
+
if (Buffer.isBuffer(input)) {
|
|
82
|
+
return file(input, task);
|
|
83
|
+
}
|
|
84
|
+
await access(input, constants.R_OK);
|
|
85
|
+
return task(input);
|
|
86
|
+
};
|
|
87
|
+
const watch = async (signal, delay = 2000) => {
|
|
88
|
+
const tick = async () => {
|
|
89
|
+
if (signal.aborted)
|
|
90
|
+
return;
|
|
91
|
+
const devs = await scan();
|
|
92
|
+
const usb = devs.filter((d) => d.usb);
|
|
93
|
+
await Promise.all(usb.map((d) => fix(d.name)));
|
|
94
|
+
if (!signal.aborted)
|
|
95
|
+
setTimeout(tick, delay);
|
|
96
|
+
};
|
|
97
|
+
tick();
|
|
98
|
+
};
|
|
99
|
+
return { scan, find, fix, print, watch };
|
|
100
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Activity } from '@google/jules-sdk';
|
|
2
|
+
import { ChangeSetSummary } from './types.js';
|
|
3
|
+
export interface ProcessedActivity {
|
|
4
|
+
activityId: string;
|
|
5
|
+
type: 'changeSet';
|
|
6
|
+
summary: ChangeSetSummary;
|
|
7
|
+
rawActivity: Activity;
|
|
8
|
+
}
|
|
9
|
+
export interface StreamMetricsOptions {
|
|
10
|
+
/**
|
|
11
|
+
* If true, keeps the stream open and listens for new activities from the network.
|
|
12
|
+
* If false, only reads existing history from the local cache and exits.
|
|
13
|
+
* @default true
|
|
14
|
+
*/
|
|
15
|
+
live?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Streams activities from a Jules session and yields parsed metrics
|
|
19
|
+
* whenever a ChangeSet is encountered.
|
|
20
|
+
*/
|
|
21
|
+
export declare function streamChangeMetrics(sessionId: string, options?: StreamMetricsOptions): AsyncGenerator<ProcessedActivity>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jules } from '@google/jules-sdk';
|
|
2
|
+
import { analyzeChangeSet } from './analyzer.js';
|
|
3
|
+
/**
|
|
4
|
+
* Streams activities from a Jules session and yields parsed metrics
|
|
5
|
+
* whenever a ChangeSet is encountered.
|
|
6
|
+
*/
|
|
7
|
+
export async function* streamChangeMetrics(sessionId, options = {}) {
|
|
8
|
+
const { live = true } = options;
|
|
9
|
+
const session = jules.session(sessionId);
|
|
10
|
+
// Choose between the infinite stream or the finite history
|
|
11
|
+
const activityStream = live ? session.stream() : session.history();
|
|
12
|
+
for await (const activity of activityStream) {
|
|
13
|
+
// We look for specific artifact types in the activity
|
|
14
|
+
const changeSetArtifact = activity.artifacts?.find(a => a.type === 'changeSet');
|
|
15
|
+
if (changeSetArtifact && changeSetArtifact.gitPatch?.unidiffPatch) {
|
|
16
|
+
const rawPatch = changeSetArtifact.gitPatch.unidiffPatch;
|
|
17
|
+
const summary = analyzeChangeSet(rawPatch);
|
|
18
|
+
yield {
|
|
19
|
+
activityId: activity.id,
|
|
20
|
+
type: 'changeSet',
|
|
21
|
+
summary,
|
|
22
|
+
rawActivity: activity
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { serve } from '@hono/node-server';
|
|
3
|
+
import { cors } from 'hono/cors';
|
|
4
|
+
import { GoogleGenAI } from "@google/genai";
|
|
5
|
+
import thermal from './print.js';
|
|
6
|
+
const app = new Hono();
|
|
7
|
+
const port = 3000;
|
|
8
|
+
const hw = thermal();
|
|
9
|
+
app.use('/*', cors());
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
hw.watch(controller.signal);
|
|
12
|
+
process.on('SIGINT', () => {
|
|
13
|
+
controller.abort();
|
|
14
|
+
process.exit();
|
|
15
|
+
});
|
|
16
|
+
const apiKey = process.env["GEMINI_API_KEY"];
|
|
17
|
+
if (!apiKey) {
|
|
18
|
+
throw new Error("GEMINI_API_KEY environment variable is required");
|
|
19
|
+
}
|
|
20
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
21
|
+
const model = "imagen-4.0-generate-001";
|
|
22
|
+
app.post('/api/generate', async (c) => {
|
|
23
|
+
const { prompt } = await c.req.json();
|
|
24
|
+
if (!prompt)
|
|
25
|
+
return c.json({ error: 'prompt required' }, 400);
|
|
26
|
+
try {
|
|
27
|
+
console.log(`🎨 dreaming: "${prompt}"`);
|
|
28
|
+
const res = await ai.models.generateImages({
|
|
29
|
+
model,
|
|
30
|
+
prompt: `A black and white kids coloring page. <image-description>${prompt}</image-description> ${prompt}`,
|
|
31
|
+
config: { numberOfImages: 1, aspectRatio: "9:16" },
|
|
32
|
+
});
|
|
33
|
+
const bytes = res.generatedImages?.[0]?.image?.imageBytes;
|
|
34
|
+
if (!bytes)
|
|
35
|
+
throw new Error('no bytes');
|
|
36
|
+
const buffer = Buffer.from(bytes, "base64");
|
|
37
|
+
const target = await hw.find();
|
|
38
|
+
if (target) {
|
|
39
|
+
console.log(`🖨️ routing: ${target.name}`);
|
|
40
|
+
hw.print(target.name, buffer, { fit: true })
|
|
41
|
+
.catch(err => console.warn('print error:', err));
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
console.warn('⚠️ no usb found');
|
|
45
|
+
}
|
|
46
|
+
return new Response(new Uint8Array(buffer), {
|
|
47
|
+
headers: { 'Content-Type': 'image/png' },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
console.error('error:', err);
|
|
52
|
+
return c.json({ error: err instanceof Error ? err.message : 'unknown' }, 500);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
serve({ fetch: app.fetch, port }, (info) => {
|
|
56
|
+
console.log(`🚀 server at http://localhost:${info.port}`);
|
|
57
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Activity } from '@google/jules-sdk';
|
|
2
|
+
export type TonePreset = 'professional' | 'pirate' | 'shakespearean' | 'excited' | 'haiku' | 'noir';
|
|
3
|
+
export interface SummarizerConfig {
|
|
4
|
+
backend?: 'cloud' | 'local';
|
|
5
|
+
apiKey?: string;
|
|
6
|
+
localModelName?: string;
|
|
7
|
+
cloudModelName?: string;
|
|
8
|
+
tone?: string;
|
|
9
|
+
tier?: 'free' | 'tier1';
|
|
10
|
+
}
|
|
11
|
+
export declare class SessionSummarizer {
|
|
12
|
+
private backend;
|
|
13
|
+
private localModelName;
|
|
14
|
+
private toneModifier;
|
|
15
|
+
private genAI;
|
|
16
|
+
private genModel;
|
|
17
|
+
private ollama;
|
|
18
|
+
private limiter;
|
|
19
|
+
constructor(config?: SummarizerConfig);
|
|
20
|
+
generateRollingSummary(previousState: string, currentActivity: Activity): Promise<string>;
|
|
21
|
+
private getCodePrompt;
|
|
22
|
+
private getPlanningPrompt;
|
|
23
|
+
getLabelData(activity: Activity): {
|
|
24
|
+
path: string;
|
|
25
|
+
additions: number;
|
|
26
|
+
deletions: number;
|
|
27
|
+
}[];
|
|
28
|
+
private executeRequest;
|
|
29
|
+
private simplifyActivity;
|
|
30
|
+
private buildSmartContext;
|
|
31
|
+
private extractHunkHeaders;
|
|
32
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { GoogleGenAI } from '@google/genai';
|
|
2
|
+
import { Ollama } from 'ollama';
|
|
3
|
+
import parseDiff from 'parse-diff';
|
|
4
|
+
import micromatch from 'micromatch';
|
|
5
|
+
import { encode } from 'gpt-tokenizer';
|
|
6
|
+
import Bottleneck from 'bottleneck';
|
|
7
|
+
// --- Configuration Constants ---
|
|
8
|
+
const CLOUD_MODEL_NAME = 'gemini-2.5-flash-lite';
|
|
9
|
+
const LOCAL_MODEL_DEFAULT = 'gemma3:4b';
|
|
10
|
+
const TOKEN_LIMIT_PER_FILE = 2000;
|
|
11
|
+
const MAX_TOTAL_TOKENS = 6000;
|
|
12
|
+
const IGNORE_PATTERNS = [
|
|
13
|
+
'**/package-lock.json',
|
|
14
|
+
'**/yarn.lock',
|
|
15
|
+
'**/*.map',
|
|
16
|
+
'**/dist/**',
|
|
17
|
+
'**/*.min.js',
|
|
18
|
+
'**/*.d.ts'
|
|
19
|
+
];
|
|
20
|
+
const TONE_MODIFIERS = {
|
|
21
|
+
professional: '',
|
|
22
|
+
pirate: 'Write in the style of a pirate. Use nautical terms and pirate slang like "arr", "matey", "ye", "be", etc.',
|
|
23
|
+
shakespearean: 'Write in Shakespearean English with dramatic flair. Use "doth", "hark", "forsooth", "verily", etc.',
|
|
24
|
+
excited: 'Write with EXTREME enthusiasm!!! Use exclamation marks and emojis like 🎉🚀✨💥!',
|
|
25
|
+
haiku: 'Format your response as a haiku (5-7-5 syllable structure). Be poetic and zen.',
|
|
26
|
+
noir: 'Write in the style of a 1940s noir detective narration. Dark, moody, metaphorical.',
|
|
27
|
+
};
|
|
28
|
+
/** Resolves a tone string to its modifier. Accepts preset names or custom instructions. */
|
|
29
|
+
function resolveToneModifier(tone) {
|
|
30
|
+
if (!tone || tone === 'professional')
|
|
31
|
+
return '';
|
|
32
|
+
if (tone in TONE_MODIFIERS)
|
|
33
|
+
return TONE_MODIFIERS[tone];
|
|
34
|
+
// Treat as custom tone instruction
|
|
35
|
+
return tone;
|
|
36
|
+
}
|
|
37
|
+
export class SessionSummarizer {
|
|
38
|
+
backend;
|
|
39
|
+
localModelName;
|
|
40
|
+
toneModifier;
|
|
41
|
+
genAI = null;
|
|
42
|
+
genModel = null;
|
|
43
|
+
ollama = null;
|
|
44
|
+
limiter;
|
|
45
|
+
constructor(config = {}) {
|
|
46
|
+
this.backend = config.backend || 'local';
|
|
47
|
+
this.localModelName = config.localModelName || LOCAL_MODEL_DEFAULT;
|
|
48
|
+
this.toneModifier = resolveToneModifier(config.tone);
|
|
49
|
+
if (this.backend === 'cloud') {
|
|
50
|
+
const key = config.apiKey || process.env.GEMINI_API_KEY;
|
|
51
|
+
if (!key)
|
|
52
|
+
throw new Error('Missing GEMINI_API_KEY for cloud backend');
|
|
53
|
+
this.genAI = new GoogleGenAI({ apiKey: key });
|
|
54
|
+
this.genModel = config.cloudModelName || CLOUD_MODEL_NAME;
|
|
55
|
+
const isTier1 = config.tier === 'tier1';
|
|
56
|
+
this.limiter = new Bottleneck({
|
|
57
|
+
minTime: isTier1 ? 20 : 4000,
|
|
58
|
+
maxConcurrent: isTier1 ? 5 : 1
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
this.ollama = new Ollama();
|
|
63
|
+
this.limiter = new Bottleneck({ maxConcurrent: 1 });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async generateRollingSummary(previousState, currentActivity) {
|
|
67
|
+
const activityContext = this.simplifyActivity(currentActivity);
|
|
68
|
+
// DECISION LOGIC: Coding (Diffs) vs. Non-Coding (Plans/Messages)
|
|
69
|
+
const isCodeTask = !!activityContext.context;
|
|
70
|
+
if (isCodeTask) {
|
|
71
|
+
return this.executeRequest(this.getCodePrompt(activityContext));
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
return this.executeRequest(this.getPlanningPrompt(activityContext));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// --- PROMPT 1: THE ACTIVE CODER (For Diffs) ---
|
|
78
|
+
getCodePrompt(context) {
|
|
79
|
+
const toneInstruction = this.toneModifier ? `\n 4. TONE: ${this.toneModifier}` : '';
|
|
80
|
+
return `
|
|
81
|
+
ROLE: You are the AI Developer.
|
|
82
|
+
TASK: Report the technical action taken.
|
|
83
|
+
|
|
84
|
+
INPUT DATA GUIDANCE:
|
|
85
|
+
- 'status': 'truncated_large_file' -> Infer changes from 'affectedScopes'.
|
|
86
|
+
- 'status': 'included' -> Read 'diff' for logic details.
|
|
87
|
+
|
|
88
|
+
NEW ACTIVITY:
|
|
89
|
+
${JSON.stringify(context)}
|
|
90
|
+
|
|
91
|
+
INSTRUCTIONS:
|
|
92
|
+
1. STYLE: Direct Technical Statement. Start with the Verb.
|
|
93
|
+
- YES: "Refactoring SessionClient to support new handshake."
|
|
94
|
+
- NO: "I am updating..." or "Updating..." (Passive)
|
|
95
|
+
2. FORMATTING: Wrap ALL filenames/classes in backticks (\`).
|
|
96
|
+
3. LENGTH: 110-150 chars.${toneInstruction}
|
|
97
|
+
|
|
98
|
+
OUTPUT:
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
// --- PROMPT 2: THE OBJECTIVE REPORTER (For Plans/Messages) ---
|
|
102
|
+
getPlanningPrompt(context) {
|
|
103
|
+
const toneInstruction = this.toneModifier ? `\n 5. TONE: ${this.toneModifier}` : '';
|
|
104
|
+
return `
|
|
105
|
+
ROLE: You are a Project Logger.
|
|
106
|
+
TASK: Summarize the event based on the JSON below.
|
|
107
|
+
|
|
108
|
+
NEW ACTIVITY:
|
|
109
|
+
${JSON.stringify(context)}
|
|
110
|
+
|
|
111
|
+
INSTRUCTIONS:
|
|
112
|
+
1. STYLE: Objective & Natural.
|
|
113
|
+
- BAD: "Hey, I made a plan..." (Too chatty)
|
|
114
|
+
- BAD: "Strategizing the execution..." (Too corporate)
|
|
115
|
+
- GOOD: "Generated a plan to update the SessionClient logic."
|
|
116
|
+
- GOOD: "Plan approved. Starting implementation."
|
|
117
|
+
- GOOD: "User requested a format check."
|
|
118
|
+
|
|
119
|
+
2. BAN LIST: Do NOT start sentences with "Hey", "Okay", "So", "Alright", "Well".
|
|
120
|
+
|
|
121
|
+
3. CONTENT:
|
|
122
|
+
- Use the 'title' or 'description' fields from the input.
|
|
123
|
+
- Summarize the specific goal (e.g. "update SessionClient").
|
|
124
|
+
|
|
125
|
+
4. LENGTH: 110-150 chars.${toneInstruction}
|
|
126
|
+
|
|
127
|
+
OUTPUT:
|
|
128
|
+
`;
|
|
129
|
+
}
|
|
130
|
+
// ... (getLabelData, executeRequest, etc. remain exactly the same) ...
|
|
131
|
+
getLabelData(activity) {
|
|
132
|
+
const changeSetArtifact = activity.artifacts?.find(a => a.type === 'changeSet');
|
|
133
|
+
if (!changeSetArtifact || changeSetArtifact.type !== 'changeSet' || !changeSetArtifact.gitPatch)
|
|
134
|
+
return [];
|
|
135
|
+
const files = parseDiff(changeSetArtifact.gitPatch.unidiffPatch);
|
|
136
|
+
return files
|
|
137
|
+
.filter(f => !micromatch.isMatch(f.to || f.from || '', IGNORE_PATTERNS))
|
|
138
|
+
.map(file => ({
|
|
139
|
+
path: file.to || file.from || 'unknown',
|
|
140
|
+
additions: file.additions,
|
|
141
|
+
deletions: file.deletions
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
async executeRequest(prompt, attempt = 1) {
|
|
145
|
+
return this.limiter.schedule(async () => {
|
|
146
|
+
try {
|
|
147
|
+
let text = '';
|
|
148
|
+
if (this.backend === 'cloud' && this.genAI) {
|
|
149
|
+
const result = await this.genAI.models.generateContent({
|
|
150
|
+
model: this.genModel,
|
|
151
|
+
contents: prompt,
|
|
152
|
+
});
|
|
153
|
+
text = result.text || '';
|
|
154
|
+
}
|
|
155
|
+
else if (this.backend === 'local' && this.ollama) {
|
|
156
|
+
const response = await this.ollama.chat({
|
|
157
|
+
model: this.localModelName,
|
|
158
|
+
messages: [{ role: 'user', content: prompt }],
|
|
159
|
+
options: { temperature: 0.2, num_ctx: 8192, num_predict: 128 }
|
|
160
|
+
});
|
|
161
|
+
text = response.message.content;
|
|
162
|
+
}
|
|
163
|
+
return text.replace(/```/g, '').replace(/[\n\r]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
const isRetryable = error.status === 429 || error.message?.includes('429') ||
|
|
167
|
+
error.status === 503 || error.message?.includes('503') ||
|
|
168
|
+
error.message?.includes('overloaded');
|
|
169
|
+
if (this.backend === 'cloud' && isRetryable && attempt < 5) {
|
|
170
|
+
const backoffMs = Math.min(2000 * Math.pow(2, attempt - 1), 60000);
|
|
171
|
+
console.log(`[Summarizer] Retrying in ${backoffMs / 1000}s (attempt ${attempt}/5)...`);
|
|
172
|
+
await new Promise(r => setTimeout(r, backoffMs));
|
|
173
|
+
return this.executeRequest(prompt, attempt + 1);
|
|
174
|
+
}
|
|
175
|
+
console.error(`[Summarizer] Error:`, error.message);
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
// --- UPDATED SIMPLIFY ACTIVITY ---
|
|
181
|
+
simplifyActivity(activity) {
|
|
182
|
+
const base = { type: activity.type, originator: activity.originator };
|
|
183
|
+
// 1. Code Changes
|
|
184
|
+
const changeSet = activity.artifacts?.find(a => a.type === 'changeSet');
|
|
185
|
+
if (changeSet && changeSet.type === 'changeSet' && changeSet.gitPatch) {
|
|
186
|
+
return {
|
|
187
|
+
...base,
|
|
188
|
+
context: this.buildSmartContext(changeSet.gitPatch.unidiffPatch),
|
|
189
|
+
commitMessage: changeSet.gitPatch.suggestedCommitMessage
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
// 2. Planning - EXTRACT ACTUAL TEXT
|
|
193
|
+
if (activity.type === 'planGenerated') {
|
|
194
|
+
// Deep extraction to find meaningful text
|
|
195
|
+
const plan = activity.planGenerated?.plan || activity.plan;
|
|
196
|
+
const firstStep = plan?.steps?.[0]?.title;
|
|
197
|
+
const activityTitle = activity.title;
|
|
198
|
+
// Priority: Activity Title -> First Step Title -> Generic fallback
|
|
199
|
+
const description = activityTitle || firstStep || 'Plan generated with no details.';
|
|
200
|
+
return { ...base, description };
|
|
201
|
+
}
|
|
202
|
+
if (activity.type === 'planApproved') {
|
|
203
|
+
return { ...base, description: 'User approved the plan.' };
|
|
204
|
+
}
|
|
205
|
+
// 3. Messages
|
|
206
|
+
if (activity.type === 'agentMessaged' || activity.type === 'userMessaged') {
|
|
207
|
+
return { ...base, message: activity.message };
|
|
208
|
+
}
|
|
209
|
+
// 4. Progress
|
|
210
|
+
if (activity.type === 'progressUpdated') {
|
|
211
|
+
return { ...base, description: activity.title };
|
|
212
|
+
}
|
|
213
|
+
return base;
|
|
214
|
+
}
|
|
215
|
+
buildSmartContext(rawDiff) {
|
|
216
|
+
const files = parseDiff(rawDiff);
|
|
217
|
+
let currentTokenCount = 0;
|
|
218
|
+
return files.map(file => {
|
|
219
|
+
const filePath = file.to || file.from || 'unknown';
|
|
220
|
+
if (micromatch.isMatch(filePath, IGNORE_PATTERNS)) {
|
|
221
|
+
return { file: filePath, status: 'ignored_artifact', changes: `+${file.additions}/-${file.deletions}` };
|
|
222
|
+
}
|
|
223
|
+
const fileDiffString = file.chunks.map(c => c.content).join('\n');
|
|
224
|
+
const fileTokens = encode(fileDiffString).length;
|
|
225
|
+
if (currentTokenCount + fileTokens > MAX_TOTAL_TOKENS) {
|
|
226
|
+
return { file: filePath, status: 'truncated_budget_exceeded', summary: `Modified ${file.additions} lines`, affectedMethods: this.extractHunkHeaders(file.chunks) };
|
|
227
|
+
}
|
|
228
|
+
if (fileTokens > TOKEN_LIMIT_PER_FILE) {
|
|
229
|
+
return { file: filePath, status: 'truncated_large_file', summary: `Large refactor (+${file.additions}/-${file.deletions})`, affectedScopes: this.extractHunkHeaders(file.chunks) };
|
|
230
|
+
}
|
|
231
|
+
currentTokenCount += fileTokens;
|
|
232
|
+
return { file: filePath, status: 'included', diff: fileDiffString };
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
extractHunkHeaders(chunks) {
|
|
236
|
+
return chunks.map(chunk => {
|
|
237
|
+
const headerMatch = chunk.content.match(/@@.*?@@\s*(.*)/);
|
|
238
|
+
return headerMatch ? headerMatch[1].trim() : null;
|
|
239
|
+
}).filter(Boolean).slice(0, 5);
|
|
240
|
+
}
|
|
241
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents the statistical impact of changes on a specific file.
|
|
3
|
+
*/
|
|
4
|
+
export interface FileImpact {
|
|
5
|
+
path: string;
|
|
6
|
+
additions: number;
|
|
7
|
+
deletions: number;
|
|
8
|
+
totalChanges: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A comprehensive report of a Git Patch / ChangeSet.
|
|
12
|
+
*/
|
|
13
|
+
export interface ChangeSetSummary {
|
|
14
|
+
files: FileImpact[];
|
|
15
|
+
totalFiles: number;
|
|
16
|
+
totalInsertions: number;
|
|
17
|
+
totalDeletions: number;
|
|
18
|
+
/**
|
|
19
|
+
* Formatted string suitable for footer display.
|
|
20
|
+
* e.g., "3 files changed, 48 insertions(+), 14 deletions(-)"
|
|
21
|
+
*/
|
|
22
|
+
summaryString: string;
|
|
23
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { CanvasRenderingContext2D } from '@napi-rs/canvas';
|
|
2
|
+
export declare function calculateWrappedLines(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[];
|
|
3
|
+
export declare function truncateMiddle(text: string, maxLength: number): string;
|
|
4
|
+
export interface TextSegment {
|
|
5
|
+
text: string;
|
|
6
|
+
isCode: boolean;
|
|
7
|
+
width?: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Splits string by backticks into normal and code segments.
|
|
11
|
+
* e.g., "foo `bar` baz" -> [{text:"foo ", isCode:false}, {text:"bar", isCode:true}, {text:" baz", isCode:false}]
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseMarkdownSegments(text: string): TextSegment[];
|
|
14
|
+
/**
|
|
15
|
+
* Advanced wrapper that handles mixed-style segments AND long-word breaking.
|
|
16
|
+
*/
|
|
17
|
+
export declare function calculateWrappedSegments(ctx: any, segments: TextSegment[], maxWidth: number, normalFont: string, codeFont: string): TextSegment[][];
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Helper to wrap text
|
|
2
|
+
export function calculateWrappedLines(ctx, text, maxWidth) {
|
|
3
|
+
const words = text.split(' ');
|
|
4
|
+
const lines = [];
|
|
5
|
+
let currentLine = words[0];
|
|
6
|
+
for (let i = 1; i < words.length; i++) {
|
|
7
|
+
const word = words[i];
|
|
8
|
+
const width = ctx.measureText(currentLine + " " + word).width;
|
|
9
|
+
if (width < maxWidth) {
|
|
10
|
+
currentLine += " " + word;
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
lines.push(currentLine);
|
|
14
|
+
currentLine = word;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
lines.push(currentLine);
|
|
18
|
+
return lines;
|
|
19
|
+
}
|
|
20
|
+
// Helper to truncate middle
|
|
21
|
+
export function truncateMiddle(text, maxLength) {
|
|
22
|
+
if (text.length <= maxLength)
|
|
23
|
+
return text;
|
|
24
|
+
const ellipsis = '...';
|
|
25
|
+
const charsToShow = maxLength - ellipsis.length;
|
|
26
|
+
const frontChars = Math.ceil(charsToShow / 2);
|
|
27
|
+
const backChars = Math.floor(charsToShow / 2);
|
|
28
|
+
return text.substring(0, frontChars) + ellipsis + text.substring(text.length - backChars);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Splits string by backticks into normal and code segments.
|
|
32
|
+
* e.g., "foo `bar` baz" -> [{text:"foo ", isCode:false}, {text:"bar", isCode:true}, {text:" baz", isCode:false}]
|
|
33
|
+
*/
|
|
34
|
+
export function parseMarkdownSegments(text) {
|
|
35
|
+
// Split by backtick.
|
|
36
|
+
// Even indices are normal text, Odd indices are code.
|
|
37
|
+
const rawSegments = text.split('`');
|
|
38
|
+
const segments = [];
|
|
39
|
+
rawSegments.forEach((segmentText, index) => {
|
|
40
|
+
if (segmentText.length === 0)
|
|
41
|
+
return; // Skip empty splits
|
|
42
|
+
segments.push({
|
|
43
|
+
text: segmentText,
|
|
44
|
+
isCode: index % 2 !== 0 // Odd index means it was inside backticks
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
return segments;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Advanced wrapper that handles mixed-style segments AND long-word breaking.
|
|
51
|
+
*/
|
|
52
|
+
export function calculateWrappedSegments(ctx, segments, maxWidth, normalFont, codeFont) {
|
|
53
|
+
const lines = [];
|
|
54
|
+
let currentLine = [];
|
|
55
|
+
let currentLineWidth = 0;
|
|
56
|
+
for (const segment of segments) {
|
|
57
|
+
// 1. Set font for measurement
|
|
58
|
+
ctx.font = segment.isCode ? codeFont : normalFont;
|
|
59
|
+
// 2. Measure the raw text
|
|
60
|
+
const padding = segment.isCode ? 8 : 0; // 4px padding on each side for code
|
|
61
|
+
const segmentWidth = ctx.measureText(segment.text).width + padding;
|
|
62
|
+
// 3. Logic for Code Blocks vs Normal Text
|
|
63
|
+
if (segment.isCode) {
|
|
64
|
+
// CASE A: Code block fits on current line
|
|
65
|
+
if (currentLineWidth + segmentWidth <= maxWidth) {
|
|
66
|
+
currentLine.push({ ...segment, width: segmentWidth });
|
|
67
|
+
currentLineWidth += segmentWidth;
|
|
68
|
+
}
|
|
69
|
+
// CASE B: Code block is huge (wider than maxWidth) -> Force Break
|
|
70
|
+
else if (segmentWidth > maxWidth) {
|
|
71
|
+
// 1. Flush current line if it has content
|
|
72
|
+
if (currentLine.length > 0) {
|
|
73
|
+
lines.push(currentLine);
|
|
74
|
+
currentLine = [];
|
|
75
|
+
currentLineWidth = 0;
|
|
76
|
+
}
|
|
77
|
+
// 2. Break the long string into chunks
|
|
78
|
+
const chars = segment.text.split('');
|
|
79
|
+
let chunk = '';
|
|
80
|
+
for (const char of chars) {
|
|
81
|
+
const testChunk = chunk + char;
|
|
82
|
+
const testWidth = ctx.measureText(testChunk).width + padding;
|
|
83
|
+
if (testWidth > maxWidth) {
|
|
84
|
+
// Flush chunk to line
|
|
85
|
+
lines.push([{ text: chunk, isCode: true, width: ctx.measureText(chunk).width + padding }]);
|
|
86
|
+
chunk = char; // Start new chunk
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
chunk = testChunk;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Add final chunk
|
|
93
|
+
if (chunk.length > 0) {
|
|
94
|
+
const width = ctx.measureText(chunk).width + padding;
|
|
95
|
+
currentLine.push({ text: chunk, isCode: true, width });
|
|
96
|
+
currentLineWidth += width;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// CASE C: Code block fits on NEXT line
|
|
100
|
+
else {
|
|
101
|
+
lines.push(currentLine);
|
|
102
|
+
currentLine = [];
|
|
103
|
+
currentLineWidth = 0;
|
|
104
|
+
currentLine.push({ ...segment, width: segmentWidth });
|
|
105
|
+
currentLineWidth += segmentWidth;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// Normal Text Logic (Word Wrapping)
|
|
110
|
+
// This handles the "orphaned period" issue by keeping punctuation with the preceding word if possible
|
|
111
|
+
// or wrapping correctly.
|
|
112
|
+
const words = segment.text.split(/(\s+)/); // Split keeping delimiters to preserve spaces
|
|
113
|
+
for (const word of words) {
|
|
114
|
+
if (word.length === 0)
|
|
115
|
+
continue;
|
|
116
|
+
const wordWidth = ctx.measureText(word).width;
|
|
117
|
+
if (currentLineWidth + wordWidth <= maxWidth) {
|
|
118
|
+
currentLine.push({ text: word, isCode: false, width: wordWidth });
|
|
119
|
+
currentLineWidth += wordWidth;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// Start new line
|
|
123
|
+
lines.push(currentLine);
|
|
124
|
+
currentLine = [];
|
|
125
|
+
currentLineWidth = 0;
|
|
126
|
+
// If a single word is massive (unlikely in normal text, but possible), clip it
|
|
127
|
+
// For now, assume normal words fit on a line.
|
|
128
|
+
currentLine.push({ text: word, isCode: false, width: wordWidth });
|
|
129
|
+
currentLineWidth += wordWidth;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Flush remaining
|
|
135
|
+
if (currentLine.length > 0)
|
|
136
|
+
lines.push(currentLine);
|
|
137
|
+
return lines;
|
|
138
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jules-ink",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Print physical labels of your AI coding sessions with Jules",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/cli.js",
|
|
7
|
+
"types": "./dist/cli.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"jules-ink": "./dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"assets"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"prepublishOnly": "npm run build",
|
|
18
|
+
"start": "tsx src/index.ts",
|
|
19
|
+
"test": "vitest",
|
|
20
|
+
"test:smoke": "vitest run tests/cli.smoke.test.ts",
|
|
21
|
+
"gen:assets": "tsx scripts/create-base-template.ts"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"jules",
|
|
25
|
+
"ai",
|
|
26
|
+
"coding",
|
|
27
|
+
"labels",
|
|
28
|
+
"thermal-printer",
|
|
29
|
+
"cli"
|
|
30
|
+
],
|
|
31
|
+
"author": "David East",
|
|
32
|
+
"license": "Apache-2.0",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/davideast/jules.ink.git"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@google/genai": "^1.39.0",
|
|
39
|
+
"@google/jules-sdk": "^0.0.4",
|
|
40
|
+
"@napi-rs/canvas": "^0.1.44",
|
|
41
|
+
"bottleneck": "^2.19.5",
|
|
42
|
+
"commander": "^14.0.3",
|
|
43
|
+
"dotenv": "^16.4.5",
|
|
44
|
+
"gpt-tokenizer": "^3.4.0",
|
|
45
|
+
"micromatch": "^4.0.8",
|
|
46
|
+
"ollama": "^0.6.3",
|
|
47
|
+
"parse-diff": "^0.11.1"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@hono/node-server": "^1.19.6",
|
|
51
|
+
"@types/micromatch": "^4.0.10",
|
|
52
|
+
"@types/node": "^20.11.20",
|
|
53
|
+
"hono": "^4.10.6",
|
|
54
|
+
"tsx": "^4.7.1",
|
|
55
|
+
"typescript": "^5.3.3",
|
|
56
|
+
"vitest": "^1.3.1"
|
|
57
|
+
}
|
|
58
|
+
}
|