@broberg/lens-daemon 0.0.0-stage → 0.1.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.
- package/README.md +14 -2
- package/package.json +52 -3
- package/src/artifact-backfill.ts +253 -0
- package/src/artifact-uploads.ts +370 -0
- package/src/auth/index.ts +294 -0
- package/src/browser.ts +440 -0
- package/src/build-marker.ts +197 -0
- package/src/capture-session.ts +873 -0
- package/src/capture.ts +348 -0
- package/src/critic-dom.ts +951 -0
- package/src/critic-vision.ts +205 -0
- package/src/critic.ts +73 -0
- package/src/daemon-routes.ts +333 -0
- package/src/durability.ts +18 -0
- package/src/ensure-chromium.ts +118 -0
- package/src/export.ts +256 -0
- package/src/fill.ts +149 -0
- package/src/flow-store.ts +207 -0
- package/src/flow.ts +2869 -0
- package/src/frames.ts +195 -0
- package/src/gate.ts +123 -0
- package/src/http.ts +175 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +741 -0
- package/src/mcp.ts +562 -0
- package/src/motion-probe.ts +653 -0
- package/src/password-in-url.ts +106 -0
- package/src/pdf-text.ts +514 -0
- package/src/pixelmatch.d.ts +21 -0
- package/src/preflight.ts +76 -0
- package/src/readiness.ts +282 -0
- package/src/record-run.ts +427 -0
- package/src/record.ts +387 -0
- package/src/sandbox.ts +22 -0
- package/src/schema.ts +300 -0
- package/src/scrape.ts +116 -0
- package/src/seed-storage.ts +149 -0
- package/src/service.ts +1483 -0
- package/src/storage.ts +159 -0
- package/src/store.ts +267 -0
- package/src/swallowed-errors.ts +252 -0
- package/src/validate-vision.ts +97 -0
- package/src/verify.ts +411 -0
- package/src/video.ts +211 -0
package/README.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @broberg/lens-daemon
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Lens module of the cardmem daemon as a package (lens F001.5). TypeScript source, Bun runtime.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { ensureLensSchema, createLensRouter, createLensMcpRouter, LENS_DAEMON_VERSION } from '@broberg/lens-daemon';
|
|
7
|
+
|
|
8
|
+
ensureLensSchema(db); // inside getDb()'s schema transaction
|
|
9
|
+
app.route('/lens', createLensRouter(lensDeps)); // every /lens/* route
|
|
10
|
+
app.route('/mcp', createLensMcpRouter(lensDeps));
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`lensDeps.resolveSandboxedPath` must be the daemon's own sandbox check. The package holds no copy of it, and every path call refuses until it is set.
|
|
14
|
+
|
|
15
|
+
Source: github.com/broberg-ai/lens `packages/daemon`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/lens-daemon",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The Lens module of the cardmem daemon (flows, critic, coverage, capture sessions, the /lens/* routes and the cardmem-lens MCP) as a package. The daemon mounts createLensRouter(deps) + createLensMcpRouter(deps). Bun runtime.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@broberg/ai-sdk": "0.56.0",
|
|
9
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
10
|
+
"@mozilla/readability": "^0.6.0",
|
|
11
|
+
"hono": "^4.6.14",
|
|
12
|
+
"pixelmatch": "^6.0.0",
|
|
13
|
+
"playwright": "1.61.1",
|
|
14
|
+
"pngjs": "^7.0.0",
|
|
15
|
+
"sharp": "^0.33.5",
|
|
16
|
+
"turndown": "^7.2.4",
|
|
17
|
+
"zod": "^3.23.8",
|
|
18
|
+
"@broberg/lens-engine": "0.10.2",
|
|
19
|
+
"@broberg/lens-svg": "0.1.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@happy-dom/global-registrator": "^20.10.6",
|
|
23
|
+
"@types/bun": "^1.1.14",
|
|
24
|
+
"@types/pngjs": "^6.0.5",
|
|
25
|
+
"@types/turndown": "^5.0.6",
|
|
26
|
+
"typescript": "^5.6.3"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/broberg-ai/lens",
|
|
31
|
+
"directory": "packages/daemon"
|
|
32
|
+
},
|
|
33
|
+
"exports": {
|
|
34
|
+
".": "./src/index.ts",
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src",
|
|
39
|
+
"!src/**/*.test.ts",
|
|
40
|
+
"!src/**/*.selftest.ts",
|
|
41
|
+
"!src/test-support",
|
|
42
|
+
"!src/fixtures",
|
|
43
|
+
"README.md"
|
|
44
|
+
],
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"bun": ">=1.1"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"test": "bun test"
|
|
54
|
+
}
|
|
6
55
|
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// F276.4 — put the whole history in the cloud. Christian, 2026-08-25, asked without
|
|
2
|
+
// hedging: "jeg vil have ALT fra Lens historik i skyen."
|
|
3
|
+
//
|
|
4
|
+
// MEASURED before building, so the job is scoped to what exists rather than to a guess:
|
|
5
|
+
// 6588 files, 982 MB, median 88 KB, largest 7.1 MB, 6585 PNG + 3 webm. Not one file
|
|
6
|
+
// exceeds the 25 MB upload ceiling, so nothing is refused on size — which makes this a
|
|
7
|
+
// throughput problem, not a selection problem.
|
|
8
|
+
//
|
|
9
|
+
// IT WALKS THE DISK, NOT THE DATABASE, and that is the load-bearing choice. The tables
|
|
10
|
+
// know which artifacts they BELIEVE exist; the disk knows which ones are actually there.
|
|
11
|
+
// Those differ in both directions — a row can point at bytes that were deleted, and bytes
|
|
12
|
+
// can sit in the directory with no row left pointing at them. Walking the rows would
|
|
13
|
+
// silently skip the second kind and report a complete backfill.
|
|
14
|
+
|
|
15
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
16
|
+
import { join, relative, sep } from 'node:path';
|
|
17
|
+
import type { LensDeps } from './service.ts';
|
|
18
|
+
import { enqueueArtifact, uploadStats } from './artifact-uploads.ts';
|
|
19
|
+
|
|
20
|
+
export interface SkippedDir {
|
|
21
|
+
/** The directory as it exists on disk. */
|
|
22
|
+
dir: string;
|
|
23
|
+
/** Why it is not going to the cloud. A skip must be able to say its reason out
|
|
24
|
+
* loud; a skip with no reason is indistinguishable from a lookup that failed. */
|
|
25
|
+
reason: string;
|
|
26
|
+
/** How many files it holds, so the size of what is NOT uploaded is visible. */
|
|
27
|
+
files: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BackfillResult {
|
|
31
|
+
/** Files found on disk. */
|
|
32
|
+
scanned: number;
|
|
33
|
+
/** Newly queued by this pass. */
|
|
34
|
+
enqueued: number;
|
|
35
|
+
/** Already known to the queue — the re-run case, and the proof it is idempotent. */
|
|
36
|
+
already_queued: number;
|
|
37
|
+
/** Directories held back ON PURPOSE, each with its reason and file count. Separate
|
|
38
|
+
* from `unattributable` because they are different facts: one is a decision, the
|
|
39
|
+
* other is a defect, and collapsing them is exactly how 967 files went missing. */
|
|
40
|
+
skipped: SkippedDir[];
|
|
41
|
+
/** Files whose path does not yield a project slug. Reported, never skipped silently:
|
|
42
|
+
* an unexplained gap between `scanned` and `enqueued` reads as a bug in the count. */
|
|
43
|
+
unattributable: string[];
|
|
44
|
+
/** The queue AFTER this pass. */
|
|
45
|
+
queue: ReturnType<typeof uploadStats>;
|
|
46
|
+
/** Set when the local storage backend is not a directory we can walk. */
|
|
47
|
+
note: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Directories whose name is NOT the project slug cardmem knows it by.
|
|
52
|
+
*
|
|
53
|
+
* MEASURED, not guessed (2026-08-25): seven of the 32 directories on disk matched no
|
|
54
|
+
* project. Three were renames the artifact tree never heard about, and their 600 files
|
|
55
|
+
* would have failed five times each and then been abandoned — silently, because the
|
|
56
|
+
* only thing a caller sees is a `failed` count with no names in it.
|
|
57
|
+
*
|
|
58
|
+
* Christian confirmed each mapping himself; `torrent-search-api` is not a guess from
|
|
59
|
+
* the repo name, it is his answer: "Hedder moovyy nu."
|
|
60
|
+
*/
|
|
61
|
+
export const DIR_ALIASES: Record<string, string> = {
|
|
62
|
+
xrt81: 'xrt81-com',
|
|
63
|
+
'broberg-ai': 'broberg-ai-site',
|
|
64
|
+
'torrent-search-api': 'moovyy',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Directories that are deliberately NOT uploaded, each with the reason it is held back.
|
|
69
|
+
*
|
|
70
|
+
* These are held back BY DECISION. Today they also happen to fail the project lookup,
|
|
71
|
+
* which means they are currently safe by accident — and an accident is not a policy.
|
|
72
|
+
* Naming them here makes the hold-back survive someone later fixing the lookup.
|
|
73
|
+
*
|
|
74
|
+
* `capture-sessions` is Christian's call, given 2026-08-25 when asked directly whether
|
|
75
|
+
* 329 screenshots of him signing in to App Store Connect and Google Play should go to
|
|
76
|
+
* the cloud: "Nej." No credentials are visible in them (all 329 verified as PNG), and
|
|
77
|
+
* they stay on the machine anyway.
|
|
78
|
+
*/
|
|
79
|
+
export const SKIP_DIRS: Record<string, string> = {
|
|
80
|
+
'capture-sessions': "owner decision 2026-08-25: login recordings stay on the Mac",
|
|
81
|
+
_external: 'screenshots of third-party sites — not this fleet\'s project history',
|
|
82
|
+
'lens-statepath-test': 'one-off engine test run, not project history',
|
|
83
|
+
'lens-mint-test': 'one-off engine test run, not project history',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type Attribution =
|
|
87
|
+
| { kind: 'project'; slug: string }
|
|
88
|
+
| { kind: 'skipped'; dir: string; reason: string }
|
|
89
|
+
| { kind: 'unattributable' };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Artifact keys are `<dir>/<kind>/…`. The first segment is USUALLY the project slug —
|
|
93
|
+
* but "usually" is the whole bug, so this returns which of three things happened rather
|
|
94
|
+
* than a slug-or-null that flattens two of them into one.
|
|
95
|
+
*/
|
|
96
|
+
export function attribute(relPath: string): Attribution {
|
|
97
|
+
const first = relPath.split(sep)[0];
|
|
98
|
+
if (!first || first === relPath) return { kind: 'unattributable' };
|
|
99
|
+
const reason = SKIP_DIRS[first];
|
|
100
|
+
if (reason) return { kind: 'skipped', dir: first, reason };
|
|
101
|
+
return { kind: 'project', slug: DIR_ALIASES[first] ?? first };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function walk(dir: string, out: string[]): Promise<void> {
|
|
105
|
+
let entries;
|
|
106
|
+
try {
|
|
107
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
108
|
+
} catch {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
for (const e of entries) {
|
|
112
|
+
const p = join(dir, e.name);
|
|
113
|
+
if (e.isDirectory()) await walk(p, out);
|
|
114
|
+
else if (e.isFile()) out.push(p);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function backfillFromDisk(deps: LensDeps & { artifactRoot?: string }): Promise<BackfillResult> {
|
|
119
|
+
const root = deps.artifactRoot ?? defaultRoot();
|
|
120
|
+
if (deps.storage.backend !== 'local') {
|
|
121
|
+
// Not a failure — tigris artifacts are already durable — but it must SAY so rather
|
|
122
|
+
// than return zeros, which would read identically to "there was nothing to do".
|
|
123
|
+
return {
|
|
124
|
+
scanned: 0,
|
|
125
|
+
enqueued: 0,
|
|
126
|
+
already_queued: 0,
|
|
127
|
+
skipped: [],
|
|
128
|
+
unattributable: [],
|
|
129
|
+
queue: uploadStats(deps.db),
|
|
130
|
+
note: `storage backend is '${deps.storage.backend}', not a local directory — nothing to walk`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const files: string[] = [];
|
|
135
|
+
await walk(root, files);
|
|
136
|
+
|
|
137
|
+
let enqueued = 0;
|
|
138
|
+
let already = 0;
|
|
139
|
+
const unattributable: string[] = [];
|
|
140
|
+
const skipped = new Map<string, SkippedDir>();
|
|
141
|
+
|
|
142
|
+
for (const abs of files) {
|
|
143
|
+
const rel = relative(root, abs);
|
|
144
|
+
const a = attribute(rel);
|
|
145
|
+
if (a.kind === 'unattributable') {
|
|
146
|
+
unattributable.push(rel);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (a.kind === 'skipped') {
|
|
150
|
+
const seen = skipped.get(a.dir);
|
|
151
|
+
if (seen) seen.files++;
|
|
152
|
+
else skipped.set(a.dir, { dir: a.dir, reason: a.reason, files: 1 });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// POSIX-style key, matching how storage.put wrote it.
|
|
156
|
+
const key = rel.split(sep).join('/');
|
|
157
|
+
const before = countKey(deps.db, key);
|
|
158
|
+
enqueueArtifact(deps.db, a.slug, key);
|
|
159
|
+
if (before) already++;
|
|
160
|
+
else enqueued++;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A row already in the queue under a dead directory name keeps failing forever
|
|
164
|
+
// unless it is repointed — the alias fixes NEW rows, this fixes the ones the old
|
|
165
|
+
// mapping already wrote. Also clears their failed state so the drain retries them.
|
|
166
|
+
repointAliased(deps.db);
|
|
167
|
+
applySkipPolicy(deps.db);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
scanned: files.length,
|
|
171
|
+
enqueued,
|
|
172
|
+
already_queued: already,
|
|
173
|
+
skipped: [...skipped.values()].sort((x, y) => y.files - x.files),
|
|
174
|
+
unattributable,
|
|
175
|
+
queue: uploadStats(deps.db),
|
|
176
|
+
note: null,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Move queue rows written under a pre-rename directory onto the current slug, and
|
|
182
|
+
* un-fail them.
|
|
183
|
+
*
|
|
184
|
+
* Without this the alias only helps files enqueued from now on: the 27 rows already
|
|
185
|
+
* sitting at `failed` with "project xrt81 is not known to cardmem" would stay failed,
|
|
186
|
+
* and a re-run would report `already_queued` for them — a count that reads like
|
|
187
|
+
* success and means the opposite.
|
|
188
|
+
*/
|
|
189
|
+
export function repointAliased(db: LensDeps['db']): number {
|
|
190
|
+
let moved = 0;
|
|
191
|
+
for (const [from, to] of Object.entries(DIR_ALIASES)) {
|
|
192
|
+
const r = db
|
|
193
|
+
.prepare(
|
|
194
|
+
`UPDATE lens_artifact_uploads
|
|
195
|
+
SET project_slug = ?, status = 'pending', attempts = 0, last_error = NULL
|
|
196
|
+
WHERE project_slug = ? AND status != 'done'`,
|
|
197
|
+
)
|
|
198
|
+
.run(to, from);
|
|
199
|
+
moved += Number(r.changes ?? 0);
|
|
200
|
+
}
|
|
201
|
+
return moved;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Park the rows belonging to a held-back directory in their OWN state.
|
|
206
|
+
*
|
|
207
|
+
* 6588 rows were already queued before the skip list existed, 365 of them from
|
|
208
|
+
* directories the owner has since said should stay on the Mac. Left as `pending` they
|
|
209
|
+
* would be retried five times each and land in `failed` — where they would be
|
|
210
|
+
* indistinguishable from a genuine upload failure, and would keep the queue looking
|
|
211
|
+
* broken forever. `skipped` says what actually happened, and carries the reason.
|
|
212
|
+
*/
|
|
213
|
+
export function applySkipPolicy(db: LensDeps['db']): number {
|
|
214
|
+
let parked = 0;
|
|
215
|
+
for (const [dir, reason] of Object.entries(SKIP_DIRS)) {
|
|
216
|
+
const r = db
|
|
217
|
+
.prepare(
|
|
218
|
+
`UPDATE lens_artifact_uploads
|
|
219
|
+
SET status = 'skipped', last_error = ?
|
|
220
|
+
WHERE status != 'done' AND status != 'skipped' AND artifact_key LIKE ?`,
|
|
221
|
+
)
|
|
222
|
+
.run(reason, `${dir}/%`);
|
|
223
|
+
parked += Number(r.changes ?? 0);
|
|
224
|
+
}
|
|
225
|
+
return parked;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function countKey(db: LensDeps['db'], key: string): boolean {
|
|
229
|
+
const r = db.query('SELECT 1 AS n FROM lens_artifact_uploads WHERE artifact_key = ?').get(key);
|
|
230
|
+
return Boolean(r);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function defaultRoot(): string {
|
|
234
|
+
return join(
|
|
235
|
+
process.env.HOME ?? '',
|
|
236
|
+
'Library/Application Support/cardmem/lens-artifacts',
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Total bytes on disk, so a human can see the size of the job before starting it. */
|
|
241
|
+
export async function backfillSize(root?: string): Promise<{ files: number; bytes: number }> {
|
|
242
|
+
const files: string[] = [];
|
|
243
|
+
await walk(root ?? defaultRoot(), files);
|
|
244
|
+
let bytes = 0;
|
|
245
|
+
for (const f of files) {
|
|
246
|
+
try {
|
|
247
|
+
bytes += (await stat(f)).size;
|
|
248
|
+
} catch {
|
|
249
|
+
/* counted in `files`, not in `bytes` — a file that vanished mid-walk */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { files: files.length, bytes };
|
|
253
|
+
}
|