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 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
+ ![grumpy-cat](./assets/labels/grumpy-cat.png)
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
@@ -0,0 +1,6 @@
1
+ import { ChangeSetSummary } from './types.js';
2
+ /**
3
+ * Transforms a raw Unidiff string into structured stats,
4
+ * filtering out noise to match the Summarizer's logic.
5
+ */
6
+ export declare function analyzeChangeSet(unidiffPatch: string): ChangeSetSummary;
@@ -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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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>;
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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 {};
@@ -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
+ }