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
|
@@ -38,9 +38,9 @@ function runRecordingRepositoryTests(name, createRepo) {
|
|
|
38
38
|
});
|
|
39
39
|
const found = repo.findById(id);
|
|
40
40
|
expect(found).not.toBeNull();
|
|
41
|
-
expect(found
|
|
42
|
-
expect(found
|
|
43
|
-
expect(found
|
|
41
|
+
expect(found?.id).toBe(id);
|
|
42
|
+
expect(found?.duration).toBe(120.5);
|
|
43
|
+
expect(found?.status).toBe('raw');
|
|
44
44
|
});
|
|
45
45
|
it('returns null for non-existent recording', () => {
|
|
46
46
|
const found = repo.findById('nonexistent');
|
|
@@ -63,8 +63,8 @@ function runRecordingRepositoryTests(name, createRepo) {
|
|
|
63
63
|
});
|
|
64
64
|
repo.updateStatus(id, 'processing', 'clustering');
|
|
65
65
|
const found = repo.findById(id);
|
|
66
|
-
expect(found
|
|
67
|
-
expect(found
|
|
66
|
+
expect(found?.status).toBe('processing');
|
|
67
|
+
expect(found?.processing_step).toBe('clustering');
|
|
68
68
|
});
|
|
69
69
|
it('finds pending recordings', () => {
|
|
70
70
|
const id1 = generateId();
|
|
@@ -146,8 +146,8 @@ function runContextRepositoryTests(name, createRepos) {
|
|
|
146
146
|
});
|
|
147
147
|
const found = contextRepo.findById(id);
|
|
148
148
|
expect(found).not.toBeNull();
|
|
149
|
-
expect(found
|
|
150
|
-
expect(found
|
|
149
|
+
expect(found?.id).toBe(id);
|
|
150
|
+
expect(found?.name).toBe('escribano');
|
|
151
151
|
});
|
|
152
152
|
it('finds by type and name', () => {
|
|
153
153
|
const id = generateId();
|
|
@@ -159,7 +159,7 @@ function runContextRepositoryTests(name, createRepos) {
|
|
|
159
159
|
});
|
|
160
160
|
const found = contextRepo.findByTypeAndName('app', 'vscode');
|
|
161
161
|
expect(found).not.toBeNull();
|
|
162
|
-
expect(found
|
|
162
|
+
expect(found?.id).toBe(id);
|
|
163
163
|
});
|
|
164
164
|
it('links and unlinks observations', () => {
|
|
165
165
|
const recordingId = generateId();
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { chmod, mkdir, rm, stat, symlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
const VIDEO_EXTENSIONS = ['.mov', '.mp4', '.mkv', '.avi', '.webm'];
|
|
6
|
+
function expandPath(inputPath) {
|
|
7
|
+
if (inputPath.startsWith('~/')) {
|
|
8
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
9
|
+
return path.join(homeDir, inputPath.slice(2));
|
|
10
|
+
}
|
|
11
|
+
return inputPath;
|
|
12
|
+
}
|
|
13
|
+
async function findLatestVideo(dirPath) {
|
|
14
|
+
const resolvedPath = expandPath(dirPath);
|
|
15
|
+
const { readdir } = await import('node:fs/promises');
|
|
16
|
+
const entries = await readdir(resolvedPath, { withFileTypes: true });
|
|
17
|
+
const videoFiles = entries.filter((entry) => entry.isFile() &&
|
|
18
|
+
VIDEO_EXTENSIONS.some((ext) => entry.name.toLowerCase().endsWith(ext)));
|
|
19
|
+
if (videoFiles.length === 0) {
|
|
20
|
+
throw new Error(`No video files found in: ${resolvedPath}`);
|
|
21
|
+
}
|
|
22
|
+
const filesWithMtime = await Promise.all(videoFiles.map(async (entry) => {
|
|
23
|
+
const fullPath = path.join(resolvedPath, entry.name);
|
|
24
|
+
try {
|
|
25
|
+
const fileStat = await stat(fullPath);
|
|
26
|
+
return { path: fullPath, mtime: fileStat.mtime };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}));
|
|
32
|
+
const validFiles = filesWithMtime.filter((f) => f !== null);
|
|
33
|
+
if (validFiles.length === 0) {
|
|
34
|
+
throw new Error(`No accessible video files found in: ${resolvedPath}`);
|
|
35
|
+
}
|
|
36
|
+
validFiles.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
37
|
+
return validFiles[0].path;
|
|
38
|
+
}
|
|
39
|
+
describe('findLatestVideo', () => {
|
|
40
|
+
let testDir;
|
|
41
|
+
beforeEach(async () => {
|
|
42
|
+
testDir = path.join(tmpdir(), `escribano-test-${Date.now()}`);
|
|
43
|
+
await mkdir(testDir, { recursive: true });
|
|
44
|
+
});
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
await rm(testDir, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
it('returns the most recently modified video file', async () => {
|
|
49
|
+
const file1 = path.join(testDir, 'old.mp4');
|
|
50
|
+
const file2 = path.join(testDir, 'new.mov');
|
|
51
|
+
await writeFile(file1, 'old');
|
|
52
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
53
|
+
await writeFile(file2, 'new');
|
|
54
|
+
const result = await findLatestVideo(testDir);
|
|
55
|
+
expect(result).toBe(file2);
|
|
56
|
+
});
|
|
57
|
+
it('skips broken symlinks and returns valid file', async () => {
|
|
58
|
+
const validFile = path.join(testDir, 'valid.mp4');
|
|
59
|
+
const brokenLink = path.join(testDir, 'broken.mov');
|
|
60
|
+
await writeFile(validFile, 'content');
|
|
61
|
+
await symlink('/nonexistent/path.mp4', brokenLink);
|
|
62
|
+
const result = await findLatestVideo(testDir);
|
|
63
|
+
expect(result).toBe(validFile);
|
|
64
|
+
});
|
|
65
|
+
it('skips files with permission denied and returns valid file', async () => {
|
|
66
|
+
const readable = path.join(testDir, 'readable.mp4');
|
|
67
|
+
const unreadable = path.join(testDir, 'unreadable.mov');
|
|
68
|
+
await writeFile(readable, 'content');
|
|
69
|
+
await writeFile(unreadable, 'content');
|
|
70
|
+
await chmod(unreadable, 0o000);
|
|
71
|
+
const result = await findLatestVideo(testDir);
|
|
72
|
+
expect(result).toBe(readable);
|
|
73
|
+
});
|
|
74
|
+
it('throws when no video files exist', async () => {
|
|
75
|
+
await writeFile(path.join(testDir, 'file.txt'), 'not a video');
|
|
76
|
+
await expect(findLatestVideo(testDir)).rejects.toThrow('No video files found');
|
|
77
|
+
});
|
|
78
|
+
it('throws when all video files are inaccessible', async () => {
|
|
79
|
+
const broken = path.join(testDir, 'broken.mov');
|
|
80
|
+
await symlink('/nonexistent', broken);
|
|
81
|
+
await chmod(testDir, 0o111);
|
|
82
|
+
try {
|
|
83
|
+
await expect(findLatestVideo(testDir)).rejects.toThrow();
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await chmod(testDir, 0o755);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
it('expands ~ to home directory', () => {
|
|
90
|
+
const result = expandPath('~/Videos/test.mp4');
|
|
91
|
+
expect(result).toMatch(/\/Videos\/test\.mp4$/);
|
|
92
|
+
expect(result).not.toContain('~');
|
|
93
|
+
});
|
|
94
|
+
it('handles various video extensions', async () => {
|
|
95
|
+
const files = ['a.mov', 'b.mp4', 'c.mkv', 'd.avi', 'e.webm'];
|
|
96
|
+
for (const f of files) {
|
|
97
|
+
await writeFile(path.join(testDir, f), 'content');
|
|
98
|
+
}
|
|
99
|
+
const result = await findLatestVideo(testDir);
|
|
100
|
+
expect(result).toMatch(/\.(mov|mp4|mkv|avi|webm)$/);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MLX Intelligence Adapter Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for Python path detection and auto-venv setup logic used to locate
|
|
5
|
+
* (or create) the correct Python interpreter with mlx-vlm installed.
|
|
6
|
+
*/
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
// Mock node:fs so we can control which paths "exist"
|
|
11
|
+
vi.mock('node:fs', () => ({
|
|
12
|
+
existsSync: vi.fn(() => false),
|
|
13
|
+
mkdirSync: vi.fn(),
|
|
14
|
+
unlinkSync: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
// Mock node:child_process so we don't actually spawn anything
|
|
17
|
+
vi.mock('node:child_process', () => ({
|
|
18
|
+
spawn: vi.fn(() => ({
|
|
19
|
+
on: vi.fn(),
|
|
20
|
+
stdout: { on: vi.fn() },
|
|
21
|
+
stderr: { on: vi.fn() },
|
|
22
|
+
kill: vi.fn(),
|
|
23
|
+
})),
|
|
24
|
+
}));
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
26
|
+
import { resolvePythonPath } from '../adapters/intelligence.mlx.adapter.js';
|
|
27
|
+
import { getPythonPath } from '../python-utils.js';
|
|
28
|
+
const mockExistsSync = vi.mocked(existsSync);
|
|
29
|
+
// Keys cleared/restored around each test
|
|
30
|
+
const MANAGED_KEYS = [
|
|
31
|
+
'ESCRIBANO_PYTHON_PATH',
|
|
32
|
+
'VIRTUAL_ENV',
|
|
33
|
+
'UV_PROJECT_ENVIRONMENT',
|
|
34
|
+
];
|
|
35
|
+
describe('getPythonPath', () => {
|
|
36
|
+
const saved = {};
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
for (const key of MANAGED_KEYS) {
|
|
39
|
+
saved[key] = process.env[key];
|
|
40
|
+
delete process.env[key];
|
|
41
|
+
}
|
|
42
|
+
mockExistsSync.mockReturnValue(false);
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
for (const key of MANAGED_KEYS) {
|
|
46
|
+
if (saved[key] === undefined) {
|
|
47
|
+
delete process.env[key];
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
process.env[key] = saved[key];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
it('returns ESCRIBANO_PYTHON_PATH when set (highest priority)', () => {
|
|
55
|
+
process.env.ESCRIBANO_PYTHON_PATH = '/custom/python3';
|
|
56
|
+
expect(getPythonPath()).toBe('/custom/python3');
|
|
57
|
+
});
|
|
58
|
+
it('returns VIRTUAL_ENV python when set', () => {
|
|
59
|
+
process.env.VIRTUAL_ENV = '/home/user/myenv';
|
|
60
|
+
expect(getPythonPath()).toBe(resolve('/home/user/myenv', 'bin', 'python3'));
|
|
61
|
+
});
|
|
62
|
+
it('prefers ESCRIBANO_PYTHON_PATH over VIRTUAL_ENV', () => {
|
|
63
|
+
process.env.ESCRIBANO_PYTHON_PATH = '/explicit/python3';
|
|
64
|
+
process.env.VIRTUAL_ENV = '/some/venv';
|
|
65
|
+
expect(getPythonPath()).toBe('/explicit/python3');
|
|
66
|
+
});
|
|
67
|
+
it('returns UV_PROJECT_ENVIRONMENT python when set', () => {
|
|
68
|
+
process.env.UV_PROJECT_ENVIRONMENT = '/project/.venv';
|
|
69
|
+
expect(getPythonPath()).toBe(resolve('/project/.venv', 'bin', 'python3'));
|
|
70
|
+
});
|
|
71
|
+
it('prefers VIRTUAL_ENV over UV_PROJECT_ENVIRONMENT', () => {
|
|
72
|
+
process.env.VIRTUAL_ENV = '/active/venv';
|
|
73
|
+
process.env.UV_PROJECT_ENVIRONMENT = '/project/.venv';
|
|
74
|
+
expect(getPythonPath()).toBe(resolve('/active/venv', 'bin', 'python3'));
|
|
75
|
+
});
|
|
76
|
+
it('returns project-local .venv python when it exists', () => {
|
|
77
|
+
const localVenvPython = resolve(process.cwd(), '.venv', 'bin', 'python3');
|
|
78
|
+
mockExistsSync.mockImplementation((p) => p === localVenvPython);
|
|
79
|
+
expect(getPythonPath()).toBe(localVenvPython);
|
|
80
|
+
});
|
|
81
|
+
it('returns home .venv python when it exists and local .venv does not', () => {
|
|
82
|
+
const homeVenvPython = resolve(homedir(), '.venv', 'bin', 'python3');
|
|
83
|
+
mockExistsSync.mockImplementation((p) => p === homeVenvPython);
|
|
84
|
+
expect(getPythonPath()).toBe(homeVenvPython);
|
|
85
|
+
});
|
|
86
|
+
it('prefers local .venv over home .venv', () => {
|
|
87
|
+
const localVenvPython = resolve(process.cwd(), '.venv', 'bin', 'python3');
|
|
88
|
+
const homeVenvPython = resolve(homedir(), '.venv', 'bin', 'python3');
|
|
89
|
+
mockExistsSync.mockImplementation((p) => p === localVenvPython || p === homeVenvPython);
|
|
90
|
+
expect(getPythonPath()).toBe(localVenvPython);
|
|
91
|
+
});
|
|
92
|
+
it('returns null when nothing is explicitly configured (triggers auto-venv)', () => {
|
|
93
|
+
mockExistsSync.mockReturnValue(false);
|
|
94
|
+
expect(getPythonPath()).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe('resolvePythonPath', () => {
|
|
98
|
+
const saved = {};
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
for (const key of MANAGED_KEYS) {
|
|
101
|
+
saved[key] = process.env[key];
|
|
102
|
+
delete process.env[key];
|
|
103
|
+
}
|
|
104
|
+
mockExistsSync.mockReturnValue(false);
|
|
105
|
+
});
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
for (const key of MANAGED_KEYS) {
|
|
108
|
+
if (saved[key] === undefined) {
|
|
109
|
+
delete process.env[key];
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
process.env[key] = saved[key];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
vi.restoreAllMocks();
|
|
116
|
+
});
|
|
117
|
+
it('returns the explicit path immediately when ESCRIBANO_PYTHON_PATH is set', async () => {
|
|
118
|
+
process.env.ESCRIBANO_PYTHON_PATH = '/my/python3';
|
|
119
|
+
await expect(resolvePythonPath()).resolves.toBe('/my/python3');
|
|
120
|
+
});
|
|
121
|
+
it('returns the explicit path immediately when VIRTUAL_ENV is set', async () => {
|
|
122
|
+
process.env.VIRTUAL_ENV = '/my/venv';
|
|
123
|
+
await expect(resolvePythonPath()).resolves.toBe(resolve('/my/venv', 'bin', 'python3'));
|
|
124
|
+
});
|
|
125
|
+
it('falls through to managed venv python when nothing is configured', async () => {
|
|
126
|
+
// Simulate: no explicit config, venv already exists, mlx-vlm already installed
|
|
127
|
+
const venvPython = resolve(homedir(), '.escribano', 'venv', 'bin', 'python3');
|
|
128
|
+
mockExistsSync.mockImplementation((p) => p === venvPython);
|
|
129
|
+
// Mock spawn so the mlx-vlm probe exits with 0 (already installed)
|
|
130
|
+
const { spawn } = await import('node:child_process');
|
|
131
|
+
const mockSpawn = vi.mocked(spawn);
|
|
132
|
+
mockSpawn.mockImplementation((_cmd, _args, _opts) => {
|
|
133
|
+
const emitter = {
|
|
134
|
+
on: vi.fn((event, cb) => {
|
|
135
|
+
if (event === 'exit')
|
|
136
|
+
cb(0);
|
|
137
|
+
return emitter;
|
|
138
|
+
}),
|
|
139
|
+
stdout: { on: vi.fn() },
|
|
140
|
+
stderr: { on: vi.fn() },
|
|
141
|
+
kill: vi.fn(),
|
|
142
|
+
};
|
|
143
|
+
return emitter;
|
|
144
|
+
});
|
|
145
|
+
await expect(resolvePythonPath()).resolves.toBe(venvPython);
|
|
146
|
+
});
|
|
147
|
+
it('creates the managed venv when it does not exist', async () => {
|
|
148
|
+
// beforeEach has mockExistsSync default to false, simulating a missing venv
|
|
149
|
+
const venvDir = resolve(homedir(), '.escribano', 'venv');
|
|
150
|
+
const { spawn } = await import('node:child_process');
|
|
151
|
+
const mockSpawn = vi.mocked(spawn);
|
|
152
|
+
mockSpawn.mockClear();
|
|
153
|
+
// Set up spawn to call exit(0) for all calls so the async function resolves.
|
|
154
|
+
mockSpawn.mockImplementation((_cmd, _args, _opts) => {
|
|
155
|
+
const emitter = {
|
|
156
|
+
on: vi.fn((event, cb) => {
|
|
157
|
+
if (event === 'exit')
|
|
158
|
+
cb(0);
|
|
159
|
+
return emitter;
|
|
160
|
+
}),
|
|
161
|
+
stdout: { on: vi.fn() },
|
|
162
|
+
stderr: { on: vi.fn() },
|
|
163
|
+
kill: vi.fn(),
|
|
164
|
+
};
|
|
165
|
+
return emitter;
|
|
166
|
+
});
|
|
167
|
+
await resolvePythonPath();
|
|
168
|
+
// Expect that we attempted to create a virtual environment in the managed directory
|
|
169
|
+
expect(mockSpawn).toHaveBeenCalled();
|
|
170
|
+
// Find the venv-creation call: command is python3 with -m venv <dir>
|
|
171
|
+
const venvCall = mockSpawn.mock.calls.find(([cmd, args]) => typeof cmd === 'string' &&
|
|
172
|
+
(cmd === 'python3' || cmd === 'python') &&
|
|
173
|
+
Array.isArray(args) &&
|
|
174
|
+
args.includes('-m') &&
|
|
175
|
+
args.includes('venv'));
|
|
176
|
+
expect(venvCall).toBeDefined();
|
|
177
|
+
expect(venvCall?.[1]).toContain(venvDir);
|
|
178
|
+
});
|
|
179
|
+
it('installs mlx-vlm when the import probe fails', async () => {
|
|
180
|
+
const venvPython = resolve(homedir(), '.escribano', 'venv', 'bin', 'python3');
|
|
181
|
+
// Simulate: managed venv python exists
|
|
182
|
+
mockExistsSync.mockImplementation((p) => p === venvPython);
|
|
183
|
+
const { spawn } = await import('node:child_process');
|
|
184
|
+
const mockSpawn = vi.mocked(spawn);
|
|
185
|
+
mockSpawn.mockClear();
|
|
186
|
+
let callIndex = 0;
|
|
187
|
+
mockSpawn.mockImplementation((_cmd, _args, _opts) => {
|
|
188
|
+
const thisCall = callIndex++;
|
|
189
|
+
const emitter = {
|
|
190
|
+
on: vi.fn((event, cb) => {
|
|
191
|
+
if (event === 'exit') {
|
|
192
|
+
// First call: import probe fails (non-zero exit)
|
|
193
|
+
// All subsequent calls (ensurepip, pip install, ...): succeed
|
|
194
|
+
cb(thisCall === 0 ? 1 : 0);
|
|
195
|
+
}
|
|
196
|
+
return emitter;
|
|
197
|
+
}),
|
|
198
|
+
stdout: { on: vi.fn() },
|
|
199
|
+
stderr: { on: vi.fn() },
|
|
200
|
+
kill: vi.fn(),
|
|
201
|
+
};
|
|
202
|
+
return emitter;
|
|
203
|
+
});
|
|
204
|
+
await expect(resolvePythonPath()).resolves.toBe(venvPython);
|
|
205
|
+
expect(mockSpawn.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
206
|
+
// Find the pip install call regardless of its position (robust to ensurepip being inserted)
|
|
207
|
+
const installCall = mockSpawn.mock.calls.find(([_cmd, args]) => Array.isArray(args) &&
|
|
208
|
+
args.includes('-m') &&
|
|
209
|
+
args.includes('pip') &&
|
|
210
|
+
args.includes('install') &&
|
|
211
|
+
args.includes('mlx-vlm'));
|
|
212
|
+
expect(installCall).toBeDefined();
|
|
213
|
+
expect(installCall?.[1]).toEqual(expect.arrayContaining([
|
|
214
|
+
'-m',
|
|
215
|
+
'pip',
|
|
216
|
+
'install',
|
|
217
|
+
'mlx-vlm',
|
|
218
|
+
'torch',
|
|
219
|
+
'torchvision',
|
|
220
|
+
]));
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -126,7 +126,7 @@ describe('adaptiveSampleWithScenes', () => {
|
|
|
126
126
|
it('should produce fewer total frames with high scene density', () => {
|
|
127
127
|
const frames = makeFrames(10); // 10 minutes, 300 frames
|
|
128
128
|
// Low density: 5 scene changes
|
|
129
|
-
const
|
|
129
|
+
const _lowDensityResult = adaptiveSampleWithScenes(frames, [30, 100, 200, 400, 500], { baseIntervalSeconds: 10 });
|
|
130
130
|
// High density: 60 scene changes (every ~10s)
|
|
131
131
|
const highDensityScenes = Array.from({ length: 60 }, (_, i) => i * 10);
|
|
132
132
|
const highDensityResult = adaptiveSampleWithScenes(frames, highDensityScenes, { baseIntervalSeconds: 10 });
|
|
@@ -150,7 +150,6 @@ describe('Visual Observer Pipeline', () => {
|
|
|
150
150
|
transcribeSegment: vi.fn().mockResolvedValue(''),
|
|
151
151
|
};
|
|
152
152
|
const mockVideoService = {
|
|
153
|
-
// biome-ignore lint/suspicious/noExplicitAny: mock
|
|
154
153
|
...vi.fn(), // Other methods mocked as needed
|
|
155
154
|
extractFramesAtTimestamps: vi.fn(),
|
|
156
155
|
extractFramesAtInterval: vi
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "escribano",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "AI-powered session intelligence tool — turn screen recordings into structured work summaries",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"better-sqlite3": "^12.6.2",
|
|
69
|
+
"dotenv": "^17.3.1",
|
|
69
70
|
"express": "^5.0.0",
|
|
70
71
|
"pidusage": "^4.0.1",
|
|
71
72
|
"undici": "^7.22.0",
|
|
@@ -15,14 +15,40 @@ function getCurrentTag() {
|
|
|
15
15
|
try {
|
|
16
16
|
return run("git describe --tags --exact-match");
|
|
17
17
|
} catch {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
// No tag - create one from package.json
|
|
19
|
+
const pkg = JSON.parse(readFileSync(join(REPO_ROOT, "package.json")));
|
|
20
|
+
const tag = `v${pkg.version}`;
|
|
21
|
+
|
|
22
|
+
console.log(`⚠️ No tag found, creating ${tag}...`);
|
|
23
|
+
run(`git tag ${tag}`);
|
|
24
|
+
run(`git push origin ${tag}`);
|
|
25
|
+
console.log(`✅ Created and pushed tag: ${tag}`);
|
|
26
|
+
|
|
27
|
+
return tag;
|
|
21
28
|
}
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
function getPreviousTag(currentTag) {
|
|
25
|
-
|
|
32
|
+
// Fetch latest tags from remote
|
|
33
|
+
run("git fetch origin --tags");
|
|
34
|
+
|
|
35
|
+
// Get all version tags and sort them
|
|
36
|
+
const allTagsOutput = run("git tag --list 'v*'");
|
|
37
|
+
const allTags = allTagsOutput.split("\n").filter(Boolean);
|
|
38
|
+
|
|
39
|
+
// Sort by version number
|
|
40
|
+
allTags.sort((a, b) => {
|
|
41
|
+
const aVersion = a.replace(/^v/, "").split(".").map(Number);
|
|
42
|
+
const bVersion = b.replace(/^v/, "").split(".").map(Number);
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < Math.max(aVersion.length, bVersion.length); i++) {
|
|
45
|
+
const aPart = aVersion[i] || 0;
|
|
46
|
+
const bPart = bVersion[i] || 0;
|
|
47
|
+
if (aPart !== bPart) return aPart - bPart;
|
|
48
|
+
}
|
|
49
|
+
return 0;
|
|
50
|
+
});
|
|
51
|
+
|
|
26
52
|
const currentIndex = allTags.indexOf(currentTag);
|
|
27
53
|
|
|
28
54
|
if (currentIndex === 0) return null;
|
|
@@ -161,6 +187,29 @@ function createGitHubRelease(tag, releaseNotes) {
|
|
|
161
187
|
}
|
|
162
188
|
}
|
|
163
189
|
|
|
190
|
+
function commitAndPushChangelog(tag) {
|
|
191
|
+
try {
|
|
192
|
+
// Check if there are changes
|
|
193
|
+
const status = run("git status --porcelain CHANGELOG.md");
|
|
194
|
+
|
|
195
|
+
if (!status) {
|
|
196
|
+
console.log("ℹ️ CHANGELOG.md already up to date, nothing to commit");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Stage, commit, and push
|
|
201
|
+
run("git add CHANGELOG.md");
|
|
202
|
+
run(`git commit -m "docs: update CHANGELOG for ${tag}"`);
|
|
203
|
+
run("git push");
|
|
204
|
+
|
|
205
|
+
console.log(`✅ Committed and pushed CHANGELOG.md for ${tag}`);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
console.error("⚠️ Failed to commit/push CHANGELOG.md:", error.message);
|
|
208
|
+
console.error(" You may need to commit manually:");
|
|
209
|
+
console.error(" git add CHANGELOG.md && git commit -m 'docs: update CHANGELOG' && git push");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
164
213
|
async function main() {
|
|
165
214
|
console.log("🚀 Creating GitHub release...\n");
|
|
166
215
|
|
|
@@ -187,12 +236,9 @@ async function main() {
|
|
|
187
236
|
|
|
188
237
|
updateChangelog(releaseNotes);
|
|
189
238
|
createGitHubRelease(currentTag, releaseNotes);
|
|
239
|
+
commitAndPushChangelog(currentTag);
|
|
190
240
|
|
|
191
|
-
console.log("\n🎉 Release complete!");
|
|
192
|
-
console.log("\nNext steps:");
|
|
193
|
-
console.log(" 1. Review CHANGELOG.md changes");
|
|
194
|
-
console.log(" 2. Commit: git add CHANGELOG.md && git commit -m 'docs: update CHANGELOG'");
|
|
195
|
-
console.log(" 3. Push: git push");
|
|
241
|
+
console.log("\n🎉 Release complete! All changes pushed to GitHub.");
|
|
196
242
|
}
|
|
197
243
|
|
|
198
244
|
main().catch(error => {
|
package/scripts/mlx_bridge.py
CHANGED
|
@@ -133,7 +133,7 @@ def signal_handler(signum: int, frame: Any) -> None:
|
|
|
133
133
|
def load_model() -> tuple[Any, Any, Any]:
|
|
134
134
|
"""Load MLX-VLM model."""
|
|
135
135
|
log(f"Loading model: {MODEL_NAME}")
|
|
136
|
-
log("This may take 30-
|
|
136
|
+
log("This may take 30-120 seconds on first run or after memory clear...")
|
|
137
137
|
start = time.time()
|
|
138
138
|
|
|
139
139
|
try:
|
|
@@ -153,7 +153,31 @@ def load_model() -> tuple[Any, Any, Any]:
|
|
|
153
153
|
return model_obj, processor_obj, config_obj
|
|
154
154
|
except ImportError as e:
|
|
155
155
|
log(f"Failed to import mlx_vlm: {e}", "error")
|
|
156
|
-
log("
|
|
156
|
+
log(f"Python used: {sys.executable}", "error")
|
|
157
|
+
custom_python = os.environ.get("ESCRIBANO_PYTHON_PATH")
|
|
158
|
+
if custom_python:
|
|
159
|
+
log(
|
|
160
|
+
"ESCRIBANO_PYTHON_PATH is set, so Escribano does not auto-install mlx-vlm "
|
|
161
|
+
"into this Python environment.",
|
|
162
|
+
"error",
|
|
163
|
+
)
|
|
164
|
+
log(
|
|
165
|
+
f"Make sure mlx-vlm is installed for that Python "
|
|
166
|
+
f"(e.g. `{custom_python} -m pip install mlx-vlm`), "
|
|
167
|
+
"or unset ESCRIBANO_PYTHON_PATH to let Escribano manage its own Python.",
|
|
168
|
+
"error",
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
log(
|
|
172
|
+
"mlx-vlm is missing from Escribano's managed Python environment. "
|
|
173
|
+
"It is normally installed automatically.",
|
|
174
|
+
"error",
|
|
175
|
+
)
|
|
176
|
+
log(
|
|
177
|
+
"Try restarting Escribano so it can recreate or repair its Python environment. "
|
|
178
|
+
"If the problem persists, install `mlx-vlm` into this Python or report an issue.",
|
|
179
|
+
"error",
|
|
180
|
+
)
|
|
157
181
|
sys.exit(1)
|
|
158
182
|
except Exception as e:
|
|
159
183
|
log(f"Failed to load model: {e}", "error")
|