escribano 0.2.2 → 0.4.1
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 +45 -0
- package/dist/0_types.js +0 -5
- package/dist/actions/generate-artifact-v3.js +3 -3
- package/dist/actions/generate-summary-v3.js +1 -1
- package/dist/actions/process-recording-v2.js +25 -17
- package/dist/actions/process-recording-v3.js +5 -4
- package/dist/adapters/audio.silero.adapter.js +3 -3
- package/dist/adapters/intelligence.mlx.adapter.js +79 -21
- package/dist/adapters/intelligence.ollama.adapter.js +2 -5
- package/dist/batch-context.js +3 -0
- package/dist/config.js +237 -0
- package/dist/domain/segment.js +1 -3
- package/dist/index.js +122 -8
- package/dist/prerequisites.js +8 -18
- package/dist/python-utils.js +64 -0
- package/dist/services/activity-segmentation.js +3 -3
- package/dist/services/signal-extraction.js +1 -1
- package/dist/services/subject-grouping.js +2 -2
- package/dist/services/temporal-alignment.js +1 -1
- package/dist/services/vlm-enrichment.js +5 -2
- package/dist/stats/observer.js +1 -1
- package/dist/tests/db/repositories.test.js +8 -8
- package/dist/tests/index.test.js +102 -0
- package/dist/tests/intelligence.mlx.adapter.test.js +222 -0
- package/dist/tests/intelligence.ollama.adapter.test.js +1 -0
- package/dist/tests/services/clustering.test.js +1 -0
- package/dist/tests/services/frame-sampling.test.js +1 -1
- package/dist/tests/visual-observer.test.js +0 -1
- package/package.json +2 -1
- package/scripts/create-release.mjs +55 -9
- package/scripts/mlx_bridge.py +26 -2
package/dist/config.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration Management
|
|
3
|
+
*
|
|
4
|
+
* Loads configuration from multiple sources with priority (highest to lowest):
|
|
5
|
+
* 1. CLI arguments
|
|
6
|
+
* 2. Shell environment variables (export ESCRIBANO_*)
|
|
7
|
+
* 3. ~/.escribano/.env file
|
|
8
|
+
* 4. Default values
|
|
9
|
+
*
|
|
10
|
+
* Note: Project-level .env is NOT loaded by default (only for development).
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { config as dotenvConfig } from 'dotenv';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// CONFIG SCHEMA
|
|
19
|
+
// =============================================================================
|
|
20
|
+
const configSchema = z.object({
|
|
21
|
+
// === PERFORMANCE ===
|
|
22
|
+
frameWidth: z.number().int().min(320).max(3840).default(1024),
|
|
23
|
+
vlmBatchSize: z.number().int().min(1).max(8).default(2),
|
|
24
|
+
sampleInterval: z.number().int().min(1).max(60).default(10),
|
|
25
|
+
// === QUALITY ===
|
|
26
|
+
sceneThreshold: z.number().min(0).max(1).default(0.4),
|
|
27
|
+
vlmMaxTokens: z.number().int().min(500).max(8000).default(2000),
|
|
28
|
+
// === MODELS ===
|
|
29
|
+
llmModel: z.string().optional(),
|
|
30
|
+
vlmModel: z.string().default('mlx-community/Qwen3-VL-2B-Instruct-4bit'),
|
|
31
|
+
subjectGroupingModel: z.string().optional(),
|
|
32
|
+
// === DEBUGGING ===
|
|
33
|
+
verbose: z.boolean().default(false),
|
|
34
|
+
debugOllama: z.boolean().default(false),
|
|
35
|
+
debugVlm: z.boolean().default(false),
|
|
36
|
+
skipLlm: z.boolean().default(false),
|
|
37
|
+
// === ADVANCED ===
|
|
38
|
+
sceneMinInterval: z.number().int().min(1).max(10).default(2),
|
|
39
|
+
sampleGapThreshold: z.number().int().min(5).max(60).default(15),
|
|
40
|
+
sampleGapFill: z.number().int().min(1).max(10).default(3),
|
|
41
|
+
mlxSocketPath: z.string().default('/tmp/escribano-mlx.sock'),
|
|
42
|
+
mlxStartupTimeout: z.number().int().min(10000).default(60000),
|
|
43
|
+
pythonPath: z.string().optional(),
|
|
44
|
+
parallelTranscription: z.boolean().default(false),
|
|
45
|
+
artifactThink: z.boolean().default(false),
|
|
46
|
+
// === OPTIONAL (Outline publishing) ===
|
|
47
|
+
outlineUrl: z.string().optional(),
|
|
48
|
+
outlineToken: z.string().optional(),
|
|
49
|
+
outlineCollection: z.string().default('Escribano Sessions'),
|
|
50
|
+
});
|
|
51
|
+
// =============================================================================
|
|
52
|
+
// DEFAULT CONFIG
|
|
53
|
+
// =============================================================================
|
|
54
|
+
const DEFAULT_CONFIG = {
|
|
55
|
+
frameWidth: 1024,
|
|
56
|
+
vlmBatchSize: 2,
|
|
57
|
+
sampleInterval: 10,
|
|
58
|
+
sceneThreshold: 0.4,
|
|
59
|
+
vlmMaxTokens: 2000,
|
|
60
|
+
vlmModel: 'mlx-community/Qwen3-VL-2B-Instruct-4bit',
|
|
61
|
+
verbose: false,
|
|
62
|
+
debugOllama: false,
|
|
63
|
+
debugVlm: false,
|
|
64
|
+
skipLlm: false,
|
|
65
|
+
sceneMinInterval: 2,
|
|
66
|
+
sampleGapThreshold: 15,
|
|
67
|
+
sampleGapFill: 3,
|
|
68
|
+
mlxSocketPath: '/tmp/escribano-mlx.sock',
|
|
69
|
+
mlxStartupTimeout: 60000,
|
|
70
|
+
parallelTranscription: false,
|
|
71
|
+
artifactThink: false,
|
|
72
|
+
outlineCollection: 'Escribano Sessions',
|
|
73
|
+
};
|
|
74
|
+
// =============================================================================
|
|
75
|
+
// CONFIG FILE TEMPLATE
|
|
76
|
+
// =============================================================================
|
|
77
|
+
const CONFIG_TEMPLATE = `# Escribano Configuration - ~/.escribano/.env
|
|
78
|
+
# Generated by escribano - edit as needed
|
|
79
|
+
# Full reference: https://github.com/eduardosanzb/escribano#configuration
|
|
80
|
+
|
|
81
|
+
# === PERFORMANCE ===
|
|
82
|
+
ESCRIBANO_FRAME_WIDTH=1024 # Lower = faster (1920, 1280, 1024, 640)
|
|
83
|
+
ESCRIBANO_VLM_BATCH_SIZE=2 # 1-4 frames (lower = more reliable)
|
|
84
|
+
ESCRIBANO_SAMPLE_INTERVAL=10 # Base frame sampling (seconds)
|
|
85
|
+
|
|
86
|
+
# === QUALITY ===
|
|
87
|
+
ESCRIBANO_SCENE_THRESHOLD=0.4 # Scene detection sensitivity (0.0-1.0)
|
|
88
|
+
ESCRIBANO_VLM_MAX_TOKENS=2000 # Token budget per batch
|
|
89
|
+
|
|
90
|
+
# === MODELS ===
|
|
91
|
+
# ESCRIBANO_LLM_MODEL=qwen3.5:27b # Summary generation (auto-detected if not set)
|
|
92
|
+
ESCRIBANO_VLM_MODEL=mlx-community/Qwen3-VL-2B-Instruct-4bit
|
|
93
|
+
|
|
94
|
+
# === DEBUGGING ===
|
|
95
|
+
ESCRIBANO_VERBOSE=false # Enable verbose logging
|
|
96
|
+
ESCRIBANO_DEBUG_VLM=false # Debug VLM processing
|
|
97
|
+
|
|
98
|
+
# === ADVANCED ===
|
|
99
|
+
ESCRIBANO_SCENE_MIN_INTERVAL=2
|
|
100
|
+
ESCRIBANO_SAMPLE_GAP_THRESHOLD=15
|
|
101
|
+
ESCRIBANO_SAMPLE_GAP_FILL=3
|
|
102
|
+
ESCRIBANO_MLX_SOCKET_PATH=/tmp/escribano-mlx.sock
|
|
103
|
+
ESCRIBANO_MLX_STARTUP_TIMEOUT=60000
|
|
104
|
+
# ESCRIBANO_PYTHON_PATH= # Auto-detected if not set
|
|
105
|
+
ESCRIBANO_ARTIFACT_THINK=false # Enable thinking for artifacts (slower)
|
|
106
|
+
|
|
107
|
+
# === OPTIONAL (Outline publishing) ===
|
|
108
|
+
# ESCRIBANO_OUTLINE_URL=
|
|
109
|
+
# ESCRIBANO_OUTLINE_TOKEN=
|
|
110
|
+
# ESCRIBANO_OUTLINE_COLLECTION=Escribano Sessions
|
|
111
|
+
`;
|
|
112
|
+
// =============================================================================
|
|
113
|
+
// CONFIG LOADER
|
|
114
|
+
// =============================================================================
|
|
115
|
+
let cachedConfig = null;
|
|
116
|
+
export function getConfigPath() {
|
|
117
|
+
return path.join(homedir(), '.escribano', '.env');
|
|
118
|
+
}
|
|
119
|
+
export function createDefaultConfig() {
|
|
120
|
+
const configDir = path.join(homedir(), '.escribano');
|
|
121
|
+
const configPath = getConfigPath();
|
|
122
|
+
try {
|
|
123
|
+
if (!existsSync(configDir)) {
|
|
124
|
+
mkdirSync(configDir, { recursive: true });
|
|
125
|
+
}
|
|
126
|
+
if (!existsSync(configPath)) {
|
|
127
|
+
writeFileSync(configPath, CONFIG_TEMPLATE, 'utf-8');
|
|
128
|
+
console.log(`Created config file at ${configPath}`);
|
|
129
|
+
console.log('You can customize settings by editing this file.\n');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
console.error(`Failed to create config file at ${configPath}: ${error.message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export function loadConfig() {
|
|
137
|
+
if (cachedConfig) {
|
|
138
|
+
return cachedConfig;
|
|
139
|
+
}
|
|
140
|
+
// 1. Load from config file (if exists)
|
|
141
|
+
const configPath = getConfigPath();
|
|
142
|
+
if (existsSync(configPath)) {
|
|
143
|
+
try {
|
|
144
|
+
const result = dotenvConfig({ path: configPath });
|
|
145
|
+
if (result.error) {
|
|
146
|
+
console.error(`Failed to parse config file ${configPath}: ${result.error.message}`);
|
|
147
|
+
console.error('Using default configuration.');
|
|
148
|
+
}
|
|
149
|
+
else if (result.parsed && Object.keys(result.parsed).length > 0) {
|
|
150
|
+
console.log(`Loaded config from ${configPath}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
console.error(`Error reading config file ${configPath}: ${error.message}`);
|
|
155
|
+
console.error('Using default configuration.');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// 2. Build config from environment variables
|
|
159
|
+
const config = {
|
|
160
|
+
// === PERFORMANCE ===
|
|
161
|
+
frameWidth: parseEnvNumber('ESCRIBANO_FRAME_WIDTH', DEFAULT_CONFIG.frameWidth),
|
|
162
|
+
vlmBatchSize: parseEnvNumber('ESCRIBANO_VLM_BATCH_SIZE', DEFAULT_CONFIG.vlmBatchSize),
|
|
163
|
+
sampleInterval: parseEnvNumber('ESCRIBANO_SAMPLE_INTERVAL', DEFAULT_CONFIG.sampleInterval),
|
|
164
|
+
// === QUALITY ===
|
|
165
|
+
sceneThreshold: parseEnvNumber('ESCRIBANO_SCENE_THRESHOLD', DEFAULT_CONFIG.sceneThreshold),
|
|
166
|
+
vlmMaxTokens: parseEnvNumber('ESCRIBANO_VLM_MAX_TOKENS', DEFAULT_CONFIG.vlmMaxTokens),
|
|
167
|
+
// === MODELS ===
|
|
168
|
+
llmModel: process.env.ESCRIBANO_LLM_MODEL,
|
|
169
|
+
vlmModel: process.env.ESCRIBANO_VLM_MODEL || DEFAULT_CONFIG.vlmModel,
|
|
170
|
+
subjectGroupingModel: process.env.ESCRIBANO_SUBJECT_GROUPING_MODEL,
|
|
171
|
+
// === DEBUGGING ===
|
|
172
|
+
verbose: parseEnvBoolean('ESCRIBANO_VERBOSE', DEFAULT_CONFIG.verbose),
|
|
173
|
+
debugOllama: parseEnvBoolean('ESCRIBANO_DEBUG_OLLAMA', DEFAULT_CONFIG.debugOllama),
|
|
174
|
+
debugVlm: parseEnvBoolean('ESCRIBANO_DEBUG_VLM', DEFAULT_CONFIG.debugVlm),
|
|
175
|
+
skipLlm: parseEnvBoolean('ESCRIBANO_SKIP_LLM', DEFAULT_CONFIG.skipLlm),
|
|
176
|
+
// === ADVANCED ===
|
|
177
|
+
sceneMinInterval: parseEnvNumber('ESCRIBANO_SCENE_MIN_INTERVAL', DEFAULT_CONFIG.sceneMinInterval),
|
|
178
|
+
sampleGapThreshold: parseEnvNumber('ESCRIBANO_SAMPLE_GAP_THRESHOLD', DEFAULT_CONFIG.sampleGapThreshold),
|
|
179
|
+
sampleGapFill: parseEnvNumber('ESCRIBANO_SAMPLE_GAP_FILL', DEFAULT_CONFIG.sampleGapFill),
|
|
180
|
+
mlxSocketPath: process.env.ESCRIBANO_MLX_SOCKET_PATH || DEFAULT_CONFIG.mlxSocketPath,
|
|
181
|
+
mlxStartupTimeout: parseEnvNumber('ESCRIBANO_MLX_STARTUP_TIMEOUT', DEFAULT_CONFIG.mlxStartupTimeout),
|
|
182
|
+
pythonPath: process.env.ESCRIBANO_PYTHON_PATH,
|
|
183
|
+
parallelTranscription: parseEnvBoolean('ESCRIBANO_PARALLEL_TRANSCRIPTION', DEFAULT_CONFIG.parallelTranscription),
|
|
184
|
+
artifactThink: parseEnvBoolean('ESCRIBANO_ARTIFACT_THINK', DEFAULT_CONFIG.artifactThink),
|
|
185
|
+
// === OPTIONAL ===
|
|
186
|
+
outlineUrl: process.env.ESCRIBANO_OUTLINE_URL,
|
|
187
|
+
outlineToken: process.env.ESCRIBANO_OUTLINE_TOKEN,
|
|
188
|
+
outlineCollection: process.env.ESCRIBANO_OUTLINE_COLLECTION ||
|
|
189
|
+
DEFAULT_CONFIG.outlineCollection,
|
|
190
|
+
};
|
|
191
|
+
// 3. Validate with Zod
|
|
192
|
+
const validated = configSchema.parse(config);
|
|
193
|
+
cachedConfig = validated;
|
|
194
|
+
return validated;
|
|
195
|
+
}
|
|
196
|
+
// =============================================================================
|
|
197
|
+
// HELPERS
|
|
198
|
+
// =============================================================================
|
|
199
|
+
function parseEnvNumber(key, defaultValue) {
|
|
200
|
+
const value = process.env[key];
|
|
201
|
+
if (!value)
|
|
202
|
+
return defaultValue;
|
|
203
|
+
const parsed = Number(value);
|
|
204
|
+
if (Number.isNaN(parsed)) {
|
|
205
|
+
console.warn(`Invalid ${key}="${value}", using default: ${defaultValue}`);
|
|
206
|
+
return defaultValue;
|
|
207
|
+
}
|
|
208
|
+
return parsed;
|
|
209
|
+
}
|
|
210
|
+
function parseEnvBoolean(key, defaultValue) {
|
|
211
|
+
const value = process.env[key];
|
|
212
|
+
if (!value)
|
|
213
|
+
return defaultValue;
|
|
214
|
+
return value === 'true';
|
|
215
|
+
}
|
|
216
|
+
// =============================================================================
|
|
217
|
+
// CLI UTILITIES
|
|
218
|
+
// =============================================================================
|
|
219
|
+
export function showConfig() {
|
|
220
|
+
const configPath = getConfigPath();
|
|
221
|
+
// Create config file if it doesn't exist
|
|
222
|
+
if (!existsSync(configPath)) {
|
|
223
|
+
createDefaultConfig();
|
|
224
|
+
}
|
|
225
|
+
const config = loadConfig();
|
|
226
|
+
console.log(`Config file: ${configPath}\n`);
|
|
227
|
+
console.log('Current configuration:');
|
|
228
|
+
console.log(JSON.stringify(config, null, 2));
|
|
229
|
+
}
|
|
230
|
+
export function showConfigPath() {
|
|
231
|
+
const configPath = getConfigPath();
|
|
232
|
+
// Create config file if it doesn't exist
|
|
233
|
+
if (!existsSync(configPath)) {
|
|
234
|
+
createDefaultConfig();
|
|
235
|
+
}
|
|
236
|
+
console.log(configPath);
|
|
237
|
+
}
|
package/dist/domain/segment.js
CHANGED
|
@@ -5,9 +5,7 @@ import { TimeRange } from './time-range.js';
|
|
|
5
5
|
import { Transcript } from './transcript.js';
|
|
6
6
|
// Minimal Context logic previously in context.ts
|
|
7
7
|
const Context = {
|
|
8
|
-
extractFromOCR: (
|
|
9
|
-
// This is a stub to keep V1 compiling.
|
|
10
|
-
// V2 uses signal-extraction.ts instead.
|
|
8
|
+
extractFromOCR: (_text) => {
|
|
11
9
|
return [];
|
|
12
10
|
},
|
|
13
11
|
unique: (contexts) => contexts,
|
package/dist/index.js
CHANGED
|
@@ -5,18 +5,87 @@
|
|
|
5
5
|
* Single command: process latest recording and generate summary
|
|
6
6
|
* Refactored to use batch-context for shared initialization logic
|
|
7
7
|
*/
|
|
8
|
+
import { access, constants, readdir, stat } from 'node:fs/promises';
|
|
8
9
|
import { homedir } from 'node:os';
|
|
9
10
|
import path from 'node:path';
|
|
10
11
|
import pkg from '../package.json' with { type: 'json' };
|
|
11
12
|
import { createCapCaptureSource } from './adapters/capture.cap.adapter.js';
|
|
12
13
|
import { createFilesystemCaptureSource } from './adapters/capture.filesystem.adapter.js';
|
|
13
14
|
import { cleanupMlxBridge, initializeSystem, processVideo, } from './batch-context.js';
|
|
15
|
+
import { loadConfig, showConfig, showConfigPath } from './config.js';
|
|
14
16
|
import { getDbPath } from './db/index.js';
|
|
15
17
|
import { checkPrerequisites, hasMissingPrerequisites, printDoctorResults, } from './prerequisites.js';
|
|
16
18
|
import { logEnvironmentVariables } from './utils/env-logger.js';
|
|
17
19
|
const MODELS_DIR = path.join(homedir(), '.escribano', 'models');
|
|
18
20
|
const MODEL_FILE = 'ggml-large-v3.bin';
|
|
19
|
-
const
|
|
21
|
+
const _MODEL_PATH = path.join(MODELS_DIR, MODEL_FILE);
|
|
22
|
+
const VIDEO_EXTENSIONS = ['.mov', '.mp4', '.mkv', '.avi', '.webm'];
|
|
23
|
+
function expandPath(inputPath) {
|
|
24
|
+
if (!inputPath.startsWith('~')) {
|
|
25
|
+
return inputPath;
|
|
26
|
+
}
|
|
27
|
+
const homeDir = homedir();
|
|
28
|
+
if (!homeDir) {
|
|
29
|
+
return inputPath;
|
|
30
|
+
}
|
|
31
|
+
if (inputPath === '~' || inputPath === '~/') {
|
|
32
|
+
return homeDir;
|
|
33
|
+
}
|
|
34
|
+
if (inputPath.startsWith('~/')) {
|
|
35
|
+
return path.join(homeDir, inputPath.slice(2));
|
|
36
|
+
}
|
|
37
|
+
return inputPath;
|
|
38
|
+
}
|
|
39
|
+
async function findLatestVideo(dirPath) {
|
|
40
|
+
const resolvedPath = expandPath(dirPath);
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = await readdir(resolvedPath, { withFileTypes: true });
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const err = error;
|
|
47
|
+
if (err && typeof err === 'object') {
|
|
48
|
+
switch (err.code) {
|
|
49
|
+
case 'ENOENT':
|
|
50
|
+
throw new Error(`Directory not found: ${resolvedPath}`);
|
|
51
|
+
case 'ENOTDIR':
|
|
52
|
+
throw new Error(`Not a directory: ${resolvedPath}`);
|
|
53
|
+
case 'EACCES':
|
|
54
|
+
case 'EPERM':
|
|
55
|
+
throw new Error(`Permission denied reading directory: ${resolvedPath}`);
|
|
56
|
+
default:
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
const videoFiles = entries.filter((entry) => entry.isFile() &&
|
|
63
|
+
VIDEO_EXTENSIONS.some((ext) => entry.name.toLowerCase().endsWith(ext)));
|
|
64
|
+
if (videoFiles.length === 0) {
|
|
65
|
+
throw new Error(`No video files found in: ${resolvedPath}`);
|
|
66
|
+
}
|
|
67
|
+
let latestFilePath = null;
|
|
68
|
+
let latestMtime = -Infinity;
|
|
69
|
+
for (const entry of videoFiles) {
|
|
70
|
+
const fullPath = path.join(resolvedPath, entry.name);
|
|
71
|
+
try {
|
|
72
|
+
await access(fullPath, constants.R_OK);
|
|
73
|
+
const fileStat = await stat(fullPath);
|
|
74
|
+
const mtimeMs = fileStat.mtime.getTime();
|
|
75
|
+
if (mtimeMs > latestMtime) {
|
|
76
|
+
latestMtime = mtimeMs;
|
|
77
|
+
latestFilePath = fullPath;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Skip files that are inaccessible (permission denied, broken symlink, etc.)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!latestFilePath) {
|
|
85
|
+
throw new Error(`No accessible video files found in: ${resolvedPath}`);
|
|
86
|
+
}
|
|
87
|
+
return latestFilePath;
|
|
88
|
+
}
|
|
20
89
|
function main() {
|
|
21
90
|
const args = parseArgs(process.argv.slice(2));
|
|
22
91
|
if (args.version) {
|
|
@@ -34,6 +103,17 @@ function main() {
|
|
|
34
103
|
});
|
|
35
104
|
return;
|
|
36
105
|
}
|
|
106
|
+
if (args.config) {
|
|
107
|
+
if (args.configPath) {
|
|
108
|
+
showConfigPath();
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
showConfig();
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Load user config file before any adapter reads process.env
|
|
116
|
+
loadConfig();
|
|
37
117
|
run(args).catch((error) => {
|
|
38
118
|
console.error('Error:', error.message);
|
|
39
119
|
console.error(error.stack);
|
|
@@ -50,6 +130,20 @@ async function runDoctor() {
|
|
|
50
130
|
function parseArgs(argsArray) {
|
|
51
131
|
const fileIndex = argsArray.indexOf('--file');
|
|
52
132
|
const filePath = fileIndex !== -1 ? argsArray[fileIndex + 1] || null : null;
|
|
133
|
+
const latestIndex = argsArray.indexOf('--latest');
|
|
134
|
+
const latestPath = latestIndex !== -1 ? argsArray[latestIndex + 1] || null : null;
|
|
135
|
+
if (filePath && latestPath) {
|
|
136
|
+
console.error('Error: Cannot use both --latest and --file');
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
if (latestIndex !== -1 && !latestPath) {
|
|
140
|
+
console.error('Error: --latest requires a directory argument');
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
if (latestIndex !== -1 && latestPath?.startsWith('-')) {
|
|
144
|
+
console.error('Error: --latest requires a directory argument');
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
53
147
|
const micIndex = argsArray.indexOf('--mic-audio');
|
|
54
148
|
const micAudio = micIndex !== -1 ? argsArray[micIndex + 1] || null : null;
|
|
55
149
|
const sysIndex = argsArray.indexOf('--system-audio');
|
|
@@ -61,7 +155,11 @@ function parseArgs(argsArray) {
|
|
|
61
155
|
help: argsArray.includes('--help') || argsArray.includes('-h'),
|
|
62
156
|
version: argsArray.includes('--version') || argsArray.includes('-v'),
|
|
63
157
|
doctor: argsArray[0] === 'doctor',
|
|
158
|
+
config: argsArray[0] === 'config',
|
|
159
|
+
configShow: argsArray.includes('--show'),
|
|
160
|
+
configPath: argsArray.includes('--path'),
|
|
64
161
|
file: filePath,
|
|
162
|
+
latest: latestPath,
|
|
65
163
|
skipSummary: argsArray.includes('--skip-summary'),
|
|
66
164
|
micAudio,
|
|
67
165
|
systemAudio,
|
|
@@ -80,7 +178,10 @@ Escribano - Session Intelligence Tool
|
|
|
80
178
|
Usage:
|
|
81
179
|
npx escribano Process latest Cap recording
|
|
82
180
|
npx escribano doctor Check prerequisites
|
|
181
|
+
npx escribano config Show current configuration
|
|
182
|
+
npx escribano config --path Show config file path
|
|
83
183
|
npx escribano --file <path> Process video from filesystem
|
|
184
|
+
npx escribano --latest <dir> Process latest video in directory
|
|
84
185
|
npx escribano --file <path> --mic-audio <wav> Use external mic audio
|
|
85
186
|
npx escribano --file <path> --system-audio <wav> Provide system audio
|
|
86
187
|
npx escribano --force Reprocess from scratch
|
|
@@ -94,22 +195,35 @@ Usage:
|
|
|
94
195
|
|
|
95
196
|
Examples:
|
|
96
197
|
npx escribano --file "~/Desktop/Screen Recording.mov"
|
|
198
|
+
npx escribano --latest "~/Desktop"
|
|
97
199
|
npx escribano --file "/path/to/video.mp4" --mic-audio "/path/to/mic.wav"
|
|
98
200
|
npx escribano --file "/path/to/video.mp4" --system-audio "/path/to/system.wav"
|
|
99
201
|
npx escribano --format standup --stdout
|
|
100
202
|
npx escribano --format narrative --include-personal
|
|
101
203
|
|
|
204
|
+
Configuration:
|
|
205
|
+
Config file: ~/.escribano/.env
|
|
206
|
+
View config: escribano config
|
|
207
|
+
Edit manually: vim ~/.escribano/.env
|
|
208
|
+
|
|
102
209
|
Output: Markdown summary saved to ~/.escribano/artifacts/
|
|
103
210
|
`);
|
|
104
211
|
}
|
|
105
212
|
async function run(args) {
|
|
106
|
-
const { force, file: filePath, skipSummary, micAudio, systemAudio, format, includePersonal, copyToClipboard, printToStdout, } = args;
|
|
213
|
+
const { force, file: filePath, latest, skipSummary, micAudio, systemAudio, format, includePersonal, copyToClipboard, printToStdout, } = args;
|
|
214
|
+
// Resolve --latest to a file path
|
|
215
|
+
let resolvedFilePath = filePath;
|
|
216
|
+
if (latest) {
|
|
217
|
+
resolvedFilePath = await findLatestVideo(latest);
|
|
218
|
+
console.log(`Found latest video: ${resolvedFilePath}`);
|
|
219
|
+
}
|
|
107
220
|
// Log environment variables if verbose mode is enabled
|
|
108
221
|
logEnvironmentVariables();
|
|
109
222
|
// Initialize system (reuses batch-context for consistency)
|
|
110
223
|
console.log('Initializing database...');
|
|
111
224
|
const ctx = await initializeSystem();
|
|
112
|
-
|
|
225
|
+
// Note: repos unused in CLI mode (only needed for batch processing)
|
|
226
|
+
void ctx;
|
|
113
227
|
console.log(`Database ready: ${getDbPath()}`);
|
|
114
228
|
console.log('');
|
|
115
229
|
// SIGINT handler for graceful cancellation
|
|
@@ -121,14 +235,14 @@ async function run(args) {
|
|
|
121
235
|
process.on('SIGINT', sigintHandler);
|
|
122
236
|
// Create appropriate capture source
|
|
123
237
|
let captureSource;
|
|
124
|
-
if (
|
|
125
|
-
console.log(`Using filesystem source: ${
|
|
238
|
+
if (resolvedFilePath) {
|
|
239
|
+
console.log(`Using filesystem source: ${resolvedFilePath}`);
|
|
126
240
|
if (micAudio)
|
|
127
241
|
console.log(` Mic audio: ${micAudio}`);
|
|
128
242
|
if (systemAudio)
|
|
129
243
|
console.log(` System audio: ${systemAudio}`);
|
|
130
244
|
captureSource = createFilesystemCaptureSource({
|
|
131
|
-
videoPath:
|
|
245
|
+
videoPath: resolvedFilePath,
|
|
132
246
|
micAudioPath: micAudio ?? undefined,
|
|
133
247
|
systemAudioPath: systemAudio ?? undefined,
|
|
134
248
|
}, ctx.adapters.video);
|
|
@@ -140,8 +254,8 @@ async function run(args) {
|
|
|
140
254
|
// Get recording
|
|
141
255
|
const recording = await captureSource.getLatestRecording();
|
|
142
256
|
if (!recording) {
|
|
143
|
-
if (
|
|
144
|
-
console.log(`Failed to load video file: ${
|
|
257
|
+
if (resolvedFilePath) {
|
|
258
|
+
console.log(`Failed to load video file: ${resolvedFilePath}`);
|
|
145
259
|
}
|
|
146
260
|
else {
|
|
147
261
|
console.log('No Cap recordings found.');
|
package/dist/prerequisites.js
CHANGED
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { execSync } from 'node:child_process';
|
|
7
7
|
import { existsSync } from 'node:fs';
|
|
8
|
-
import {
|
|
9
|
-
import { resolve } from 'node:path';
|
|
8
|
+
import { ESCRIBANO_VENV_PYTHON, getEffectivePythonPathSync, } from './python-utils.js';
|
|
10
9
|
const LLM_MODEL_TIERS = [
|
|
11
10
|
{ model: 'qwen3.5:27b', tier: 4, minRamGB: 32, label: 'best' },
|
|
12
11
|
{ model: 'qwen3:14b', tier: 3, minRamGB: 20, label: 'very good' },
|
|
@@ -59,8 +58,8 @@ const PREREQUISITES = [
|
|
|
59
58
|
{
|
|
60
59
|
name: 'mlx-vlm',
|
|
61
60
|
found: false,
|
|
62
|
-
installCommand: 'pip install mlx-vlm',
|
|
63
|
-
notes: 'VLM library for frame analysis (Apple Silicon)',
|
|
61
|
+
installCommand: 'pip install mlx-vlm torch torchvision',
|
|
62
|
+
notes: 'VLM library for frame analysis (Apple Silicon) — auto-installed by escribano on first run',
|
|
64
63
|
},
|
|
65
64
|
];
|
|
66
65
|
function checkCommand(command, args = ['--version']) {
|
|
@@ -128,21 +127,8 @@ function checkLLMModels() {
|
|
|
128
127
|
installCommand: 'ollama pull qwen3:8b',
|
|
129
128
|
};
|
|
130
129
|
}
|
|
131
|
-
function getPythonPath() {
|
|
132
|
-
if (process.env.ESCRIBANO_PYTHON_PATH) {
|
|
133
|
-
return process.env.ESCRIBANO_PYTHON_PATH;
|
|
134
|
-
}
|
|
135
|
-
if (process.env.VIRTUAL_ENV) {
|
|
136
|
-
return resolve(process.env.VIRTUAL_ENV, 'bin', 'python3');
|
|
137
|
-
}
|
|
138
|
-
const uvHomeVenv = resolve(homedir(), '.venv', 'bin', 'python3');
|
|
139
|
-
if (existsSync(uvHomeVenv)) {
|
|
140
|
-
return uvHomeVenv;
|
|
141
|
-
}
|
|
142
|
-
return 'python3';
|
|
143
|
-
}
|
|
144
130
|
function checkPythonPackage(packageName) {
|
|
145
|
-
const pythonPath =
|
|
131
|
+
const pythonPath = getEffectivePythonPathSync();
|
|
146
132
|
try {
|
|
147
133
|
execSync(`"${pythonPath}" -c "import ${packageName}"`, {
|
|
148
134
|
encoding: 'utf-8',
|
|
@@ -243,6 +229,10 @@ export function checkPrerequisites() {
|
|
|
243
229
|
if (check.found && check.pythonPath) {
|
|
244
230
|
result.notes = `via ${check.pythonPath}`;
|
|
245
231
|
}
|
|
232
|
+
else if (existsSync(ESCRIBANO_VENV_PYTHON)) {
|
|
233
|
+
// Managed venv exists but mlx-vlm is missing — auto-install will fix it on next run
|
|
234
|
+
result.installCommand = `${ESCRIBANO_VENV_PYTHON} -m pip install mlx-vlm torch torchvision`;
|
|
235
|
+
}
|
|
246
236
|
else {
|
|
247
237
|
result.installCommand = detectPipCommand();
|
|
248
238
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Python path resolution utilities for Escribano.
|
|
3
|
+
*
|
|
4
|
+
* Used by both the MLX adapter (runtime) and the prerequisites checker (doctor).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
/** Escribano's managed Python environment — created automatically on first use. */
|
|
10
|
+
export const ESCRIBANO_HOME = resolve(homedir(), '.escribano');
|
|
11
|
+
export const ESCRIBANO_VENV = resolve(ESCRIBANO_HOME, 'venv');
|
|
12
|
+
export const ESCRIBANO_VENV_PYTHON = resolve(ESCRIBANO_VENV, 'bin', 'python3');
|
|
13
|
+
/**
|
|
14
|
+
* Get explicitly configured Python path.
|
|
15
|
+
* Returns null when nothing is explicitly configured or found via well-known
|
|
16
|
+
* conventions (active venv, uv project environment, local/home .venv directory).
|
|
17
|
+
* Callers receiving null should fall through to ensureEscribanoVenv() for
|
|
18
|
+
* zero-config auto-setup.
|
|
19
|
+
*
|
|
20
|
+
* Priority:
|
|
21
|
+
* 1. ESCRIBANO_PYTHON_PATH env var (explicit override)
|
|
22
|
+
* 2. Active virtual environment (VIRTUAL_ENV)
|
|
23
|
+
* 3. UV_PROJECT_ENVIRONMENT (uv project-synced venv)
|
|
24
|
+
* 4. Project-local .venv (created by `uv venv` in CWD)
|
|
25
|
+
* 5. ~/.venv/bin/python3 (home-level venv)
|
|
26
|
+
* 6. null — no environment detected; auto-venv will be created
|
|
27
|
+
*/
|
|
28
|
+
export function getPythonPath() {
|
|
29
|
+
if (process.env.ESCRIBANO_PYTHON_PATH) {
|
|
30
|
+
return process.env.ESCRIBANO_PYTHON_PATH;
|
|
31
|
+
}
|
|
32
|
+
if (process.env.VIRTUAL_ENV) {
|
|
33
|
+
return resolve(process.env.VIRTUAL_ENV, 'bin', 'python3');
|
|
34
|
+
}
|
|
35
|
+
// UV_PROJECT_ENVIRONMENT: set by uv when running inside a project with `uv sync`
|
|
36
|
+
if (process.env.UV_PROJECT_ENVIRONMENT) {
|
|
37
|
+
return resolve(process.env.UV_PROJECT_ENVIRONMENT, 'bin', 'python3');
|
|
38
|
+
}
|
|
39
|
+
// Check project-local .venv (created by `uv venv` in the current working directory)
|
|
40
|
+
const localVenv = resolve(process.cwd(), '.venv', 'bin', 'python3');
|
|
41
|
+
if (existsSync(localVenv)) {
|
|
42
|
+
return localVenv;
|
|
43
|
+
}
|
|
44
|
+
// Check common home-level venv (e.g., `uv venv ~/.venv`)
|
|
45
|
+
const uvHomeVenv = resolve(homedir(), '.venv', 'bin', 'python3');
|
|
46
|
+
if (existsSync(uvHomeVenv)) {
|
|
47
|
+
return uvHomeVenv;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Get the best Python path for synchronous prerequisite checks (e.g., `escribano doctor`).
|
|
53
|
+
* Checks the managed venv if it already exists. Does NOT create or install anything.
|
|
54
|
+
*
|
|
55
|
+
* Priority: explicit config → managed ~/.escribano/venv (if it exists) → system python3
|
|
56
|
+
*/
|
|
57
|
+
export function getEffectivePythonPathSync() {
|
|
58
|
+
const explicit = getPythonPath();
|
|
59
|
+
if (explicit)
|
|
60
|
+
return explicit;
|
|
61
|
+
if (existsSync(ESCRIBANO_VENV_PYTHON))
|
|
62
|
+
return ESCRIBANO_VENV_PYTHON;
|
|
63
|
+
return 'python3';
|
|
64
|
+
}
|
|
@@ -102,7 +102,7 @@ function extractActivityType(vlmDescription) {
|
|
|
102
102
|
* Parse VLM description to extract apps and topics.
|
|
103
103
|
* Expects format like: "Debugging Python error in VSCode, working on escribano project"
|
|
104
104
|
*/
|
|
105
|
-
function
|
|
105
|
+
function _extractContext(vlmDescription) {
|
|
106
106
|
if (!vlmDescription)
|
|
107
107
|
return { apps: [], topics: [] };
|
|
108
108
|
const apps = [];
|
|
@@ -120,7 +120,7 @@ function extractContext(vlmDescription) {
|
|
|
120
120
|
];
|
|
121
121
|
for (const pattern of appPatterns) {
|
|
122
122
|
const match = text.match(pattern);
|
|
123
|
-
if (match
|
|
123
|
+
if (match?.[1]) {
|
|
124
124
|
apps.push(match[1].toLowerCase().replace(' ', '_'));
|
|
125
125
|
}
|
|
126
126
|
}
|
|
@@ -132,7 +132,7 @@ function extractContext(vlmDescription) {
|
|
|
132
132
|
];
|
|
133
133
|
for (const pattern of topicPatterns) {
|
|
134
134
|
const match = text.match(pattern);
|
|
135
|
-
if (match
|
|
135
|
+
if (match?.[1]) {
|
|
136
136
|
topics.push(match[1].toLowerCase());
|
|
137
137
|
}
|
|
138
138
|
}
|
|
@@ -68,7 +68,7 @@ export function extractProjects(texts) {
|
|
|
68
68
|
for (const text of texts) {
|
|
69
69
|
for (const pattern of PROJECT_PATTERNS) {
|
|
70
70
|
const match = text.match(pattern);
|
|
71
|
-
if (match
|
|
71
|
+
if (match?.[1]) {
|
|
72
72
|
const name = match[1].toLowerCase();
|
|
73
73
|
// Filter out common non-project names
|
|
74
74
|
if (!['src', 'lib', 'dist', 'build', 'node_modules', 'packages'].includes(name)) {
|
|
@@ -217,7 +217,7 @@ function parseGroupingResponse(response, topicBlocks) {
|
|
|
217
217
|
}
|
|
218
218
|
return { groups };
|
|
219
219
|
}
|
|
220
|
-
function detectPersonalSubject(apps,
|
|
220
|
+
function detectPersonalSubject(apps, _activityBreakdown) {
|
|
221
221
|
let personalAppCount = 0;
|
|
222
222
|
let totalAppCount = 0;
|
|
223
223
|
for (const app of apps) {
|
|
@@ -322,7 +322,7 @@ function formatDuration(seconds) {
|
|
|
322
322
|
}
|
|
323
323
|
return `${Math.floor(seconds)}s`;
|
|
324
324
|
}
|
|
325
|
-
export function saveSubjectsToDatabase(subjects,
|
|
325
|
+
export function saveSubjectsToDatabase(subjects, _recordingId, repos) {
|
|
326
326
|
const subjectInserts = [];
|
|
327
327
|
const links = [];
|
|
328
328
|
for (const subject of subjects) {
|
|
@@ -49,7 +49,7 @@ export function alignAudioToSegments(segments, audioObservations, config = {}) {
|
|
|
49
49
|
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
50
50
|
// Filter to audio observations with transcripts
|
|
51
51
|
const audioTranscripts = audioObservations
|
|
52
|
-
.filter((o) => o.type === 'audio' && o.text && o.text.trim().length > 0)
|
|
52
|
+
.filter((o) => o.type === 'audio' && o.text !== null && o.text.trim().length > 0)
|
|
53
53
|
.map((o) => ({
|
|
54
54
|
source: o.audio_source,
|
|
55
55
|
text: o.text,
|
|
@@ -68,13 +68,16 @@ export async function describeFrames(frames, intelligence) {
|
|
|
68
68
|
const results = new Map();
|
|
69
69
|
if (frames.length === 0)
|
|
70
70
|
return results;
|
|
71
|
-
const
|
|
71
|
+
const validFrames = frames.filter((f) => f.observation.image_path !== null);
|
|
72
|
+
if (validFrames.length === 0)
|
|
73
|
+
return results;
|
|
74
|
+
const images = validFrames.map((f) => ({
|
|
72
75
|
imagePath: f.observation.image_path,
|
|
73
76
|
clusterId: 0, // Not used in our case
|
|
74
77
|
timestamp: f.observation.timestamp,
|
|
75
78
|
}));
|
|
76
79
|
const descriptions = await intelligence.describeImages(images);
|
|
77
|
-
for (const [index, frame] of
|
|
80
|
+
for (const [index, frame] of validFrames.entries()) {
|
|
78
81
|
const desc = descriptions[index];
|
|
79
82
|
if (desc?.description) {
|
|
80
83
|
results.set(frame.observation.id, desc.description);
|
package/dist/stats/observer.js
CHANGED
|
@@ -17,7 +17,7 @@ export function setupStatsObserver(repo) {
|
|
|
17
17
|
pipelineEvents.on('run:end', (data) => {
|
|
18
18
|
if (!currentRunId)
|
|
19
19
|
return;
|
|
20
|
-
const
|
|
20
|
+
const _startedAt = data.timestamp;
|
|
21
21
|
repo.updateRun(currentRunId, {
|
|
22
22
|
status: data.status,
|
|
23
23
|
completed_at: new Date(data.timestamp).toISOString(),
|