henri-photo 2.0.0

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.
Files changed (72) hide show
  1. package/README.md +108 -0
  2. package/dist/analyze/batch-runner.d.ts +10 -0
  3. package/dist/analyze/batch-runner.js +144 -0
  4. package/dist/analyze/local-analyzer.d.ts +16 -0
  5. package/dist/analyze/local-analyzer.js +119 -0
  6. package/dist/analyze/local-schema.d.ts +381 -0
  7. package/dist/analyze/local-schema.js +78 -0
  8. package/dist/analyze/prompt.d.ts +3 -0
  9. package/dist/analyze/prompt.js +91 -0
  10. package/dist/analyze/schema.d.ts +216 -0
  11. package/dist/analyze/schema.js +46 -0
  12. package/dist/analyze/upload-analyzer.d.ts +6 -0
  13. package/dist/analyze/upload-analyzer.js +45 -0
  14. package/dist/analyze/vision-analyzer.d.ts +10 -0
  15. package/dist/analyze/vision-analyzer.js +56 -0
  16. package/dist/channels/telegram-adapter.d.ts +8 -0
  17. package/dist/channels/telegram-adapter.js +282 -0
  18. package/dist/channels/twilio-adapter.d.ts +7 -0
  19. package/dist/channels/twilio-adapter.js +48 -0
  20. package/dist/cli.d.ts +2 -0
  21. package/dist/cli.js +405 -0
  22. package/dist/context/context-log.d.ts +22 -0
  23. package/dist/context/context-log.js +91 -0
  24. package/dist/core/conversation-router.d.ts +9 -0
  25. package/dist/core/conversation-router.js +199 -0
  26. package/dist/core/database.d.ts +4 -0
  27. package/dist/core/database.js +48 -0
  28. package/dist/core/main.d.ts +11 -0
  29. package/dist/core/main.js +94 -0
  30. package/dist/curate/collection-builder.d.ts +12 -0
  31. package/dist/curate/collection-builder.js +99 -0
  32. package/dist/curate/cross-body.d.ts +18 -0
  33. package/dist/curate/cross-body.js +142 -0
  34. package/dist/curate/essay-builder.d.ts +61 -0
  35. package/dist/curate/essay-builder.js +299 -0
  36. package/dist/curate/pair-finder.d.ts +8 -0
  37. package/dist/curate/pair-finder.js +93 -0
  38. package/dist/curate/scorer.d.ts +24 -0
  39. package/dist/curate/scorer.js +77 -0
  40. package/dist/curate/sequencer.d.ts +8 -0
  41. package/dist/curate/sequencer.js +66 -0
  42. package/dist/http/server.d.ts +7 -0
  43. package/dist/http/server.js +262 -0
  44. package/dist/ingest/eye-pipeline.d.ts +58 -0
  45. package/dist/ingest/eye-pipeline.js +342 -0
  46. package/dist/ingest/pipeline.d.ts +86 -0
  47. package/dist/ingest/pipeline.js +439 -0
  48. package/dist/lib/api.d.ts +3 -0
  49. package/dist/lib/api.js +25 -0
  50. package/dist/lib/config.d.ts +52 -0
  51. package/dist/lib/config.js +73 -0
  52. package/dist/lib/manifest.d.ts +74 -0
  53. package/dist/lib/manifest.js +40 -0
  54. package/dist/lib/state.d.ts +3 -0
  55. package/dist/lib/state.js +21 -0
  56. package/dist/lib/text.d.ts +6 -0
  57. package/dist/lib/text.js +51 -0
  58. package/dist/mcp/bin.d.ts +10 -0
  59. package/dist/mcp/bin.js +21 -0
  60. package/dist/mcp/server.d.ts +25 -0
  61. package/dist/mcp/server.js +89 -0
  62. package/dist/photos/apple-photos.d.ts +33 -0
  63. package/dist/photos/apple-photos.js +125 -0
  64. package/dist/search/query.d.ts +10 -0
  65. package/dist/search/query.js +93 -0
  66. package/dist/signal/generate-feed.d.ts +41 -0
  67. package/dist/signal/generate-feed.js +134 -0
  68. package/dist/signal/output.d.ts +17 -0
  69. package/dist/signal/output.js +111 -0
  70. package/dist/tools/henri-tools.d.ts +7 -0
  71. package/dist/tools/henri-tools.js +536 -0
  72. package/package.json +61 -0
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # henri-photo
2
+
3
+ Photographic intelligence via [Model Context Protocol](https://modelcontextprotocol.io). HENRI analyzes, curates, and critiques photographs with museum-curator eye.
4
+
5
+ Named after Henri Cartier-Bresson. Reads through formal analysis, historical literacy, and curatorial judgment.
6
+
7
+ ## Install
8
+
9
+ Add to your Claude Code MCP settings:
10
+
11
+ ```json
12
+ {
13
+ "mcpServers": {
14
+ "henri": {
15
+ "command": "npx",
16
+ "args": ["henri-photo", "--dir", "/path/to/your/henri-data"]
17
+ }
18
+ }
19
+ }
20
+ ```
21
+
22
+ Or run directly:
23
+
24
+ ```bash
25
+ npx henri-photo --dir ~/.henri
26
+ ```
27
+
28
+ ## Setup
29
+
30
+ HENRI reads from a data directory containing your analyzed photo archive:
31
+
32
+ ```
33
+ ~/.henri/
34
+ manifest-v2.json # Image manifest with analysis data
35
+ collections/ # Curated thematic collections
36
+ essays/ # Visual essays (critical prose)
37
+ pairs.json # Cross-body visual pairings
38
+ embeddings/ # CLIP embeddings, color, scene data
39
+ eye/ # Secondary camera manifest
40
+ ```
41
+
42
+ To build this data, use the HENRI CLI to analyze your photos:
43
+
44
+ ```bash
45
+ # Clone the repo for full CLI access
46
+ git clone https://github.com/sethgoldstein/henri
47
+ cd henri && npm install
48
+
49
+ # Set your data directory and photo source
50
+ export HENRI_DIR=~/.henri
51
+ export HENRI_PHOTOS=~/Pictures/photos
52
+
53
+ # Analyze your archive (~$0.03/image via Claude API)
54
+ npm run henri -- analyze --batch
55
+
56
+ # Generate collections and essays
57
+ npm run henri -- curate
58
+ npm run henri -- collections --propose
59
+ npm run henri -- essay -c <collection-id>
60
+ ```
61
+
62
+ ## MCP Tools
63
+
64
+ 15 tools available to any MCP-compatible client:
65
+
66
+ | Tool | Description |
67
+ |------|-------------|
68
+ | `search_archive` | Natural language search across all analysis axes |
69
+ | `get_image_analysis` | Full 5-axis analysis for a specific image |
70
+ | `list_collections` | All curated thematic collections |
71
+ | `get_collection` | Collection details with thesis and sequence |
72
+ | `get_daily_signal` | Daily selects, cross-body echoes, rising images |
73
+ | `get_tier_breakdown` | Archive quality tiers (exhibition through discard) |
74
+ | `get_cross_body_pairs` | Same-day echoes across camera bodies |
75
+ | `get_essay` | Read a visual essay |
76
+ | `list_essays` | All available essays |
77
+ | `search_by_color` | Find images by color, harmony, temperature |
78
+ | `get_shooting_patterns` | Temporal patterns, golden hour, burst groups |
79
+ | `find_similar` | CLIP-based visual similarity search |
80
+ | `get_scene_breakdown` | Scene types, indoor/outdoor, objects, materials |
81
+ | `get_people` | Face detection, distinct people, co-occurrences |
82
+ | `get_style_clusters` | Visual style clusters and near-duplicates |
83
+
84
+ ## The 5-Axis Framework
85
+
86
+ Every image is scored across five axes:
87
+
88
+ - **Formal** -- composition, light, spatial organization, color relationships
89
+ - **Technical** -- sharpness, exposure, focus decisions, processing
90
+ - **Semantic** -- subject, narrative, emotional register, cultural context
91
+ - **Historical** -- photographic traditions, references, lineage
92
+ - **Curatorial** -- tier ranking (exhibition/portfolio/strong/study/discard), 0-100 score
93
+
94
+ ## Pricing
95
+
96
+ - Analysis: ~$0.03/image (Claude API cost)
97
+ - Essay generation: ~$0.07/essay
98
+ - MCP server: free (reads pre-computed data)
99
+
100
+ ## Links
101
+
102
+ - [henri.photos](https://henri.photos) -- landing page and case studies
103
+ - [henri.photos/ask](https://henri.photos/ask) -- chat with HENRI
104
+ - [henri.photos/hire](https://henri.photos/hire) -- get started
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,10 @@
1
+ export interface BatchOptions {
2
+ concurrency: number;
3
+ costLimit: number;
4
+ resume: boolean;
5
+ dryRun: boolean;
6
+ singleImage?: string;
7
+ useThumbnails: boolean;
8
+ model: string;
9
+ }
10
+ export declare function runBatch(opts: BatchOptions): Promise<void>;
@@ -0,0 +1,144 @@
1
+ import path from 'path';
2
+ import { PATHS } from '../lib/config.js';
3
+ import { readJson, writeJson, log } from '../lib/state.js';
4
+ import { loadSiteManifest, saveHenriManifest } from '../lib/manifest.js';
5
+ import { analyzeImage } from './vision-analyzer.js';
6
+ function loadProgress() {
7
+ return readJson(PATHS.analysisProgress, {
8
+ completed: {},
9
+ totalCostUsd: 0,
10
+ totalInputTokens: 0,
11
+ totalOutputTokens: 0,
12
+ lastUpdated: new Date().toISOString(),
13
+ errors: [],
14
+ });
15
+ }
16
+ function saveProgress(progress) {
17
+ progress.lastUpdated = new Date().toISOString();
18
+ writeJson(PATHS.analysisProgress, progress);
19
+ }
20
+ export async function runBatch(opts) {
21
+ const siteManifest = loadSiteManifest();
22
+ const progress = opts.resume ? loadProgress() : {
23
+ completed: {},
24
+ totalCostUsd: 0,
25
+ totalInputTokens: 0,
26
+ totalOutputTokens: 0,
27
+ lastUpdated: new Date().toISOString(),
28
+ errors: [],
29
+ };
30
+ // Determine which images to process
31
+ let images = siteManifest.images;
32
+ if (opts.singleImage) {
33
+ const target = opts.singleImage.endsWith('.webp') ? opts.singleImage : `${opts.singleImage}.webp`;
34
+ images = images.filter(img => img.filename === target);
35
+ if (images.length === 0) {
36
+ log(`image not found: ${opts.singleImage}`);
37
+ return;
38
+ }
39
+ }
40
+ // Filter already completed
41
+ const pending = images.filter(img => !progress.completed[img.filename]);
42
+ log(`batch: ${pending.length} pending, ${Object.keys(progress.completed).length} completed, $${progress.totalCostUsd.toFixed(2)} spent`);
43
+ if (opts.dryRun) {
44
+ log('dry run — showing first image prompt:');
45
+ if (pending.length > 0) {
46
+ const img = pending[0];
47
+ const { buildSystemPrompt, buildUserPrompt } = await import('./prompt.js');
48
+ console.log('\n--- SYSTEM PROMPT ---');
49
+ console.log(buildSystemPrompt());
50
+ console.log('\n--- USER PROMPT ---');
51
+ console.log(buildUserPrompt(img.exif, img.analysis));
52
+ console.log(`\n--- IMAGE: ${img.filename} (${opts.useThumbnails ? 'thumbnail' : 'full-res'}) ---`);
53
+ const imgPath = opts.useThumbnails
54
+ ? path.join(PATHS.thumbnailDir, img.thumbnail.replace('thumbnails/', ''))
55
+ : path.join(PATHS.webpDir, img.path.replace('webp/', ''));
56
+ console.log(`Path: ${imgPath}`);
57
+ }
58
+ return;
59
+ }
60
+ // Process with concurrency control
61
+ let active = 0;
62
+ let idx = 0;
63
+ async function processOne(img) {
64
+ const imgPath = opts.useThumbnails
65
+ ? path.join(PATHS.thumbnailDir, img.thumbnail.replace('thumbnails/', ''))
66
+ : path.join(PATHS.webpDir, img.path.replace('webp/', ''));
67
+ try {
68
+ const result = await analyzeImage(imgPath, img.filename, img.exif, img.analysis, opts.model);
69
+ progress.completed[result.filename] = result.analysis;
70
+ progress.totalCostUsd += result.costUsd;
71
+ progress.totalInputTokens += result.inputTokens;
72
+ progress.totalOutputTokens += result.outputTokens;
73
+ saveProgress(progress);
74
+ const tier = result.analysis.curatorial.tier;
75
+ const score = result.analysis.curatorial.score;
76
+ log(`${result.filename} | ${score} | ${tier} | $${progress.totalCostUsd.toFixed(2)}/$${opts.costLimit.toFixed(2)}`);
77
+ }
78
+ catch (err) {
79
+ const errMsg = err?.message || String(err);
80
+ log(`ERROR ${img.filename}: ${errMsg}`);
81
+ progress.errors.push({
82
+ filename: img.filename,
83
+ error: errMsg,
84
+ timestamp: new Date().toISOString(),
85
+ });
86
+ saveProgress(progress);
87
+ // Retry once on non-validation errors
88
+ if (!errMsg.includes('ZodError') && !errMsg.includes('parse')) {
89
+ log(`retrying ${img.filename}...`);
90
+ try {
91
+ const result = await analyzeImage(imgPath, img.filename, img.exif, img.analysis, opts.model);
92
+ progress.completed[result.filename] = result.analysis;
93
+ progress.totalCostUsd += result.costUsd;
94
+ progress.totalInputTokens += result.inputTokens;
95
+ progress.totalOutputTokens += result.outputTokens;
96
+ saveProgress(progress);
97
+ log(`${result.filename} | ${result.analysis.curatorial.score} | ${result.analysis.curatorial.tier} | $${progress.totalCostUsd.toFixed(2)}/$${opts.costLimit.toFixed(2)} (retry)`);
98
+ }
99
+ catch (retryErr) {
100
+ log(`RETRY FAILED ${img.filename}: ${retryErr?.message || retryErr}`);
101
+ }
102
+ }
103
+ }
104
+ }
105
+ // Simple concurrency pool
106
+ const queue = [...pending];
107
+ const sessionStartCost = progress.totalCostUsd;
108
+ async function worker() {
109
+ while (queue.length > 0) {
110
+ // Check cost limit (per-session spend, not cumulative)
111
+ const sessionCost = progress.totalCostUsd - sessionStartCost;
112
+ if (sessionCost >= opts.costLimit) {
113
+ log(`session cost limit reached: $${sessionCost.toFixed(2)} >= $${opts.costLimit.toFixed(2)} (cumulative: $${progress.totalCostUsd.toFixed(2)})`);
114
+ return;
115
+ }
116
+ const img = queue.shift();
117
+ await processOne(img);
118
+ }
119
+ }
120
+ const workers = Array.from({ length: Math.min(opts.concurrency, queue.length) }, () => worker());
121
+ await Promise.all(workers);
122
+ // Build HENRI manifest
123
+ const henriManifest = {
124
+ metadata: {
125
+ ...siteManifest.metadata,
126
+ henriVersion: '2.0',
127
+ henriAnalysisDate: new Date().toISOString(),
128
+ },
129
+ images: siteManifest.images.map(img => ({
130
+ ...img,
131
+ henriAnalysis: progress.completed[img.filename],
132
+ })),
133
+ };
134
+ saveHenriManifest(henriManifest);
135
+ const completedCount = Object.keys(progress.completed).length;
136
+ const errorCount = progress.errors.length;
137
+ log(`batch complete: ${completedCount} analyzed, ${errorCount} errors, $${progress.totalCostUsd.toFixed(2)} total`);
138
+ // Tier breakdown
139
+ const tiers = { discard: 0, study: 0, strong: 0, portfolio: 0, exhibition: 0 };
140
+ for (const analysis of Object.values(progress.completed)) {
141
+ tiers[analysis.curatorial.tier]++;
142
+ }
143
+ log(`tiers: ${Object.entries(tiers).map(([k, v]) => `${k}=${v}`).join(', ')}`);
144
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Local Analyzer — TypeScript orchestrator for Python compute modules.
3
+ *
4
+ * Calls Python workers via child_process (same pattern as osxphotos integration).
5
+ * Manages checkpointing and result merging.
6
+ */
7
+ export declare function runEmbeddings(manifestPath?: string): void;
8
+ export declare function runColors(manifestPath?: string): void;
9
+ export declare function runFaces(manifestPath?: string): void;
10
+ export declare function runGeometry(manifestPath?: string): void;
11
+ export declare function runOcr(manifestPath?: string): void;
12
+ export declare function runTemporal(manifestPath?: string): void;
13
+ export declare function runScene(manifestPath?: string): void;
14
+ export declare function runClustering(): void;
15
+ export declare function runFullPipeline(manifestPath?: string): void;
16
+ export declare function computeStatus(): void;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Local Analyzer — TypeScript orchestrator for Python compute modules.
3
+ *
4
+ * Calls Python workers via child_process (same pattern as osxphotos integration).
5
+ * Manages checkpointing and result merging.
6
+ */
7
+ import { execSync } from 'child_process';
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { PATHS } from '../lib/config.js';
11
+ import { log } from '../lib/state.js';
12
+ function ensureOutputDir() {
13
+ fs.mkdirSync(PATHS.embeddingsDir, { recursive: true });
14
+ }
15
+ function runPython(command, args, timeoutMs = 600_000) {
16
+ const pythonCmd = `python3 -m compute ${command} ${args.join(' ')}`;
17
+ log(`running: ${pythonCmd}`);
18
+ try {
19
+ const output = execSync(pythonCmd, {
20
+ cwd: path.join(PATHS.computeDir, '..'), // Run from src/ so `compute` is a package
21
+ timeout: timeoutMs,
22
+ maxBuffer: 50 * 1024 * 1024,
23
+ encoding: 'utf-8',
24
+ stdio: ['pipe', 'pipe', 'pipe'],
25
+ });
26
+ return output;
27
+ }
28
+ catch (err) {
29
+ const stderr = err.stderr?.toString() || '';
30
+ const stdout = err.stdout?.toString() || '';
31
+ log(`compute error: ${stderr.substring(0, 500)}`);
32
+ if (stdout)
33
+ log(`compute stdout: ${stdout.substring(0, 500)}`);
34
+ throw err;
35
+ }
36
+ }
37
+ // ─── Individual Module Runners ──────────────────────────────────────
38
+ export function runEmbeddings(manifestPath) {
39
+ const manifest = manifestPath || PATHS.henriManifest;
40
+ ensureOutputDir();
41
+ const output = runPython('embed', ['--manifest', manifest, '--output', PATHS.embeddingsDir]);
42
+ log(output.trim().split('\n').pop() || 'embeddings done');
43
+ }
44
+ export function runColors(manifestPath) {
45
+ const manifest = manifestPath || PATHS.henriManifest;
46
+ ensureOutputDir();
47
+ const output = runPython('colors', ['--manifest', manifest, '--output', path.join(PATHS.embeddingsDir, 'colors.json')]);
48
+ log(output.trim().split('\n').pop() || 'colors done');
49
+ }
50
+ export function runFaces(manifestPath) {
51
+ const manifest = manifestPath || PATHS.henriManifest;
52
+ ensureOutputDir();
53
+ const output = runPython('faces', ['--manifest', manifest, '--output', path.join(PATHS.embeddingsDir, 'faces.json')]);
54
+ log(output.trim().split('\n').pop() || 'faces done');
55
+ }
56
+ export function runGeometry(manifestPath) {
57
+ const manifest = manifestPath || PATHS.henriManifest;
58
+ ensureOutputDir();
59
+ const output = runPython('geometry', ['--manifest', manifest, '--output', path.join(PATHS.embeddingsDir, 'geometry.json')]);
60
+ log(output.trim().split('\n').pop() || 'geometry done');
61
+ }
62
+ export function runOcr(manifestPath) {
63
+ const manifest = manifestPath || PATHS.henriManifest;
64
+ ensureOutputDir();
65
+ const output = runPython('ocr', ['--manifest', manifest, '--output', path.join(PATHS.embeddingsDir, 'ocr.json')]);
66
+ log(output.trim().split('\n').pop() || 'ocr done');
67
+ }
68
+ export function runTemporal(manifestPath) {
69
+ const manifest = manifestPath || PATHS.henriManifest;
70
+ ensureOutputDir();
71
+ const output = runPython('temporal', ['--manifest', manifest, '--output', path.join(PATHS.embeddingsDir, 'temporal.json')]);
72
+ log(output.trim().split('\n').pop() || 'temporal done');
73
+ }
74
+ export function runScene(manifestPath) {
75
+ const manifest = manifestPath || PATHS.henriManifest;
76
+ ensureOutputDir();
77
+ const output = runPython('scene', ['--manifest', manifest, '--output', path.join(PATHS.embeddingsDir, 'scenes.json')]);
78
+ log(output.trim().split('\n').pop() || 'scene done');
79
+ }
80
+ export function runClustering() {
81
+ ensureOutputDir();
82
+ const output = runPython('cluster', ['--embeddings-dir', PATHS.embeddingsDir, '--output', path.join(PATHS.embeddingsDir, 'clusters.json')]);
83
+ log(output.trim().split('\n').pop() || 'clustering done');
84
+ }
85
+ // ─── Full Pipeline ──────────────────────────────────────────────────
86
+ export function runFullPipeline(manifestPath) {
87
+ const manifest = manifestPath || PATHS.henriManifest;
88
+ ensureOutputDir();
89
+ const output = runPython('analyze-all', ['--manifest', manifest, '--output-dir', PATHS.embeddingsDir], 1_200_000);
90
+ log(output.trim().split('\n').pop() || 'full pipeline done');
91
+ }
92
+ // ─── Status ─────────────────────────────────────────────────────────
93
+ export function computeStatus() {
94
+ const modules = [
95
+ { name: 'CLIP embeddings', file: 'clip-embeddings.npy' },
96
+ { name: 'CLIP index', file: 'clip-index.json' },
97
+ { name: 'Colors', file: 'colors.json' },
98
+ { name: 'Faces', file: 'faces.json' },
99
+ { name: 'Face embeddings', file: 'face-embeddings.npy' },
100
+ { name: 'Geometry', file: 'geometry.json' },
101
+ { name: 'OCR', file: 'ocr.json' },
102
+ { name: 'Temporal', file: 'temporal.json' },
103
+ { name: 'Scenes', file: 'scenes.json' },
104
+ { name: 'Clusters', file: 'clusters.json' },
105
+ ];
106
+ log(`compute output dir: ${PATHS.embeddingsDir}`);
107
+ for (const mod of modules) {
108
+ const filePath = path.join(PATHS.embeddingsDir, mod.file);
109
+ if (fs.existsSync(filePath)) {
110
+ const stat = fs.statSync(filePath);
111
+ const sizeKb = Math.round(stat.size / 1024);
112
+ const modified = stat.mtime.toISOString().substring(0, 19);
113
+ log(` ${mod.name}: ${sizeKb}KB (${modified})`);
114
+ }
115
+ else {
116
+ log(` ${mod.name}: not computed`);
117
+ }
118
+ }
119
+ }