@mrdrmccoy/xerj-opencode-plugin 0.0.0-stage → 0.4.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.
@@ -0,0 +1,225 @@
1
+ // Opt-in second brain. `autoindex --brain` writes documents under the brain
2
+ // prefix and edges to `.xerj-memory-<name>-edges`; it cannot run in watch mode
3
+ // (watch requires --no-graph), so brains are one-shot runs after the project
4
+ // index and on explicit reindex.
5
+
6
+ import { spawn } from "node:child_process";
7
+ import { stat } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ import type { Config } from "../config.ts";
10
+ import type { Logger } from "../log.ts";
11
+ import { XerjClient, type ClientObserver } from "../node/client.ts";
12
+ import type { BrainSpec } from "../paths.ts";
13
+
14
+ export interface BrainRun {
15
+ readonly name: string;
16
+ readonly prefix: string;
17
+ /** Epoch ms of the last attempt; 0 means it has not run yet. */
18
+ readonly at: number;
19
+ readonly docs: number;
20
+ readonly edges: number;
21
+ readonly error: string | null;
22
+ }
23
+
24
+ export interface BrainRunSummary {
25
+ readonly docs: number;
26
+ readonly edges: number;
27
+ }
28
+
29
+ export interface BrainIndexerOptions {
30
+ readonly binaryPath: string;
31
+ readonly nodeUrl: string;
32
+ readonly dataRoot: string;
33
+ readonly config: Config;
34
+ readonly specs: ReadonlyArray<BrainSpec>;
35
+ readonly logger: Logger;
36
+ readonly observer?: ClientObserver;
37
+ readonly onChange?: () => void;
38
+ readonly onRun?: () => void;
39
+ }
40
+
41
+ const MIN_TIMEOUT_MS = 60_000;
42
+
43
+ export class BrainIndexer {
44
+ private readonly runs = new Map<string, BrainRun>();
45
+ private readonly client: XerjClient;
46
+ private running = false;
47
+
48
+ constructor(private readonly options: BrainIndexerOptions) {
49
+ this.client = new XerjClient(options.nodeUrl, undefined, options.observer);
50
+ }
51
+
52
+ /** Configured brains, with zeros for the ones that have not run yet. */
53
+ snapshot(): ReadonlyArray<BrainRun> {
54
+ return this.options.specs.map(
55
+ (spec) =>
56
+ this.runs.get(spec.name) ?? {
57
+ name: spec.name,
58
+ prefix: spec.prefix,
59
+ at: 0,
60
+ docs: 0,
61
+ edges: 0,
62
+ error: null,
63
+ },
64
+ );
65
+ }
66
+
67
+ async runAll(): Promise<void> {
68
+ if (this.running) {
69
+ this.options.logger.debug("brain indexing is already running");
70
+ return;
71
+ }
72
+
73
+ this.running = true;
74
+ try {
75
+ for (const spec of this.options.specs) await this.run(spec);
76
+ } finally {
77
+ this.running = false;
78
+ }
79
+ }
80
+
81
+ private async run(spec: BrainSpec): Promise<void> {
82
+ const info = await stat(spec.directory).catch(() => null);
83
+ if (!info?.isDirectory()) {
84
+ this.record(spec, {
85
+ at: Date.now(),
86
+ docs: 0,
87
+ edges: 0,
88
+ error: `${spec.label} is not a directory`,
89
+ });
90
+ return;
91
+ }
92
+
93
+ const args = [
94
+ "autoindex",
95
+ spec.directory,
96
+ "--url",
97
+ this.options.nodeUrl,
98
+ "--prefix",
99
+ spec.prefix,
100
+ "--brain",
101
+ spec.name,
102
+ "--state-dir",
103
+ join(this.options.dataRoot, "state", spec.prefix),
104
+ "--json",
105
+ "--progress",
106
+ "none",
107
+ ];
108
+
109
+ if (!this.options.config.index.semantic) args.push("--no-semantic");
110
+ if (this.options.config.index.workers !== null) {
111
+ args.push("--workers", String(this.options.config.index.workers));
112
+ }
113
+
114
+ try {
115
+ const output = await run(this.options.binaryPath, args, this.timeoutMs());
116
+ const summary = await this.summarize(spec, parseBrainRun(output));
117
+ this.record(spec, { at: Date.now(), ...summary, error: null });
118
+ this.options.onRun?.();
119
+ this.options.logger.info(`brain ${spec.name} indexed`, summary);
120
+ } catch (error) {
121
+ this.record(spec, {
122
+ at: Date.now(),
123
+ docs: 0,
124
+ edges: 0,
125
+ error: error instanceof Error ? error.message : String(error),
126
+ });
127
+ this.options.logger.warn(`brain ${spec.name} indexing failed`, String(error));
128
+ }
129
+ }
130
+
131
+ /** The run document reports edges written, not the graph's live total. */
132
+ private async summarize(
133
+ spec: BrainSpec,
134
+ fallback: BrainRunSummary,
135
+ ): Promise<BrainRunSummary> {
136
+ try {
137
+ const overview = await this.client.get(
138
+ `/_graph/${encodeURIComponent(spec.name)}/overview`,
139
+ "graph overview failed",
140
+ );
141
+ if (!isRecord(overview)) return fallback;
142
+
143
+ // `nodes.total` counts section records, not files; keep the run's doc
144
+ // total and take only the live edge count from the overview.
145
+ const edges = isRecord(overview.edges)
146
+ ? numberField(overview.edges, "total")
147
+ : null;
148
+
149
+ return {
150
+ docs: fallback.docs,
151
+ edges: edges ?? fallback.edges,
152
+ };
153
+ } catch (error) {
154
+ this.options.logger.debug("could not read the graph overview", String(error));
155
+ return fallback;
156
+ }
157
+ }
158
+
159
+ private record(spec: BrainSpec, run: Omit<BrainRun, "name" | "prefix">): void {
160
+ this.runs.set(spec.name, { name: spec.name, prefix: spec.prefix, ...run });
161
+ this.options.onChange?.();
162
+ }
163
+
164
+ private timeoutMs(): number {
165
+ return Math.max(MIN_TIMEOUT_MS, this.options.config.index.maxMinutes * 60_000);
166
+ }
167
+ }
168
+
169
+ /** Reads the run document `autoindex --json` prints as its last JSON line. */
170
+ export function parseBrainRun(output: string): BrainRunSummary {
171
+ const line = [...output.trim().split("\n")]
172
+ .reverse()
173
+ .find((entry) => entry.trim().startsWith("{"));
174
+
175
+ if (!line) return { docs: 0, edges: 0 };
176
+
177
+ try {
178
+ const document = JSON.parse(line) as Record<string, unknown>;
179
+ const graph = isRecord(document.graph) ? document.graph : {};
180
+ return {
181
+ docs:
182
+ numberField(document, "records_total") ??
183
+ numberField(document, "records_submitted_this_run") ??
184
+ 0,
185
+ edges: numberField(graph, "edges_written") ?? 0,
186
+ };
187
+ } catch {
188
+ return { docs: 0, edges: 0 };
189
+ }
190
+ }
191
+
192
+ function run(
193
+ command: string,
194
+ args: ReadonlyArray<string>,
195
+ timeoutMs: number,
196
+ ): Promise<string> {
197
+ return new Promise((resolve, reject) => {
198
+ const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "pipe"] });
199
+ let stdout = "";
200
+ let stderr = "";
201
+
202
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
203
+ child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
204
+ child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString()));
205
+ child.on("error", (error) => {
206
+ clearTimeout(timer);
207
+ reject(error);
208
+ });
209
+ child.on("close", (code) => {
210
+ clearTimeout(timer);
211
+ // Exit 3 completed with junk recorded; the run still counts.
212
+ if (code === 0 || code === 3) resolve(stdout);
213
+ else reject(new Error(`autoindex exited ${code}: ${stderr.trim().slice(0, 300)}`));
214
+ });
215
+ });
216
+ }
217
+
218
+ function numberField(value: Record<string, unknown>, key: string): number | null {
219
+ const found = value[key];
220
+ return typeof found === "number" && Number.isFinite(found) ? found : null;
221
+ }
222
+
223
+ function isRecord(value: unknown): value is Record<string, unknown> {
224
+ return typeof value === "object" && value !== null && !Array.isArray(value);
225
+ }
@@ -0,0 +1,164 @@
1
+ // Corpus layout and the autoindex command line. Pure pieces live here so the
2
+ // tests can pin the exact flags without spawning anything.
3
+ //
4
+ // Verified against v1.0.0-rc.100:
5
+ // - `--watch` requires `--no-graph` and emits one run document per pass on
6
+ // stdout when `--json` is set.
7
+ // - exit 3 is success with junk recorded; exit 4 is a decision request with a
8
+ // JSON body on stdout.
9
+
10
+ import { mkdir, writeFile } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import type { Config } from "../config.ts";
13
+ import { projectStateDir } from "../paths.ts";
14
+
15
+ export interface CorpusLayout {
16
+ readonly projectRoot: string;
17
+ readonly prefix: string;
18
+ readonly stateDir: string;
19
+ readonly ignoreFile: string;
20
+ }
21
+
22
+ export function corpusLayout(
23
+ projectRoot: string,
24
+ prefix: string,
25
+ dataRoot: string,
26
+ ): CorpusLayout {
27
+ return {
28
+ projectRoot,
29
+ prefix,
30
+ // `--watch` refuses a state dir inside the watched tree: journal writes are
31
+ // filesystem events and would restart the pass forever. Keep the journal
32
+ // beside the node data instead.
33
+ stateDir: join(dataRoot, "state", prefix),
34
+ ignoreFile: join(projectRoot, ".xerjignore"),
35
+ };
36
+ }
37
+
38
+ /** Secret-shaped names XERJ's own ignore rules do not cover. */
39
+ const IGNORE_DEFAULTS = [
40
+ "# Written once by the XERJ OpenCode plugin. Edit freely; it is not rewritten.",
41
+ ".env",
42
+ ".env.*",
43
+ "*.pem",
44
+ "*.key",
45
+ "**/secrets/**",
46
+ "",
47
+ ].join("\n");
48
+
49
+ export async function ensureIgnoreFile(layout: CorpusLayout): Promise<boolean> {
50
+ await mkdir(projectStateDir(layout.projectRoot), { recursive: true });
51
+ try {
52
+ await writeFile(layout.ignoreFile, IGNORE_DEFAULTS, { flag: "wx" });
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ export interface AutoindexOptions {
60
+ readonly layout: CorpusLayout;
61
+ readonly nodeUrl: string;
62
+ readonly config: Config;
63
+ readonly watch: boolean;
64
+ }
65
+
66
+ export function autoindexArgs(options: AutoindexOptions): ReadonlyArray<string> {
67
+ const { layout, nodeUrl, config, watch } = options;
68
+ const args = [
69
+ "autoindex",
70
+ layout.projectRoot,
71
+ "--url",
72
+ nodeUrl,
73
+ "--prefix",
74
+ layout.prefix,
75
+ "--state-dir",
76
+ layout.stateDir,
77
+ "--no-graph",
78
+ "--json",
79
+ "--progress",
80
+ "none",
81
+ ];
82
+
83
+ if (watch) {
84
+ args.push("--watch", "--debounce", String(config.index.debounceMs));
85
+ }
86
+
87
+ if (config.index.workers !== null) {
88
+ args.push("--workers", String(config.index.workers));
89
+ }
90
+
91
+ if (!config.index.semantic) {
92
+ args.push("--no-semantic");
93
+ }
94
+
95
+ if (config.index.maxBytes > 0) {
96
+ args.push("--max-index-size", String(config.index.maxBytes));
97
+ }
98
+
99
+ args.push("--max-minutes", String(config.index.maxMinutes));
100
+ return args;
101
+ }
102
+
103
+ export interface CorpusRun {
104
+ readonly at: number;
105
+ readonly generation: number | null;
106
+ readonly files: number | null;
107
+ readonly records: number | null;
108
+ readonly junk: number | null;
109
+ readonly reason: string | null;
110
+ }
111
+
112
+ /** Parses one line of `autoindex --json` output. Returns null for anything else. */
113
+ export function parseRunDocument(line: string): CorpusRun | null {
114
+ const trimmed = line.trim();
115
+ if (!trimmed.startsWith("{")) return null;
116
+
117
+ let value: unknown;
118
+ try {
119
+ value = JSON.parse(trimmed);
120
+ } catch {
121
+ return null;
122
+ }
123
+
124
+ if (typeof value !== "object" || value === null) return null;
125
+ const record = value as Record<string, unknown>;
126
+
127
+ // Watch mode emits a run document per pass; decision requests carry a
128
+ // different shape and are handled by the caller.
129
+ if (record.doc_kind !== "run") return null;
130
+
131
+ const started = typeof record.started === "string" ? Date.parse(record.started) : Number.NaN;
132
+
133
+ return {
134
+ at: Number.isFinite(started) ? started : Date.now(),
135
+ generation: numberOrNull(record.generation),
136
+ files: numberOrNull(record.files_indexed ?? record.files_total),
137
+ records: numberOrNull(record.records_total),
138
+ junk: numberOrNull(record.junk_records_total),
139
+ reason: stringOrNull(record.reason),
140
+ };
141
+ }
142
+
143
+ export type ExitMeaning = "ok" | "junk" | "decision" | "usage" | "error";
144
+
145
+ export function exitMeaning(code: number | null): ExitMeaning {
146
+ if (code === 0) return "ok";
147
+ if (code === 3) return "junk";
148
+ if (code === 4) return "decision";
149
+ if (code === 2) return "usage";
150
+ return "error";
151
+ }
152
+
153
+ function numberOrNull(value: unknown): number | null {
154
+ if (typeof value === "number" && Number.isFinite(value)) return value;
155
+ if (typeof value === "string") {
156
+ const parsed = Number(value);
157
+ return Number.isFinite(parsed) ? parsed : null;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ function stringOrNull(value: unknown): string | null {
163
+ return typeof value === "string" ? value : null;
164
+ }
@@ -0,0 +1,318 @@
1
+ // Hidden documents: autoindex skips hidden files and directories, so text under
2
+ // `.agents`, `.github`, or any other dot-directory never reaches the project
3
+ // corpus. This scanner walks configured folders and indexes text through
4
+ // `_bulk` into `<prefix>-hidden-docs`.
5
+ //
6
+ // The index is separate from the autoindex corpus so deletions can be
7
+ // reconciled by id. It is still matched by the project prefix, so search and
8
+ // citations use the same paths relative to the project root.
9
+
10
+ import { createHash } from "node:crypto";
11
+ import { watch, type FSWatcher } from "node:fs";
12
+ import { readdir, readFile, stat } from "node:fs/promises";
13
+ import { basename, join, relative, resolve, sep } from "node:path";
14
+ import type { Logger } from "../log.ts";
15
+ import { NodeRequestError, type XerjClient } from "../node/client.ts";
16
+ import type { DocumentRoot } from "../paths.ts";
17
+ import { isSecretPath, isTextPath } from "../remote/spool.ts";
18
+
19
+ export interface HiddenIndexOptions {
20
+ readonly client: XerjClient;
21
+ readonly projectRoot: string;
22
+ readonly prefix: string;
23
+ readonly roots: ReadonlyArray<DocumentRoot>;
24
+ readonly maxBytes: number;
25
+ readonly logger: Logger;
26
+ readonly onChange?: () => void;
27
+ readonly onRun?: () => void;
28
+ }
29
+
30
+ export interface HiddenSnapshot {
31
+ readonly docs: number;
32
+ readonly lastRunAt: number | null;
33
+ readonly error: string | null;
34
+ }
35
+
36
+ interface HiddenFile {
37
+ readonly id: string;
38
+ readonly path: string;
39
+ readonly absolute: string;
40
+ readonly body: string;
41
+ readonly mtime: number;
42
+ }
43
+
44
+ const SKIP_DIRS = new Set([
45
+ ".git",
46
+ ".xerj",
47
+ "node_modules",
48
+ "dist",
49
+ "target",
50
+ "vendor",
51
+ "coverage",
52
+ ".venv",
53
+ "venv",
54
+ "__pycache__",
55
+ ".cache",
56
+ ".next",
57
+ ".turbo",
58
+ ".idea",
59
+ "secrets",
60
+ ]);
61
+
62
+ const MAX_FILES = 2_000;
63
+ const MAX_INDEXED_DOCS = 5_000;
64
+ const BATCH_SIZE = 50;
65
+ const DEBOUNCE_MS = 1_500;
66
+ const FALLBACK_POLL_MS = 30_000;
67
+
68
+ export class HiddenIndex {
69
+ readonly index: string;
70
+
71
+ private readonly watchers: FSWatcher[] = [];
72
+ private interval: ReturnType<typeof setInterval> | null = null;
73
+ private timer: ReturnType<typeof setTimeout> | null = null;
74
+ private running = false;
75
+ private queued = false;
76
+ private mapped = false;
77
+ private state: HiddenSnapshot = { docs: 0, lastRunAt: null, error: null };
78
+
79
+ constructor(private readonly options: HiddenIndexOptions) {
80
+ this.index = `${options.prefix}-hidden-docs`;
81
+ }
82
+
83
+ snapshot(): HiddenSnapshot {
84
+ return this.state;
85
+ }
86
+
87
+ async start(): Promise<void> {
88
+ if (this.options.roots.length === 0) return;
89
+
90
+ await this.run();
91
+ this.watchRoots();
92
+ }
93
+
94
+ async stop(): Promise<void> {
95
+ for (const watcher of this.watchers) watcher.close();
96
+ this.watchers.length = 0;
97
+
98
+ if (this.interval) clearInterval(this.interval);
99
+ if (this.timer) clearTimeout(this.timer);
100
+ this.interval = null;
101
+ this.timer = null;
102
+ }
103
+
104
+ async reindex(): Promise<void> {
105
+ await this.run();
106
+ }
107
+
108
+ private watchRoots(): void {
109
+ for (const root of this.options.roots) {
110
+ try {
111
+ const watcher = watch(root.directory, { recursive: true }, () =>
112
+ this.schedule(),
113
+ );
114
+ watcher.on("error", (error) => {
115
+ this.options.logger.debug(
116
+ `hidden watch failed for ${root.label}; polling instead`,
117
+ String(error),
118
+ );
119
+ this.useFallback();
120
+ });
121
+ this.watchers.push(watcher);
122
+ } catch (error) {
123
+ this.options.logger.debug(
124
+ `hidden watch unavailable for ${root.label}; polling instead`,
125
+ String(error),
126
+ );
127
+ this.useFallback();
128
+ }
129
+ }
130
+ }
131
+
132
+ private useFallback(): void {
133
+ if (this.interval) return;
134
+ this.interval = setInterval(() => void this.run(), FALLBACK_POLL_MS);
135
+ }
136
+
137
+ private schedule(): void {
138
+ if (this.timer) return;
139
+ this.timer = setTimeout(() => {
140
+ this.timer = null;
141
+ void this.run();
142
+ }, DEBOUNCE_MS);
143
+ }
144
+
145
+ private async run(): Promise<void> {
146
+ if (this.running) {
147
+ this.queued = true;
148
+ return;
149
+ }
150
+
151
+ this.running = true;
152
+ try {
153
+ await this.ensureMapping();
154
+ const files = await this.scan();
155
+ await this.upload(files);
156
+ await this.removeStale(new Set(files.map((file) => file.id)));
157
+
158
+ this.state = { docs: files.length, lastRunAt: Date.now(), error: null };
159
+ this.options.onRun?.();
160
+ this.options.onChange?.();
161
+ } catch (error) {
162
+ const message = error instanceof Error ? error.message : String(error);
163
+ this.state = { ...this.state, lastRunAt: Date.now(), error: message };
164
+ this.options.logger.warn("hidden document indexing failed", message);
165
+ } finally {
166
+ this.running = false;
167
+ if (this.queued) {
168
+ this.queued = false;
169
+ void this.run();
170
+ }
171
+ }
172
+ }
173
+
174
+ private async ensureMapping(): Promise<void> {
175
+ if (this.mapped) return;
176
+
177
+ try {
178
+ await this.options.client.put(
179
+ `/${this.index}`,
180
+ {
181
+ mappings: {
182
+ properties: {
183
+ body: { type: "semantic_text" },
184
+ title: { type: "keyword" },
185
+ path: { type: "keyword" },
186
+ abs_path: { type: "keyword" },
187
+ mtime: { type: "date" },
188
+ hidden: { type: "boolean" },
189
+ },
190
+ },
191
+ },
192
+ "could not create the hidden index",
193
+ );
194
+ } catch (error) {
195
+ // The index survives restarts; 400/409 mean it already exists.
196
+ if (
197
+ !(error instanceof NodeRequestError) ||
198
+ (error.status !== 400 && error.status !== 409)
199
+ ) {
200
+ throw error;
201
+ }
202
+ }
203
+
204
+ this.mapped = true;
205
+ }
206
+
207
+ private async scan(): Promise<HiddenFile[]> {
208
+ const files: HiddenFile[] = [];
209
+ const root = resolve(this.options.projectRoot);
210
+
211
+ for (const documentRoot of this.options.roots) {
212
+ await this.walk(documentRoot.directory, root, files);
213
+ if (files.length >= MAX_FILES) {
214
+ this.options.logger.warn(
215
+ `hidden indexing stopped at ${MAX_FILES} files; ` +
216
+ "raise the limit only if the tree is small",
217
+ );
218
+ break;
219
+ }
220
+ }
221
+
222
+ return files;
223
+ }
224
+
225
+ private async walk(
226
+ directory: string,
227
+ projectRoot: string,
228
+ files: HiddenFile[],
229
+ ): Promise<void> {
230
+ const entries = await readdir(directory, { withFileTypes: true }).catch(
231
+ () => [],
232
+ );
233
+
234
+ for (const entry of entries) {
235
+ if (files.length >= MAX_FILES) return;
236
+
237
+ const absolute = join(directory, entry.name);
238
+
239
+ if (entry.isDirectory()) {
240
+ if (SKIP_DIRS.has(entry.name)) continue;
241
+ await this.walk(absolute, projectRoot, files);
242
+ continue;
243
+ }
244
+
245
+ if (!entry.isFile() || !isTextPath(entry.name) || isSecretPath(entry.name)) {
246
+ continue;
247
+ }
248
+
249
+ const info = await stat(absolute).catch(() => null);
250
+ if (!info || info.size === 0 || info.size > this.options.maxBytes) {
251
+ continue;
252
+ }
253
+
254
+ const raw = await readFile(absolute, "utf8").catch(() => null);
255
+ if (raw === null || raw.includes("\u0000")) continue;
256
+
257
+ const path = relative(projectRoot, absolute).split(sep).join("/");
258
+ files.push({
259
+ id: createHash("sha256").update(path).digest("hex").slice(0, 24),
260
+ path,
261
+ absolute,
262
+ body: raw,
263
+ mtime: info.mtimeMs,
264
+ });
265
+ }
266
+ }
267
+
268
+ private async upload(files: ReadonlyArray<HiddenFile>): Promise<void> {
269
+ for (let index = 0; index < files.length; index += BATCH_SIZE) {
270
+ const batch = files.slice(index, index + BATCH_SIZE);
271
+ await this.options.client.bulk(
272
+ this.index,
273
+ batch.map((file) => ({
274
+ id: file.id,
275
+ source: {
276
+ body: file.body,
277
+ title: basename(file.path),
278
+ path: file.path,
279
+ abs_path: file.absolute,
280
+ mtime: new Date(file.mtime).toISOString(),
281
+ hidden: true,
282
+ },
283
+ })),
284
+ );
285
+ }
286
+ }
287
+
288
+ private async removeStale(current: ReadonlySet<string>): Promise<void> {
289
+ const existing = await this.existingIds();
290
+ const stale = existing.filter((id) => !current.has(id));
291
+ if (stale.length > 0) await this.options.client.bulkDelete(this.index, stale);
292
+ }
293
+
294
+ private async existingIds(): Promise<string[]> {
295
+ try {
296
+ const response = await this.options.client.search(this.index, {
297
+ size: MAX_INDEXED_DOCS,
298
+ _source: ["path"],
299
+ query: { match_all: {} },
300
+ });
301
+
302
+ if (!isRecord(response)) return [];
303
+ const hits = response.hits;
304
+ if (!isRecord(hits) || !Array.isArray(hits.hits)) return [];
305
+
306
+ return hits.hits.flatMap((hit): string[] =>
307
+ isRecord(hit) && typeof hit._id === "string" ? [hit._id] : [],
308
+ );
309
+ } catch (error) {
310
+ if (error instanceof NodeRequestError && error.status === 404) return [];
311
+ throw error;
312
+ }
313
+ }
314
+ }
315
+
316
+ function isRecord(value: unknown): value is Record<string, unknown> {
317
+ return typeof value === "object" && value !== null && !Array.isArray(value);
318
+ }