@functional-systems/srcx 0.1.0-dev.7 → 0.1.0-dev.9

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/src/cli.ts CHANGED
@@ -1,12 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
- import { Command, InvalidArgumentError } from "commander";
3
+ import { Command, InvalidArgumentError, Option } from "commander";
4
4
  import { mkdir } from "node:fs/promises";
5
5
  import { join, resolve } from "node:path";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { configure, loadSettings, stateRoot } from "./settings.js";
8
8
  import { identity, resolveCommit } from "./git.js";
9
- import { loadBuild, materialize, validateBuild, type Build } from "./build.js";
9
+ import {
10
+ loadBuild,
11
+ materialize,
12
+ validateBuild,
13
+ type Build,
14
+ type Preset,
15
+ PRESET,
16
+ presetFor,
17
+ } from "./build.js";
10
18
  import { invariant, optionalJson } from "./common.js";
11
19
  import { LambdaRemote } from "./remote.js";
12
20
  import {
@@ -87,6 +95,14 @@ repo
87
95
  .option("--remote <name>")
88
96
  .option("--description <text>")
89
97
  .option("--tag <key=value>", "One optional Collection metadata tag")
98
+ .addOption(
99
+ new Option(
100
+ "--embedding <model>",
101
+ "Managed model (source text is sent to the provider on import)",
102
+ )
103
+ .choices(["none", "text-embedding-3-small", "text-embedding-3-large"])
104
+ .default("none"),
105
+ )
90
106
  .action(async (o) => {
91
107
  const { remote } = await connected();
92
108
  let labels: Record<string, string> | undefined;
@@ -101,6 +117,7 @@ repo
101
117
  remote: o.remote,
102
118
  description: o.description,
103
119
  labels,
120
+ preset: presetFor(o.embedding),
104
121
  }),
105
122
  );
106
123
  });
@@ -128,6 +145,12 @@ cli
128
145
  .option("--repo <name>", "Registered remote repository")
129
146
  .option("--ref <ref>", "Local branch, Git tag, or commit OID")
130
147
  .option("--dry-run", "Only materialize and validate a local build")
148
+ .addOption(
149
+ new Option(
150
+ "--embedding <model>",
151
+ "Preset for --dry-run --path; connected imports use the repository preset",
152
+ ).choices(["none", "text-embedding-3-small", "text-embedding-3-large"]),
153
+ )
131
154
  .option("--output <directory>", "New artifact directory")
132
155
  .option(
133
156
  "--previous <directory>",
@@ -140,6 +163,10 @@ cli
140
163
  )
141
164
  .option("--timeout <seconds>", "Validation wait deadline", integer, 120)
142
165
  .action(async (o) => {
166
+ invariant(
167
+ !o.embedding || (o.dryRun && o.path),
168
+ "--embedding is only accepted with --dry-run --path; connected imports use the repository preset.",
169
+ );
143
170
  invariant(!(o.path && o.repo), "Choose --path or --repo.");
144
171
  invariant(
145
172
  !o.previous || o.dryRun,
@@ -154,6 +181,7 @@ cli
154
181
  source: Awaited<ReturnType<typeof identity>>,
155
182
  previous?: Build,
156
183
  state?: string,
184
+ preset: Preset = PRESET,
157
185
  ) => {
158
186
  invariant(o.ref, "--ref is required to build a commit.");
159
187
  const commit = await resolveCommit(source.path, o.ref);
@@ -178,6 +206,7 @@ cli
178
206
  resolvedCommit: commit,
179
207
  output: path,
180
208
  previous,
209
+ preset,
181
210
  });
182
211
  };
183
212
  if (o.dryRun) {
@@ -187,18 +216,20 @@ cli
187
216
  await validateBuild(b);
188
217
  } else {
189
218
  let source;
219
+ let preset = presetFor(o.embedding);
190
220
  if (o.path) source = await identity(o.path, o.remote);
191
221
  else {
192
222
  invariant(o.repo, "Use --path for a credential-free dry run.");
193
223
  const { remote, settings } = await connected();
194
- source = await attachment(
195
- settings,
196
- await selectRepository(remote, o.repo),
197
- );
224
+ const selected = await selectRepository(remote, o.repo);
225
+ preset = selected.preset ?? PRESET;
226
+ source = await attachment(settings, selected);
198
227
  }
199
228
  b = await buildLocal(
200
229
  source,
201
230
  o.previous ? await loadBuild(o.previous) : undefined,
231
+ undefined,
232
+ preset,
202
233
  );
203
234
  }
204
235
  output({
@@ -239,6 +270,7 @@ cli
239
270
  await attachment(settings, r),
240
271
  undefined,
241
272
  pending ? undefined : state,
273
+ r.preset ?? PRESET,
242
274
  );
243
275
  let baseline: { version: Published; build: Build } | undefined;
244
276
  // Tracked branches discover their own immutable baseline remotely.
@@ -295,6 +327,14 @@ cli
295
327
  .requiredOption("--version <commit-branch-or-release>")
296
328
  .requiredOption("--query <text>")
297
329
  .option("--limit <count>", "Maximum hits", integer, 10)
330
+ .addOption(
331
+ new Option(
332
+ "--mode <mode>",
333
+ "Retrieval method; semantic/hybrid incur managed query embedding usage",
334
+ )
335
+ .choices(["lexical", "semantic", "hybrid"])
336
+ .default("lexical"),
337
+ )
298
338
  .option("--path <path>", "Exact path filter")
299
339
  .option("--language <name>")
300
340
  .action(async (o) => {
@@ -303,10 +343,19 @@ cli
303
343
  store = remote.store(r.collection),
304
344
  v = await resolveVersion(store, r, o.version);
305
345
  output(
306
- await search(store, settings, r, v, o.query, o.limit, {
307
- path: o.path,
308
- language: o.language,
309
- }),
346
+ await search(
347
+ store,
348
+ settings,
349
+ r,
350
+ v,
351
+ o.query,
352
+ o.limit,
353
+ {
354
+ path: o.path,
355
+ language: o.language,
356
+ },
357
+ o.mode,
358
+ ),
310
359
  );
311
360
  });
312
361
  cli
package/src/publish.ts CHANGED
@@ -10,7 +10,14 @@ import {
10
10
  sleep,
11
11
  type Doc,
12
12
  } from "./common.js";
13
- import { corpusMetadata, records, validateBuild, type Build } from "./build.js";
13
+ import {
14
+ corpusMetadata,
15
+ records,
16
+ recordHash,
17
+ supportedPreset,
18
+ validateBuild,
19
+ type Build,
20
+ } from "./build.js";
14
21
  import { branch, tag, one, type CollectionStore } from "./remote.js";
15
22
  export type Binding = {
16
23
  repoId: string;
@@ -154,7 +161,11 @@ export async function validateCandidate(
154
161
  for (const d of corpusMetadata(b, binding.repoId, binding.indexId, attemptId))
155
162
  expected.set(d.id, hash(d));
156
163
  for await (const d of store.list(tag(name))) {
157
- if (expected.get(d.id) !== hash(d)) return false;
164
+ try {
165
+ if (expected.get(d.id) !== recordHash(d, b.preset)) return false;
166
+ } catch {
167
+ return false;
168
+ }
158
169
  expected.delete(d.id);
159
170
  }
160
171
  if (expected.size) return false;
@@ -203,7 +214,8 @@ export async function validateCandidate(
203
214
  !found.length ||
204
215
  found.some(
205
216
  (h) =>
206
- h.doc.kind !== "chunk" || b.recordHashes[h.doc.id] !== hash(h.doc),
217
+ h.doc.kind !== "chunk" ||
218
+ b.recordHashes[h.doc.id] !== recordHash(h.doc, b.preset),
207
219
  )
208
220
  )
209
221
  return false;
@@ -284,8 +296,8 @@ export async function publish(args: {
284
296
  "Build does not belong to this index.",
285
297
  );
286
298
  invariant(
287
- !b.preset.embedding,
288
- "CLI publication currently supports only the embedding=none preset.",
299
+ !b.preset.embedding || supportedPreset(b.preset),
300
+ "Publication requires a supported lexical or managed embedding preset.",
289
301
  );
290
302
  await validateBuild(b);
291
303
  const journalPath = join(state, "pending.json");
@@ -522,7 +534,7 @@ export async function publish(args: {
522
534
  if (j.baseline)
523
535
  for await (const d of store.list(tag(j.baseline.tagName))) {
524
536
  oldIds.add(d.id);
525
- oldHashes.set(d.id, hash(d));
537
+ oldHashes.set(d.id, recordHash(d, b.preset));
526
538
  }
527
539
  const newIds = new Set([
528
540
  ...Object.keys(b.recordHashes),
package/src/remote.ts CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  tagTarget,
8
8
  type IndexConfigsUnion,
9
9
  } from "@functional-systems/lambdadb";
10
- import { invariant, type Doc } from "./common.js";
10
+ import { hash, invariant, type Doc } from "./common.js";
11
11
  import type { Settings } from "./settings.js";
12
12
  export type Ref = { kind: "branch" | "tag"; name: string };
13
13
  export type Snapshot = { name: string; snapshotId: string };
@@ -96,6 +96,43 @@ export class LambdaRemote {
96
96
  const c = this.client.collection(name);
97
97
  const ref = (r: Ref) =>
98
98
  r.kind === "tag" ? tagRef(r.name) : branchRef(r.name);
99
+ // Some deployments omit managed vectors from list/query responses despite
100
+ // includeVectors. Keep the same immutable Tag and exact non-vector payload.
101
+ const hydrate = async (r: Ref, docs: Doc[]): Promise<Doc[]> => {
102
+ const missing = docs.filter(
103
+ (d) => d.embeddingStatus === "managed" && d.embedding === undefined,
104
+ );
105
+ const hydrated = new Map<string, Doc>();
106
+ if (missing.length) {
107
+ invariant(
108
+ r.kind === "tag",
109
+ "Managed vector hydration requires an immutable Tag.",
110
+ );
111
+ const fetched = await request("fetch omitted vectors", () =>
112
+ c.docs.fetch({
113
+ ref: tagRef(r.name),
114
+ ids: missing.map((d) => d.id),
115
+ consistentRead: false,
116
+ includeVectors: true,
117
+ }),
118
+ );
119
+ const expected = new Map(missing.map((d) => [d.id, hash(d)]));
120
+ for (const item of fetched.docs) {
121
+ const doc = item.doc as Doc;
122
+ const { embedding: _, ...source } = doc;
123
+ invariant(
124
+ !hydrated.has(doc.id) && expected.get(doc.id) === hash(source),
125
+ "Returned/fetched managed payloads disagree.",
126
+ );
127
+ hydrated.set(doc.id, doc);
128
+ }
129
+ invariant(
130
+ hydrated.size === expected.size,
131
+ "Returned managed records are missing from fetch.",
132
+ );
133
+ }
134
+ return docs.map((doc) => hydrated.get(doc.id) ?? doc);
135
+ };
99
136
  return {
100
137
  fetch: (r, ids, consistent = false) =>
101
138
  request("fetch", async () => {
@@ -132,7 +169,11 @@ export class LambdaRemote {
132
169
  pageToken: cursor,
133
170
  }),
134
171
  );
135
- for (const d of page.docs) yield d.doc as Doc;
172
+ for (const doc of await hydrate(
173
+ r,
174
+ page.docs.map((d) => d.doc as Doc),
175
+ ))
176
+ yield doc;
136
177
  cursor = page.nextPageToken || undefined;
137
178
  if (cursor) {
138
179
  invariant(
@@ -199,17 +240,22 @@ export class LambdaRemote {
199
240
  : c.aliases.create({ aliasName: name, target: tagTarget(target) }),
200
241
  );
201
242
  },
202
- query: (tag, query, size) =>
203
- request("query", async () =>
204
- (
205
- await c.query({
206
- ref: tagRef(tag),
207
- consistentRead: false,
208
- query,
209
- size,
210
- })
211
- ).docs.map((h) => ({ doc: h.doc as Doc, score: h.score })),
212
- ),
243
+ query: async (name, query, size) => {
244
+ const result = await request("query", () =>
245
+ c.query({
246
+ ref: tagRef(name),
247
+ consistentRead: false,
248
+ query,
249
+ size,
250
+ includeVectors: true,
251
+ }),
252
+ );
253
+ const docs = await hydrate(
254
+ { kind: "tag", name },
255
+ result.docs.map((h) => h.doc as Doc),
256
+ );
257
+ return result.docs.map((h, i) => ({ doc: docs[i]!, score: h.score }));
258
+ },
213
259
  };
214
260
  }
215
261
  }
package/src/repository.ts CHANGED
@@ -8,13 +8,14 @@ import {
8
8
  PURPOSE,
9
9
  type Doc,
10
10
  } from "./common.js";
11
- import { PRESET, INDEX_CONFIGS } from "./build.js";
11
+ import { PRESET, indexConfigs, supportedPreset, type Preset } from "./build.js";
12
12
  import { identity, type Identity } from "./git.js";
13
13
  import { LambdaRemote, one, branch } from "./remote.js";
14
14
  import { exclusive, type Binding } from "./publish.js";
15
15
  import { stateRoot, type Settings } from "./settings.js";
16
16
  import type { IndexConfigsUnion } from "@functional-systems/lambdadb";
17
17
  export type Repository = Binding & {
18
+ preset?: Preset;
18
19
  collection: string;
19
20
  name: string;
20
21
  description: string;
@@ -42,12 +43,15 @@ export function collectionName(
42
43
  return `code-${slug}-${hash(["collection-v1", key, configHash]).slice(0, 16)}`;
43
44
  }
44
45
  /** LambdaDB adds its built-in keyword id index to collection metadata. */
45
- export function matchesIndexSchema(actual: unknown): boolean {
46
+ export function matchesIndexSchema(
47
+ actual: unknown,
48
+ preset: Preset = PRESET,
49
+ ): boolean {
46
50
  if (!actual || typeof actual !== "object" || Array.isArray(actual))
47
51
  return false;
48
52
  const { id, ...fields } = actual as Record<string, unknown>;
49
53
  if (id !== undefined && hash(id) !== hash({ type: "keyword" })) return false;
50
- return hash(fields) === hash(INDEX_CONFIGS);
54
+ return hash(fields) === hash(indexConfigs(preset));
51
55
  }
52
56
  function descriptor(r: Repository): Doc {
53
57
  return {
@@ -60,7 +64,7 @@ function descriptor(r: Repository): Doc {
60
64
  repoId: r.repoId,
61
65
  indexId: r.indexId,
62
66
  name: r.name,
63
- preset: PRESET,
67
+ preset: r.preset ?? PRESET,
64
68
  initialization: "ready",
65
69
  };
66
70
  }
@@ -76,8 +80,8 @@ function decode(
76
80
  invariant(
77
81
  doc?.role === "repository" &&
78
82
  doc.initialization === "ready" &&
79
- doc.configHash === hash(PRESET) &&
80
- hash(doc.preset) === hash(PRESET),
83
+ supportedPreset(doc.preset) &&
84
+ doc.configHash === hash(doc.preset),
81
85
  "Repository is partially initialized or uses an unsupported preset.",
82
86
  );
83
87
  invariant(
@@ -87,7 +91,7 @@ function decode(
87
91
  "Repository descriptor and Collection labels disagree.",
88
92
  );
89
93
  invariant(
90
- matchesIndexSchema(c.indexConfigs),
94
+ matchesIndexSchema(c.indexConfigs, doc.preset as Preset),
91
95
  "Collection schema differs from the pinned preset.",
92
96
  );
93
97
  invariant(
@@ -98,6 +102,7 @@ function decode(
98
102
  "Malformed repository descriptor.",
99
103
  );
100
104
  return {
105
+ preset: doc.preset as Preset,
101
106
  collection: c.collectionName,
102
107
  repoKey: doc.repoKey,
103
108
  repoId: doc.repoId,
@@ -148,7 +153,7 @@ export async function selectRepository(
148
153
  invariant(
149
154
  selected.length === 1,
150
155
  selected.length
151
- ? "Ambiguous repository; use its full source identity or Collection name."
156
+ ? "Ambiguous repository; use its exact Collection name (presets have separate Collections)."
152
157
  : `Repository not found${partial.length ? "; partially initialized collections exist (see repo list)" : ""}.`,
153
158
  );
154
159
  return selected[0]!;
@@ -160,10 +165,13 @@ export async function register(
160
165
  remote?: string;
161
166
  description?: string;
162
167
  labels?: Record<string, string>;
168
+ preset?: Preset;
163
169
  },
164
170
  ): Promise<Repository> {
165
171
  const source = await identity(args.path, args.remote);
166
- const configHash = hash(PRESET),
172
+ const preset = args.preset ?? PRESET;
173
+ invariant(supportedPreset(preset), "Unsupported repository preset.");
174
+ const configHash = hash(preset),
167
175
  collection = collectionName(source.key, source.name, configHash),
168
176
  state = destination(remote.settings, collection);
169
177
  return exclusive(state, async () => {
@@ -218,6 +226,7 @@ export async function register(
218
226
  );
219
227
  const indexId = randomUUID();
220
228
  intent = {
229
+ preset,
221
230
  repoKey: source.key,
222
231
  name: source.name,
223
232
  repoId: randomUUID(),
@@ -244,13 +253,13 @@ export async function register(
244
253
  if (existing)
245
254
  invariant(
246
255
  hash(existing.tags) === hash(intent.tags) &&
247
- matchesIndexSchema(existing.indexConfigs),
256
+ matchesIndexSchema(existing.indexConfigs, preset),
248
257
  "Existing Collection differs from provisioning journal.",
249
258
  );
250
259
  else
251
260
  await remote.create(
252
261
  collection,
253
- JSON.parse(JSON.stringify(INDEX_CONFIGS)) as Record<
262
+ JSON.parse(JSON.stringify(indexConfigs(preset))) as Record<
254
263
  string,
255
264
  IndexConfigsUnion
256
265
  >,
package/src/search.ts CHANGED
@@ -5,7 +5,7 @@ import { one, tag, type CollectionStore } from "./remote.js";
5
5
  import { stateRoot, type Settings } from "./settings.js";
6
6
  import type { Repository } from "./repository.js";
7
7
  import type { Published } from "./publish.js";
8
- import type { InventoryItem } from "./build.js";
8
+ import { PRESET, recordHash, type InventoryItem } from "./build.js";
9
9
  export type Handle = {
10
10
  id: string;
11
11
  endpoint: string;
@@ -84,6 +84,33 @@ export function lexicalQuery(
84
84
  ],
85
85
  };
86
86
  }
87
+ export type SearchMode = "lexical" | "semantic" | "hybrid";
88
+ export function retrievalQuery(
89
+ query: string,
90
+ size: number,
91
+ filters: { path?: string; language?: string },
92
+ mode: SearchMode,
93
+ ): Record<string, unknown> {
94
+ invariant(
95
+ ["lexical", "semantic", "hybrid"].includes(mode),
96
+ "Unknown search mode.",
97
+ );
98
+ const lexical = lexicalQuery(query, filters);
99
+ if (mode === "lexical") return lexical;
100
+ const semantic = {
101
+ knn: {
102
+ field: "embedding",
103
+ queryText: query,
104
+ k: size,
105
+ filter: {
106
+ bool: (lexical.bool as { occur: string }[]).filter(
107
+ (c) => c.occur === "filter",
108
+ ),
109
+ },
110
+ },
111
+ };
112
+ return mode === "semantic" ? semantic : { rrf: [lexical, semantic] };
113
+ }
87
114
  export async function search(
88
115
  store: CollectionStore,
89
116
  s: Settings,
@@ -92,21 +119,23 @@ export async function search(
92
119
  query: string,
93
120
  size = 10,
94
121
  filters: { path?: string; language?: string } = {},
122
+ mode: SearchMode = "lexical",
95
123
  ): Promise<unknown[]> {
96
124
  invariant(
97
125
  Number.isInteger(size) && size > 0 && size <= 100,
98
126
  "Limit must be an integer from 1 to 100.",
99
127
  );
128
+ invariant(
129
+ mode === "lexical" || r.preset?.embedding?.managed,
130
+ "Semantic/hybrid search requires a managed embedding Collection; register with --embedding text-embedding-3-small or text-embedding-3-large.",
131
+ );
132
+ const request = retrievalQuery(query, size, filters, mode);
100
133
  const entries = await inventoryAt(store, r, v);
101
134
  const files = new Map(
102
135
  entries.filter((e) => e.status === "included").map((e) => [e.fileId, e]),
103
136
  );
104
137
  const result = [];
105
- for (const hit of await store.query(
106
- v.tagName,
107
- lexicalQuery(query, filters),
108
- size,
109
- )) {
138
+ for (const hit of await store.query(v.tagName, request, size)) {
110
139
  const d = hit.doc,
111
140
  e = files.get(d.fileId as string);
112
141
  invariant(
@@ -128,7 +157,7 @@ export async function search(
128
157
  path: String(d.path),
129
158
  contentHash: String(d.contentHash),
130
159
  chunkId: d.id,
131
- chunkHash: hash(d),
160
+ chunkHash: recordHash(d, r.preset ?? PRESET),
132
161
  startLine: Number(d.startLine),
133
162
  endLine: Number(d.endLine),
134
163
  };
@@ -222,7 +251,7 @@ export async function readHandle(
222
251
  const d = await one(store, tag(v.tagName), h.chunkId);
223
252
  invariant(
224
253
  d &&
225
- hash(d) === h.chunkHash &&
254
+ recordHash(d, h.repository.preset ?? PRESET) === h.chunkHash &&
226
255
  d.fileId === h.fileId &&
227
256
  d.contentHash === h.contentHash,
228
257
  "Pinned chunk has changed or is missing.",