scream-code 0.18.7 → 0.18.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.
@@ -4,14 +4,14 @@ import { dirname as __cjsShimDirname } from 'node:path';
4
4
  const __filename = __cjsShimFileURLToPath(import.meta.url);
5
5
  const __dirname = __cjsShimDirname(__filename);
6
6
  import { createHash, randomBytes } from "node:crypto";
7
- import ro, { mkdir, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
7
+ import ro, { mkdir, open, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
8
8
  import I from "fs";
9
9
  import Qr, { EventEmitter } from "events";
10
10
  import js, { dirname, parse } from "path";
11
11
  import { Buffer as Buffer$1 } from "buffer";
12
12
  import Cs from "node:stream";
13
- import Kt, { createReadStream, existsSync, mkdirSync, rmSync, statSync } from "node:fs";
14
- import path, { basename, join, posix, win32 } from "node:path";
13
+ import Kt, { createReadStream, existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
14
+ import path, { basename, join, posix, resolve, win32 } from "node:path";
15
15
  import { DatabaseSync } from "node:sqlite";
16
16
  import { EventEmitter as EventEmitter$1 } from "node:events";
17
17
  import { StringDecoder } from "node:string_decoder";
@@ -218,7 +218,7 @@ function computeRelevanceScore(memo, query, usageCount = 0, currentProjectDir, p
218
218
  const ti = intent?.temporalBias ?? .3;
219
219
  const fi = intent?.factualBias ?? .6;
220
220
  const factors = {
221
- keywordOverlap: computeKeywordSimilarity(memo, query),
221
+ keywordOverlap: computeKeywordOverlap(memo, query),
222
222
  recency: computeRecency(memo.recordedAt),
223
223
  usageBoost: Math.min(.3, usageCount * .1),
224
224
  projectBoost: computeProjectBoost(memo.projectDir, currentProjectDir),
@@ -416,7 +416,21 @@ function extractKeywords(text) {
416
416
  return [...new Set(tokens)];
417
417
  }
418
418
  function computeKeywordSimilarity(memo, query) {
419
- const memoWords = extractKeywords(`${memo.userNeed} ${memo.approach} ${memo.whatFailed} ${memo.whatWorked}`);
419
+ return jaccardSimilarity(keywordText(memo), query);
420
+ }
421
+ /**
422
+ * Ranking variant: the distilled conclusion and the note are separate
423
+ * retrieval surfaces. Score each field on its own and keep the best match —
424
+ * a short conclusion would otherwise be diluted by the long base text.
425
+ */
426
+ function computeKeywordOverlap(memo, query) {
427
+ return Math.max(jaccardSimilarity(keywordText(memo), query), jaccardSimilarity(memo.distilled ?? "", query), jaccardSimilarity(memo.note ?? "", query));
428
+ }
429
+ function keywordText(memo) {
430
+ return `${memo.userNeed} ${memo.approach} ${memo.whatFailed} ${memo.whatWorked}`;
431
+ }
432
+ function jaccardSimilarity(memoText, query) {
433
+ const memoWords = extractKeywords(memoText);
420
434
  const queryWords = extractKeywords(query);
421
435
  if (memoWords.length === 0 || queryWords.length === 0) return 0;
422
436
  const intersection = memoWords.filter((w) => queryWords.includes(w)).length;
@@ -469,6 +483,10 @@ function createMemoryMemo(partial) {
469
483
  outcome: partial.outcome,
470
484
  whatFailed: partial.whatFailed,
471
485
  whatWorked: partial.whatWorked,
486
+ distilled: partial.distilled,
487
+ origin: partial.origin ?? "session",
488
+ distilledFrom: partial.distilledFrom,
489
+ distilledAt: partial.distilledAt,
472
490
  extractionSource: partial.extractionSource,
473
491
  recordedAt: partial.recordedAt ?? Date.now(),
474
492
  projectDir: partial.projectDir ?? "",
@@ -490,6 +508,10 @@ function toSummary(memo) {
490
508
  outcome: memo.outcome,
491
509
  whatFailed: memo.whatFailed,
492
510
  whatWorked: memo.whatWorked,
511
+ distilled: memo.distilled,
512
+ origin: memo.origin,
513
+ distilledFrom: memo.distilledFrom,
514
+ distilledAt: memo.distilledAt,
493
515
  extractionSource: memo.extractionSource,
494
516
  recordedAt: memo.recordedAt,
495
517
  projectDir: memo.projectDir,
@@ -3531,7 +3553,7 @@ function buildEmbeddingText(memo) {
3531
3553
  */
3532
3554
  const engineCache = /* @__PURE__ */ new Map();
3533
3555
  /**
3534
- * Create an embedding engine backed by fastembed.
3556
+ * Create an embedding engine backed by the in-process WASM pipeline.
3535
3557
  * Lazily loads the model on first use so startup is not blocked.
3536
3558
  * Engines are cached by cacheDir so repeated calls with the same cacheDir
3537
3559
  * return the same instance, sharing model state and download progress.
@@ -3539,21 +3561,22 @@ const engineCache = /* @__PURE__ */ new Map();
3539
3561
  * Defaults to "local_cache" (CWD-relative) if not provided — prefer
3540
3562
  * passing an explicit path so the cache doesn't duplicate across CWDs.
3541
3563
  */
3542
- function createFastEmbedEngine(cacheDir) {
3543
- const key = cacheDir ?? "";
3564
+ function createEmbeddingEngine(cacheDir) {
3565
+ const key = cacheDir === void 0 ? "" : resolve(cacheDir);
3544
3566
  const cached = engineCache.get(key);
3545
3567
  if (cached !== void 0) return cached;
3546
- const engine = createFastEmbedEngineImpl(cacheDir);
3568
+ const engine = createEmbeddingEngineImpl(cacheDir);
3547
3569
  engineCache.set(key, engine);
3548
3570
  return engine;
3549
3571
  }
3550
3572
  /** Stable identity of the model used by every engine created here. */
3551
3573
  const EMBEDDING_MODEL_NAME = "bge-small-zh-v1.5";
3552
- function createFastEmbedEngineImpl(cacheDir) {
3574
+ function createEmbeddingEngineImpl(cacheDir) {
3553
3575
  let embedder = null;
3554
3576
  let initPromise = null;
3555
3577
  let loadFailed = false;
3556
3578
  let lastError;
3579
+ let progressListener;
3557
3580
  return {
3558
3581
  modelName: EMBEDDING_MODEL_NAME,
3559
3582
  get available() {
@@ -3562,6 +3585,9 @@ function createFastEmbedEngineImpl(cacheDir) {
3562
3585
  get lastError() {
3563
3586
  return lastError;
3564
3587
  },
3588
+ setProgressListener(listener) {
3589
+ progressListener = listener;
3590
+ },
3565
3591
  async embedBatch(texts) {
3566
3592
  if (!this.available) return null;
3567
3593
  if (texts.length === 0) return [];
@@ -3598,12 +3624,12 @@ function createFastEmbedEngineImpl(cacheDir) {
3598
3624
  if (loadFailed || initPromise === null) {
3599
3625
  loadFailed = false;
3600
3626
  lastError = void 0;
3601
- initPromise = loadEmbedder(cacheDir);
3627
+ initPromise = loadEmbeddingRuntime(cacheDir, (progress) => progressListener?.(progress));
3602
3628
  }
3603
3629
  embedder = await initPromise;
3604
3630
  if (embedder === null) {
3605
3631
  initPromise = null;
3606
- lastError = "fastembed returned an empty model";
3632
+ lastError = "embedding runtime returned an empty session";
3607
3633
  return false;
3608
3634
  }
3609
3635
  loadFailed = false;
@@ -3618,42 +3644,157 @@ function createFastEmbedEngineImpl(cacheDir) {
3618
3644
  }
3619
3645
  };
3620
3646
  }
3621
- async function loadEmbedder(cacheDir) {
3622
- const { FlagEmbedding, EmbeddingModel } = await import("fastembed");
3623
- if (cacheDir !== void 0) {
3624
- mkdirSync(cacheDir, { recursive: true });
3625
- await ensureEmbeddingModelFiles(cacheDir);
3626
- }
3627
- const model = EmbeddingModel.BGESmallZH;
3628
- const initOpts = cacheDir !== void 0 ? {
3629
- model,
3630
- cacheDir
3631
- } : { model };
3632
- try {
3633
- return await FlagEmbedding.init(initOpts);
3634
- } catch (initError) {
3635
- const msg = initError instanceof Error ? initError.message : "";
3636
- if (!/Config file not found|Tokenizer file not found|Tokens map file not found/iu.test(msg)) throw initError;
3637
- await ensureFastembedModelSidecars(model, cacheDir);
3638
- return FlagEmbedding.init(initOpts);
3647
+ /** Maximum tokens per text; matches the model's position-embedding limit. */
3648
+ const MAX_SEQ_LEN = 512;
3649
+ /** Texts per inference batch: 16 × 512 × 512 floats ≈ 16MB of activations. */
3650
+ const EMBED_BATCH_SIZE = 16;
3651
+ /** Threads for the WASM backend. One keeps resolution deterministic in packaged
3652
+ * builds; `SCREAM_CODE_EMBEDDING_THREADS=4` is the supported opt-in. */
3653
+ const EMBEDDING_THREADS_ENV = "SCREAM_CODE_EMBEDDING_THREADS";
3654
+ /**
3655
+ * Token-level truncation that keeps the sentence boundary tokens.
3656
+ *
3657
+ * The tokenizer package does not apply the truncation policy itself and the
3658
+ * model package ships no truncation config, so this is the only place it can
3659
+ * happen. Keeping the head (which starts with `[CLS]`) and re-appending the
3660
+ * trailing special token (`[SEP]`) mirrors what a HuggingFace pipeline would
3661
+ * produce; slicing the tail off blindly would feed the encoder a sentence that
3662
+ * never closes and drift from the reference vectors on long inputs.
3663
+ */
3664
+ function truncateEncoded(ids, types) {
3665
+ if (ids.length <= MAX_SEQ_LEN) return {
3666
+ ids: [...ids],
3667
+ types: [...types]
3668
+ };
3669
+ const headSize = MAX_SEQ_LEN - 1;
3670
+ const head = ids.slice(0, headSize);
3671
+ const headTypes = types.slice(0, headSize);
3672
+ return {
3673
+ ids: [...head, ids.at(-1)],
3674
+ types: [...headTypes, types.at(-1)]
3675
+ };
3676
+ }
3677
+ /**
3678
+ * Builds the embedding runtime in-process on `onnxruntime-web` (WASM) and the
3679
+ * pure-JS tokenizer. No native binaries, no install scripts: the whole stack is
3680
+ * JavaScript plus one WebAssembly artifact resolved from the package.
3681
+ *
3682
+ * The numeric pipeline is load-bearing and must stay compatible with the
3683
+ * reference embeddings of this model: token ids come from `tokenizer.json`, and
3684
+ * the sentence vector is the **CLS token** hidden state (`last_hidden_state[0]`)
3685
+ * followed by L2 normalization. Mean pooling produces visibly different vectors
3686
+ * (cosine ≈ 0.9 against the reference), so pooling is not a stylistic choice.
3687
+ */
3688
+ async function createWasmRuntime(modelDir) {
3689
+ const ort = await import("onnxruntime-web");
3690
+ const { Tokenizer } = await import("@huggingface/tokenizers");
3691
+ const requestedThreads = Number(process.env[EMBEDDING_THREADS_ENV]);
3692
+ ort.env.wasm.numThreads = Number.isFinite(requestedThreads) && requestedThreads > 0 ? requestedThreads : 1;
3693
+ ort.env.logLevel = "error";
3694
+ const tokenizer = new Tokenizer(JSON.parse(readFileSync(join(modelDir, "tokenizer.json"), "utf8")), JSON.parse(readFileSync(join(modelDir, "tokenizer_config.json"), "utf8")));
3695
+ const session = await ort.InferenceSession.create(readFileSync(join(modelDir, "model_optimized.onnx")), { executionProviders: ["wasm"] });
3696
+ if (session === null || session === void 0) return null;
3697
+ return { async *embed(textStrings) {
3698
+ if (textStrings.length === 0) return;
3699
+ const encoded = textStrings.map((text, index) => {
3700
+ const encoding = tokenizer.encode(text, { return_token_type_ids: true });
3701
+ const types = encoding.token_type_ids ?? encoding.ids.map(() => 0);
3702
+ const truncated = truncateEncoded(encoding.ids, types);
3703
+ return {
3704
+ index,
3705
+ ids: truncated.ids,
3706
+ types: truncated.types
3707
+ };
3708
+ });
3709
+ const results = Array.from({ length: textStrings.length });
3710
+ const buckets = encoded.toSorted((a, b) => a.ids.length - b.ids.length);
3711
+ for (let start = 0; start < buckets.length; start += EMBED_BATCH_SIZE) {
3712
+ const group = buckets.slice(start, start + EMBED_BATCH_SIZE);
3713
+ const rows = group.length;
3714
+ const width = Math.max(...group.map((entry) => entry.ids.length));
3715
+ const ids = new BigInt64Array(rows * width);
3716
+ const mask = new BigInt64Array(rows * width);
3717
+ const types = new BigInt64Array(rows * width);
3718
+ group.forEach((entry, row) => {
3719
+ for (let column = 0; column < width; column++) {
3720
+ const offset = row * width + column;
3721
+ ids[offset] = BigInt(column < entry.ids.length ? entry.ids[column] : 0);
3722
+ mask[offset] = column < entry.ids.length ? 1n : 0n;
3723
+ types[offset] = BigInt(column < entry.types.length ? entry.types[column] : 0);
3724
+ }
3725
+ });
3726
+ const output = await session.run({
3727
+ input_ids: new ort.Tensor("int64", ids, [rows, width]),
3728
+ attention_mask: new ort.Tensor("int64", mask, [rows, width]),
3729
+ token_type_ids: new ort.Tensor("int64", types, [rows, width])
3730
+ });
3731
+ const hidden = output["last_hidden_state"] ?? output[Object.keys(output)[0]];
3732
+ const hiddenSize = hidden.dims.at(-1);
3733
+ const data = hidden.data;
3734
+ group.forEach((entry, row) => {
3735
+ const rowStart = row * width * hiddenSize;
3736
+ const vector = Array.from(data.subarray(rowStart, rowStart + hiddenSize));
3737
+ let norm = 0;
3738
+ for (const value of vector) norm += value * value;
3739
+ norm = Math.sqrt(norm) || 1;
3740
+ for (let i = 0; i < vector.length; i += 1) vector[i] = vector[i] / norm;
3741
+ results[entry.index] = vector;
3742
+ });
3743
+ }
3744
+ for (let start = 0; start < results.length; start += EMBED_BATCH_SIZE) yield results.slice(start, start + EMBED_BATCH_SIZE);
3745
+ } };
3746
+ }
3747
+ async function loadEmbeddingRuntime(cacheDir, emit) {
3748
+ if (cacheDir === void 0) {
3749
+ emit?.({ phase: "load" });
3750
+ return createWasmRuntime(join("local_cache", BGESMALLZH_CACHE_NAME));
3639
3751
  }
3752
+ mkdirSync(cacheDir, { recursive: true });
3753
+ await ensureEmbeddingModelFiles(cacheDir, {
3754
+ onProgress: (received, total) => {
3755
+ emit?.(total === void 0 ? {
3756
+ phase: "download",
3757
+ received
3758
+ } : {
3759
+ phase: "download",
3760
+ received,
3761
+ total
3762
+ });
3763
+ },
3764
+ onPhase: (phase) => {
3765
+ if (phase === "verify") emit?.({ phase: "verify" });
3766
+ }
3767
+ });
3768
+ emit?.({ phase: "load" });
3769
+ return createWasmRuntime(join(cacheDir, BGESMALLZH_CACHE_NAME));
3640
3770
  }
3641
3771
  /**
3642
- * Cache entry names fastembed uses for the fixed BGESmallZH model: it extracts
3643
- * into `<cacheDir>/<name>/` from the staged archive `<cacheDir>/<name>.tar.gz`.
3772
+ * Cache entry name for the fixed BGESmallZH model: the staged archive extracts
3773
+ * to `<cacheDir>/<name>/` and the runtime loads from that directory.
3644
3774
  */
3645
3775
  const BGESMALLZH_CACHE_NAME = "fast-bge-small-zh-v1.5";
3776
+ /** Files the runtime needs before the model can be considered installed. */
3777
+ const EMBEDDING_REQUIRED_FILES = [
3778
+ "model_optimized.onnx",
3779
+ "tokenizer.json",
3780
+ "tokenizer_config.json"
3781
+ ];
3646
3782
  /** Escape hatch for air-gapped installs and tests: points at an archive file. */
3647
3783
  const EMBEDDING_MODEL_URL_ENV = "SCREAM_CODE_EMBEDDING_MODEL_URL";
3648
- function modelWeightsPath(cacheDir) {
3649
- return join(cacheDir, BGESMALLZH_CACHE_NAME, "model_optimized.onnx");
3784
+ /** Default stall guards: a trickling connection must not spin a spinner forever. */
3785
+ const DEFAULT_IDLE_TIMEOUT_MS = 6e4;
3786
+ const DEFAULT_TOTAL_TIMEOUT_MS = 15 * 6e4;
3787
+ /** Progress callback throttle: frequent enough to move, rare enough to be free. */
3788
+ const PROGRESS_THROTTLE_MS = 100;
3789
+ function modelDirPath(cacheDir) {
3790
+ return join(cacheDir, BGESMALLZH_CACHE_NAME);
3650
3791
  }
3651
3792
  function sha256File(filePath) {
3652
3793
  return readFile(filePath).then((data) => createHash("sha256").update(data).digest("hex"));
3653
3794
  }
3654
3795
  /**
3655
3796
  * Stage the embedding model files when they are missing: download the pinned
3656
- * archive (HTTP status verified — unlike fastembed's own downloader), extract,
3797
+ * archive (HTTP status verified, unlike a naive downloader), extract,
3657
3798
  * and verify the weights payload against the pinned sha256.
3658
3799
  *
3659
3800
  * A partially extracted tree or a hash mismatch is removed so the caller keeps
@@ -3663,62 +3804,149 @@ function sha256File(filePath) {
3663
3804
  async function ensureEmbeddingModelFiles(cacheDir, options) {
3664
3805
  if (hasEmbeddingModelCache(cacheDir)) return;
3665
3806
  mkdirSync(cacheDir, { recursive: true });
3666
- const modelDir = join(cacheDir, BGESMALLZH_CACHE_NAME);
3807
+ const modelDir = modelDirPath(cacheDir);
3667
3808
  const stagingPath = join(cacheDir, `${BGESMALLZH_CACHE_NAME}.tar.gz.part`);
3668
3809
  const url = options?.sourceUrl ?? process.env[EMBEDDING_MODEL_URL_ENV] ?? "https://github.com/LIUTod/scream-code/releases/download/embedding-models-v1/fast-bge-small-zh-v1.5.tar.gz";
3810
+ const idleTimeoutMs = options?.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
3811
+ const totalTimeoutMs = options?.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS;
3812
+ const controller = new AbortController();
3813
+ const onExternalAbort = () => {
3814
+ controller.abort();
3815
+ };
3816
+ if (options?.signal !== void 0) if (options.signal.aborted) controller.abort();
3817
+ else options.signal.addEventListener("abort", onExternalAbort, { once: true });
3818
+ let idleTimer;
3819
+ const armIdleTimer = () => {
3820
+ if (idleTimer !== void 0) clearTimeout(idleTimer);
3821
+ idleTimer = setTimeout(() => {
3822
+ controller.abort();
3823
+ }, idleTimeoutMs);
3824
+ };
3825
+ const totalTimer = setTimeout(() => {
3826
+ controller.abort();
3827
+ }, totalTimeoutMs);
3828
+ let received = 0;
3829
+ let announcedTotal;
3830
+ let downloadCompleted = false;
3831
+ const announceProgress = () => {
3832
+ options?.onProgress?.(received, announcedTotal);
3833
+ };
3669
3834
  try {
3670
3835
  let response;
3671
3836
  try {
3672
- response = await fetch(url);
3837
+ armIdleTimer();
3838
+ response = await fetch(url, { signal: controller.signal });
3673
3839
  } catch (error) {
3674
3840
  const message = error instanceof Error ? error.message : String(error);
3675
3841
  throw new Error(`fetch failed for ${url}: ${message}`, { cause: error });
3676
3842
  }
3677
3843
  if (!response.ok) throw new Error(`model source returned HTTP ${response.status} for ${url}`);
3678
- await writeFile(stagingPath, Buffer.from(await response.arrayBuffer()));
3844
+ const contentLength = Number(response.headers.get("content-length"));
3845
+ announcedTotal = Number.isFinite(contentLength) && contentLength > 0 ? contentLength : void 0;
3846
+ announceProgress();
3847
+ const body = response.body;
3848
+ if (body === null) throw new Error(`model source returned an empty body for ${url}`);
3849
+ const reader = body.getReader();
3850
+ const abortRejection = new Promise((_, reject) => {
3851
+ if (controller.signal.aborted) {
3852
+ reject(/* @__PURE__ */ new Error("aborted"));
3853
+ return;
3854
+ }
3855
+ controller.signal.addEventListener("abort", () => {
3856
+ reject(/* @__PURE__ */ new Error("aborted"));
3857
+ }, { once: true });
3858
+ });
3859
+ const abortAwareRead = () => Promise.race([reader.read(), abortRejection]);
3860
+ let handle;
3861
+ try {
3862
+ handle = await open(stagingPath, "w");
3863
+ } catch (error) {
3864
+ await reader.cancel().catch(() => void 0);
3865
+ throw error;
3866
+ }
3867
+ let lastProgressAt = 0;
3868
+ try {
3869
+ await (async () => {
3870
+ for (;;) {
3871
+ let chunk;
3872
+ try {
3873
+ chunk = await abortAwareRead();
3874
+ } catch (error) {
3875
+ const message = error instanceof Error ? error.message : String(error);
3876
+ throw new Error(`${message} (received ${received} of ${announcedTotal ?? "?"} bytes): ${url}`, { cause: error });
3877
+ }
3878
+ if (chunk.done) break;
3879
+ const bytes = chunk.value;
3880
+ armIdleTimer();
3881
+ await handle.write(bytes);
3882
+ received += bytes.byteLength;
3883
+ const now = Date.now();
3884
+ if (now - lastProgressAt >= PROGRESS_THROTTLE_MS) {
3885
+ lastProgressAt = now;
3886
+ announceProgress();
3887
+ }
3888
+ }
3889
+ announceProgress();
3890
+ downloadCompleted = true;
3891
+ })();
3892
+ } finally {
3893
+ if (idleTimer !== void 0) clearTimeout(idleTimer);
3894
+ await handle.close();
3895
+ await reader.cancel().catch(() => void 0);
3896
+ }
3897
+ options?.onPhase?.("verify");
3679
3898
  await So({
3680
3899
  file: stagingPath,
3681
3900
  cwd: cacheDir
3682
3901
  });
3683
- const weightsPath = modelWeightsPath(cacheDir);
3684
- if (!existsSync(weightsPath) || statSync(weightsPath).size === 0) throw new Error(`model package did not contain weights after extraction: ${url}`);
3685
- const sha256 = await sha256File(weightsPath);
3902
+ for (const fileName of EMBEDDING_REQUIRED_FILES) {
3903
+ const target = join(modelDir, fileName);
3904
+ if (!existsSync(target) || statSync(target).size === 0) throw new Error(`model package is missing ${fileName} after extraction: ${url}`);
3905
+ }
3906
+ const sha256 = await sha256File(join(modelDir, "model_optimized.onnx"));
3686
3907
  const expected = options?.expectedOnnxSha256 ?? "1294ea4b6331115a353d81f96b85e8c8d7fdcc284453d5b2fab5b016230aad38";
3687
3908
  if (sha256 !== expected) throw new Error(`model weights sha256 mismatch (${sha256.slice(0, 12)}… != ${expected.slice(0, 12)}…): ${url}`);
3688
3909
  } catch (error) {
3689
- rmSync(modelDir, {
3690
- recursive: true,
3691
- force: true
3692
- });
3693
- throw error instanceof Error ? error : new Error(String(error));
3910
+ try {
3911
+ rmSync(modelDir, {
3912
+ recursive: true,
3913
+ force: true
3914
+ });
3915
+ } catch {}
3916
+ if (!downloadCompleted && controller.signal.aborted && !(options?.signal?.aborted ?? false)) throw new Error(`model download timed out (received ${received} of ${announcedTotal ?? "?"} bytes): ${url}`, { cause: error });
3917
+ throw error instanceof Error ? error : new Error(String(error), { cause: error });
3694
3918
  } finally {
3919
+ clearTimeout(totalTimer);
3920
+ if (idleTimer !== void 0) clearTimeout(idleTimer);
3921
+ if (options?.signal !== void 0) options.signal.removeEventListener("abort", onExternalAbort);
3695
3922
  rmSync(stagingPath, { force: true });
3696
3923
  }
3697
3924
  }
3698
- const PLATFORM_FAILURE = /@anush008\/tokenizers|Failed to load native binding|Unsupported (?:OS|platform|architecture)|ERR_DLOPEN_FAILED|invalid ELF header|Exec format error|is not a valid Win32 application|GLIBC_\d+[^"\n]*not found|Error loading shared library|cannot open shared object file/iu;
3699
- const ARCHIVE_FAILURE = /\bTAR_[A-Z_]+|incorrect header check|unexpected end of file|invalid tar|zlib/iu;
3700
- const NETWORK_FAILURE = /\b(?:ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EHOSTUNREACH|socket hang up|fetch failed)\b|network error|HTTP \d{3}/iu;
3925
+ const INSTALL_FAILURE = /Cannot find (?:package|module) '(?:onnxruntime-web|@huggingface\/tokenizers)'|ERR_MODULE_NOT_FOUND|Failed to resolve module specifier/iu;
3926
+ const ARCHIVE_FAILURE = /\bTAR_[A-Z_]+|incorrect header check|unexpected end of file|invalid tar|zlib|after extraction|sha256 mismatch/iu;
3927
+ const NETWORK_FAILURE = /\b(?:ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EHOSTUNREACH|socket hang up|fetch failed)\b|network error|HTTP \d{3}|timed out|empty body/iu;
3701
3928
  /**
3702
- * Classify a load failure so callers can say something true about it: the four
3703
- * causes need four different answers (unsupported platform, corrupt cache,
3704
- * network, unknown), and they used to be reported as one network problem.
3929
+ * Classify a load failure so callers can say something true about it: an
3930
+ * incomplete install, a corrupt cache, a network problem and anything else need
3931
+ * four different answers, and they used to be reported as one network problem.
3705
3932
  */
3706
3933
  function classifyEmbeddingFailure(message) {
3707
3934
  if (message === void 0) return "other";
3708
- if (PLATFORM_FAILURE.test(message)) return "platform";
3935
+ if (INSTALL_FAILURE.test(message)) return "install";
3709
3936
  if (ARCHIVE_FAILURE.test(message)) return "archive";
3710
3937
  if (NETWORK_FAILURE.test(message)) return "network";
3711
3938
  return "other";
3712
3939
  }
3713
3940
  /**
3714
- * Loads only the module entry point — no model download — so callers can tell
3715
- * "this machine cannot run the local model at all" apart from "the download
3716
- * failed". fastembed imports its native tokenizer binding at module scope, which
3717
- * is exactly the step a platform without a published binary cannot pass.
3941
+ * Loads both runtime packages — no model download — so callers can tell "this
3942
+ * installation cannot run the local model at all" apart from "the download
3943
+ * failed". The runtime is JavaScript plus WebAssembly, so a failure here is an
3944
+ * incomplete installation rather than an unsupported platform.
3718
3945
  */
3719
3946
  async function probeLocalEmbeddingSupport() {
3720
3947
  try {
3721
- await import("fastembed");
3948
+ await import("onnxruntime-web");
3949
+ await import("@huggingface/tokenizers");
3722
3950
  return { supported: true };
3723
3951
  } catch (error) {
3724
3952
  return {
@@ -3729,15 +3957,11 @@ async function probeLocalEmbeddingSupport() {
3729
3957
  }
3730
3958
  /**
3731
3959
  * Remove every trace of a previous BGESmallZH download: the extracted model
3732
- * directory *and* the staged archive it came from.
3960
+ * directory and the legacy staged archive name.
3733
3961
  *
3734
- * The archive has to go with it. fastembed skips the download whenever
3735
- * `<cacheDir>/<name>.tar.gz` exists, and it only deletes that file after a
3736
- * *successful* extract — its download error paths (response error, file stream
3737
- * error) reject and leave whatever was written behind. So an archive sitting
3738
- * next to a failed attempt is usually a partial leftover, and keeping it makes
3739
- * every retry extract the same junk locally: no network traffic, a few
3740
- * milliseconds, the same failure forever.
3962
+ * The legacy `<name>.tar.gz` file is still swept because older versions staged
3963
+ * the archive under that name; leaving it behind would waste 55MB per doomed
3964
+ * attempt. Our own downloader stages `.tar.gz.part` and always removes it.
3741
3965
  *
3742
3966
  * Never throws: a file locked by another process (on Windows a mapped ONNX file
3743
3967
  * stays locked) must not replace the download error the caller is reporting.
@@ -3755,48 +3979,19 @@ function clearEmbeddingModelCache(cacheDir) {
3755
3979
  }
3756
3980
  }
3757
3981
  /**
3758
- * True when the extracted model weights are already on disk. Loading then
3759
- * never downloads (fastembed skips the fetch whenever the model dir exists),
3760
- * so callers can safely trigger a local load without risking a surprise
3761
- * mid-query download.
3982
+ * True when every file the runtime needs is already on disk. Loading then never
3983
+ * downloads, so callers can safely trigger a local load without risking a
3984
+ * surprise mid-query download. All required files are checked (not just the
3985
+ * weights): a partially restored cache must count as missing so the setup flow
3986
+ * repairs it instead of failing at load time.
3762
3987
  */
3763
3988
  function hasEmbeddingModelCache(cacheDir) {
3764
- const modelDir = join(cacheDir, BGESMALLZH_CACHE_NAME);
3765
- return existsSync(modelDir) && existsSync(join(modelDir, "model_optimized.onnx"));
3766
- }
3767
- /**
3768
- * Small config/tokenizer files that fastembed expects alongside model.onnx.
3769
- * If these are missing (e.g. GCS download partially failed), fastembed throws.
3770
- * We download them from HuggingFace so the model can load — this covers the
3771
- * case where the GCS tarball was incomplete but the HF repo is reachable.
3772
- */
3773
- const FASTEMBED_SIDECARS = [
3774
- "config.json",
3775
- "tokenizer.json",
3776
- "tokenizer_config.json",
3777
- "special_tokens_map.json"
3778
- ];
3779
- const FASTEMBED_HF_REPOS = { "fast-bge-small-zh-v1.5": "BAAI/bge-small-zh-v1.5" };
3780
- async function ensureFastembedModelSidecars(model, cacheDir) {
3781
- const repo = FASTEMBED_HF_REPOS[model];
3782
- if (repo === void 0) return;
3783
- const modelDir = join(cacheDir ?? "local_cache", model);
3784
- mkdirSync(modelDir, { recursive: true });
3785
- for (const fileName of FASTEMBED_SIDECARS) {
3989
+ const modelDir = modelDirPath(cacheDir);
3990
+ if (!existsSync(modelDir)) return false;
3991
+ return EMBEDDING_REQUIRED_FILES.every((fileName) => {
3786
3992
  const target = join(modelDir, fileName);
3787
- try {
3788
- const { access } = await import("node:fs/promises");
3789
- await access(target);
3790
- continue;
3791
- } catch {}
3792
- const hfUrl = `https://huggingface.co/${repo}/resolve/main/${fileName}`;
3793
- try {
3794
- const response = await fetch(hfUrl);
3795
- if (!response.ok) continue;
3796
- const { writeFile } = await import("node:fs/promises");
3797
- await writeFile(target, Buffer.from(await response.arrayBuffer()));
3798
- } catch {}
3799
- }
3993
+ return existsSync(target) && statSync(target).size > 0;
3994
+ });
3800
3995
  }
3801
3996
  //#endregion
3802
3997
  //#region ../../packages/memory/src/store.ts
@@ -3940,7 +4135,19 @@ var MemoryMemoStore = class MemoryMemoStore {
3940
4135
  JOIN memos_fts f ON m.rowid = f.rowid
3941
4136
  WHERE f.memos_fts MATCH ? AND (m.project_dir = ? OR m.project_dir = '')
3942
4137
  ORDER BY m.recorded_at DESC LIMIT ?`);
3943
- return (projectDir === void 0 ? stmt.all(ftsQuery, limit) : stmt.all(ftsQuery, projectDir, limit)).map(rowToMemo);
4138
+ const rows = projectDir === void 0 ? stmt.all(ftsQuery, limit) : stmt.all(ftsQuery, projectDir, limit);
4139
+ const like = likePattern(query);
4140
+ const remaining = Math.max(0, limit - rows.length);
4141
+ const extraRows = remaining === 0 ? [] : projectDir === void 0 ? this.db.prepare("SELECT * FROM memos WHERE distilled LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\' ORDER BY recorded_at DESC LIMIT ?").all(like, like, remaining) : this.db.prepare("SELECT * FROM memos WHERE (distilled LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\') AND (project_dir = ? OR project_dir = '') ORDER BY recorded_at DESC LIMIT ?").all(like, like, projectDir, remaining);
4142
+ const seen = new Set(rows.map((row) => String(row["id"])));
4143
+ const merged = rows.map(rowToMemo);
4144
+ for (const row of extraRows) {
4145
+ const id = String(row["id"]);
4146
+ if (seen.has(id)) continue;
4147
+ seen.add(id);
4148
+ merged.push(rowToMemo(row));
4149
+ }
4150
+ return merged;
3944
4151
  }
3945
4152
  /** List memos with optional full-text search and pagination. */
3946
4153
  async list(options) {
@@ -4082,7 +4289,12 @@ var MemoryMemoStore = class MemoryMemoStore {
4082
4289
  extraction_source TEXT NOT NULL CHECK(extraction_source IN ('compaction', 'exit', 'manual')),
4083
4290
  recorded_at INTEGER NOT NULL,
4084
4291
  project_dir TEXT NOT NULL DEFAULT '',
4085
- tags TEXT NOT NULL DEFAULT '[]'
4292
+ tags TEXT NOT NULL DEFAULT '[]',
4293
+ note TEXT,
4294
+ distilled TEXT NOT NULL DEFAULT '',
4295
+ origin TEXT NOT NULL DEFAULT 'session',
4296
+ distilled_from TEXT,
4297
+ distilled_at INTEGER
4086
4298
  );
4087
4299
 
4088
4300
  CREATE INDEX IF NOT EXISTS idx_memos_project_dir ON memos(project_dir);
@@ -4112,6 +4324,14 @@ var MemoryMemoStore = class MemoryMemoStore {
4112
4324
  const info = this.db.prepare("PRAGMA table_info(memos)").all();
4113
4325
  if (!info.some((col) => col.name === "project_dir")) this.db.exec("ALTER TABLE memos ADD COLUMN project_dir TEXT NOT NULL DEFAULT ''");
4114
4326
  if (!info.some((col) => col.name === "tags")) this.db.exec("ALTER TABLE memos ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'");
4327
+ if (!info.some((col) => col.name === "note")) this.db.exec("ALTER TABLE memos ADD COLUMN note TEXT");
4328
+ if (!info.some((col) => col.name === "distilled")) this.db.exec("ALTER TABLE memos ADD COLUMN distilled TEXT NOT NULL DEFAULT ''");
4329
+ if (!info.some((col) => col.name === "origin")) {
4330
+ this.db.exec("ALTER TABLE memos ADD COLUMN origin TEXT NOT NULL DEFAULT 'session'");
4331
+ this.db.exec("UPDATE memos SET origin = 'dream' WHERE distilled <> ''");
4332
+ }
4333
+ if (!info.some((col) => col.name === "distilled_from")) this.db.exec("ALTER TABLE memos ADD COLUMN distilled_from TEXT");
4334
+ if (!info.some((col) => col.name === "distilled_at")) this.db.exec("ALTER TABLE memos ADD COLUMN distilled_at INTEGER");
4115
4335
  this.db.exec(`
4116
4336
  CREATE INDEX IF NOT EXISTS idx_memos_project_dir ON memos(project_dir);
4117
4337
  CREATE INDEX IF NOT EXISTS idx_memory_embeddings_created_at ON memory_embeddings(created_at DESC);
@@ -4155,15 +4375,16 @@ var MemoryMemoStore = class MemoryMemoStore {
4155
4375
  if (this.db === void 0 || memos.length === 0) return;
4156
4376
  const insert = this.db.prepare(`INSERT OR IGNORE INTO memos (
4157
4377
  id, source_session_id, source_session_title, user_need, approach,
4158
- outcome, what_failed, what_worked, extraction_source, recorded_at, project_dir, tags
4159
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4378
+ outcome, what_failed, what_worked, extraction_source, recorded_at, project_dir, tags,
4379
+ note, distilled, origin, distilled_from, distilled_at
4380
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4160
4381
  RETURNING rowid`);
4161
4382
  const insertFts = this.db.prepare(`INSERT INTO memos_fts(rowid, user_need, approach, what_failed, what_worked, source_session_title)
4162
4383
  VALUES (?, ?, ?, ?, ?, ?)`);
4163
4384
  this.db.exec("BEGIN TRANSACTION");
4164
4385
  try {
4165
4386
  for (const memo of memos) {
4166
- const row = insert.get(memo.id, memo.sourceSessionId, memo.sourceSessionTitle ?? null, memo.userNeed, memo.approach, memo.outcome, memo.whatFailed, memo.whatWorked, memo.extractionSource, memo.recordedAt, memo.projectDir ?? "", JSON.stringify(memo.tags ?? []));
4387
+ const row = insert.get(memo.id, memo.sourceSessionId, memo.sourceSessionTitle ?? null, memo.userNeed, memo.approach, memo.outcome, memo.whatFailed, memo.whatWorked, memo.extractionSource, memo.recordedAt, memo.projectDir ?? "", JSON.stringify(memo.tags ?? []), memo.note ?? null, memo.distilled ?? "", memo.origin ?? "session", stringifyDistilledFrom(memo.distilledFrom), memo.distilledAt ?? null);
4167
4388
  if (row === void 0) continue;
4168
4389
  insertFts.run(row.rowid, toFtsText$1(memo.userNeed), toFtsText$1(memo.approach), toFtsText$1(memo.whatFailed), toFtsText$1(memo.whatWorked), toFtsText$1(memo.sourceSessionTitle ?? ""));
4169
4390
  }
@@ -4178,14 +4399,15 @@ var MemoryMemoStore = class MemoryMemoStore {
4178
4399
  if (this.db === void 0) return;
4179
4400
  const insert = this.db.prepare(`INSERT INTO memos (
4180
4401
  id, source_session_id, source_session_title, user_need, approach,
4181
- outcome, what_failed, what_worked, extraction_source, recorded_at, project_dir, tags
4182
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4402
+ outcome, what_failed, what_worked, extraction_source, recorded_at, project_dir, tags,
4403
+ note, distilled, origin, distilled_from, distilled_at
4404
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4183
4405
  RETURNING rowid`);
4184
4406
  const insertFts = this.db.prepare(`INSERT INTO memos_fts(rowid, user_need, approach, what_failed, what_worked, source_session_title)
4185
4407
  VALUES (?, ?, ?, ?, ?, ?)`);
4186
4408
  this.db.exec("BEGIN TRANSACTION");
4187
4409
  try {
4188
- const row = insert.get(entry.id, entry.sourceSessionId, entry.sourceSessionTitle ?? null, entry.userNeed, entry.approach, entry.outcome, entry.whatFailed, entry.whatWorked, entry.extractionSource, entry.recordedAt, entry.projectDir ?? "", JSON.stringify(entry.tags ?? []));
4410
+ const row = insert.get(entry.id, entry.sourceSessionId, entry.sourceSessionTitle ?? null, entry.userNeed, entry.approach, entry.outcome, entry.whatFailed, entry.whatWorked, entry.extractionSource, entry.recordedAt, entry.projectDir ?? "", JSON.stringify(entry.tags ?? []), entry.note ?? null, entry.distilled ?? "", entry.origin ?? "session", stringifyDistilledFrom(entry.distilledFrom), entry.distilledAt ?? null);
4189
4411
  insertFts.run(row.rowid, toFtsText$1(entry.userNeed), toFtsText$1(entry.approach), toFtsText$1(entry.whatFailed), toFtsText$1(entry.whatWorked), toFtsText$1(entry.sourceSessionTitle ?? ""));
4190
4412
  this.db.exec("COMMIT");
4191
4413
  this.scheduleEmbedding(entry);
@@ -4221,7 +4443,12 @@ var MemoryMemoStore = class MemoryMemoStore {
4221
4443
  extraction_source = ?,
4222
4444
  recorded_at = ?,
4223
4445
  project_dir = ?,
4224
- tags = ?
4446
+ tags = ?,
4447
+ note = ?,
4448
+ distilled = ?,
4449
+ origin = ?,
4450
+ distilled_from = ?,
4451
+ distilled_at = ?
4225
4452
  WHERE id = ?
4226
4453
  RETURNING rowid`);
4227
4454
  const updateFts = this.db.prepare(`INSERT INTO memos_fts(rowid, user_need, approach, what_failed, what_worked, source_session_title)
@@ -4235,7 +4462,7 @@ var MemoryMemoStore = class MemoryMemoStore {
4235
4462
  this.db.exec("ROLLBACK");
4236
4463
  return false;
4237
4464
  }
4238
- const row = update.get(updated.sourceSessionId, updated.sourceSessionTitle ?? null, updated.userNeed, updated.approach, updated.outcome, updated.whatFailed, updated.whatWorked, updated.extractionSource, updated.recordedAt, updated.projectDir ?? "", JSON.stringify(updated.tags ?? []), id);
4465
+ const row = update.get(updated.sourceSessionId, updated.sourceSessionTitle ?? null, updated.userNeed, updated.approach, updated.outcome, updated.whatFailed, updated.whatWorked, updated.extractionSource, updated.recordedAt, updated.projectDir ?? "", JSON.stringify(updated.tags ?? []), updated.note ?? null, updated.distilled ?? "", updated.origin ?? "session", stringifyDistilledFrom(updated.distilledFrom), updated.distilledAt ?? null, id);
4239
4466
  if (row === void 0) {
4240
4467
  this.db.exec("ROLLBACK");
4241
4468
  return false;
@@ -4483,6 +4710,9 @@ var MemoryMemoStore = class MemoryMemoStore {
4483
4710
  function rowToMemo(row) {
4484
4711
  const sourceSessionTitle = row["source_session_title"];
4485
4712
  const projectDir = row["project_dir"];
4713
+ const distilled = row["distilled"];
4714
+ const note = row["note"];
4715
+ const distilledAt = row["distilled_at"];
4486
4716
  return {
4487
4717
  id: String(row["id"]),
4488
4718
  sourceSessionId: String(row["source_session_id"]),
@@ -4492,12 +4722,36 @@ function rowToMemo(row) {
4492
4722
  outcome: String(row["outcome"]),
4493
4723
  whatFailed: String(row["what_failed"]),
4494
4724
  whatWorked: String(row["what_worked"]),
4725
+ distilled: typeof distilled === "string" && distilled.trim().length > 0 ? distilled : void 0,
4726
+ origin: parseOrigin(row["origin"]),
4727
+ distilledFrom: parseDistilledFrom(row["distilled_from"]),
4728
+ distilledAt: typeof distilledAt === "number" && Number.isFinite(distilledAt) ? distilledAt : void 0,
4495
4729
  extractionSource: row["extraction_source"],
4496
4730
  recordedAt: Number(row["recorded_at"]),
4497
4731
  projectDir: typeof projectDir === "string" ? projectDir : "",
4498
- tags: parseTags(row["tags"])
4732
+ tags: parseTags(row["tags"]),
4733
+ note: typeof note === "string" && note.length > 0 ? note : void 0
4499
4734
  };
4500
4735
  }
4736
+ function parseOrigin(value) {
4737
+ return value === "dream" || value === "user" || value === "session" ? value : "session";
4738
+ }
4739
+ /** Column value for `distilled_from`: undefined when there is nothing to store. */
4740
+ function stringifyDistilledFrom(sources) {
4741
+ return sources === void 0 || sources.length === 0 ? null : JSON.stringify(sources);
4742
+ }
4743
+ /** Parse the `distilled_from` JSON column, dropping entries that fail validation. */
4744
+ function parseDistilledFrom(value) {
4745
+ if (typeof value !== "string" || value.length === 0) return void 0;
4746
+ try {
4747
+ const parsed = JSON.parse(value);
4748
+ if (!Array.isArray(parsed)) return void 0;
4749
+ const sources = parsed.filter((item) => typeof item === "object" && item !== null && typeof item.id === "string" && typeof item.title === "string" && typeof item.at === "number" && Number.isFinite(item.at));
4750
+ return sources.length > 0 ? sources.slice(0, 100) : void 0;
4751
+ } catch {
4752
+ return;
4753
+ }
4754
+ }
4501
4755
  function parseTags(value) {
4502
4756
  if (typeof value !== "string" || value.length === 0) return void 0;
4503
4757
  try {
@@ -4509,12 +4763,17 @@ function parseTags(value) {
4509
4763
  return;
4510
4764
  }
4511
4765
  }
4766
+ function likePattern(value) {
4767
+ return `%${value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
4768
+ }
4512
4769
  function memoMatchesSearch(memo, search) {
4513
4770
  return [
4514
4771
  memo.userNeed,
4515
4772
  memo.approach,
4516
4773
  memo.whatFailed,
4517
4774
  memo.whatWorked,
4775
+ memo.distilled ?? "",
4776
+ memo.note ?? "",
4518
4777
  memo.sourceSessionTitle ?? "",
4519
4778
  ...memo.tags ?? []
4520
4779
  ].join(" ").toLowerCase().replaceAll(/\s+/g, "").includes(search.replaceAll(/\s+/g, ""));
@@ -4688,6 +4947,7 @@ async function buildConsolidationPlan(store, options) {
4688
4947
  async function applyConsolidation(store, plan) {
4689
4948
  let deleted = 0;
4690
4949
  let created = 0;
4950
+ let skipped = 0;
4691
4951
  if (plan.resolved.length > 0) {
4692
4952
  const worked = plan.resolved.filter((m) => m.whatWorked).map((m) => m.whatWorked).join("; ");
4693
4953
  const failed = plan.resolved.filter((m) => m.whatFailed).map((m) => m.whatFailed).join("; ");
@@ -4726,9 +4986,22 @@ async function applyConsolidation(store, plan) {
4726
4986
  created++;
4727
4987
  }
4728
4988
  }
4989
+ const mergeableGroups = [];
4729
4990
  for (const group of plan.duplicateGroups) {
4991
+ const distilledText = (group.merged.distilled ?? "").trim();
4992
+ if (distilledText.length === 0 && group.memos.some(isDistilledRule)) {
4993
+ skipped++;
4994
+ continue;
4995
+ }
4996
+ mergeableGroups.push({
4997
+ group,
4998
+ distilledText
4999
+ });
5000
+ }
5001
+ for (const { group, distilledText } of mergeableGroups) {
4730
5002
  const newest = group.memos.reduce((a, b) => a.recordedAt > b.recordedAt ? a : b);
4731
5003
  const mergedTags = normalizeTags(group.memos.flatMap((m) => m.tags ?? []));
5004
+ const mergedNotes = [...new Set(group.memos.map((memo) => memo.note?.trim() ?? "").filter((note) => note.length > 0))];
4732
5005
  const merged = createMemoryMemo({
4733
5006
  sourceSessionId: newest.sourceSessionId,
4734
5007
  sourceSessionTitle: newest.sourceSessionTitle,
@@ -4737,14 +5010,19 @@ async function applyConsolidation(store, plan) {
4737
5010
  outcome: group.merged.outcome,
4738
5011
  whatFailed: group.merged.whatFailed,
4739
5012
  whatWorked: group.merged.whatWorked,
5013
+ note: mergedNotes.length > 0 ? mergedNotes.join("; ") : void 0,
4740
5014
  tags: group.merged.tags ?? mergedTags,
5015
+ distilled: distilledText.length > 0 ? distilledText : void 0,
4741
5016
  projectDir: newest.projectDir,
4742
- extractionSource: "compaction"
5017
+ extractionSource: "compaction",
5018
+ origin: "dream",
5019
+ distilledAt: distilledText.length > 0 ? Date.now() : void 0,
5020
+ distilledFrom: collectLineage(group.memos)
4743
5021
  });
4744
5022
  await store.append(merged);
4745
5023
  created++;
4746
5024
  }
4747
- for (const group of plan.duplicateGroups) for (const memo of group.memos) {
5025
+ for (const { group } of mergeableGroups) for (const memo of group.memos) {
4748
5026
  await store.delete(memo.id);
4749
5027
  deleted++;
4750
5028
  }
@@ -4758,9 +5036,31 @@ async function applyConsolidation(store, plan) {
4758
5036
  }
4759
5037
  return {
4760
5038
  deleted,
4761
- created
5039
+ created,
5040
+ skipped
4762
5041
  };
4763
5042
  }
5043
+ /**
5044
+ * Build the lineage snapshot for a merged memo: every source memo in the group
5045
+ * plus each source's own lineage, deduped by id so a rule distilled from other
5046
+ * rules keeps its whole ancestry. Sorted oldest-first and capped at 100.
5047
+ */
5048
+ function collectLineage(memos) {
5049
+ const seen = /* @__PURE__ */ new Map();
5050
+ for (const memo of memos) {
5051
+ if (!seen.has(memo.id)) seen.set(memo.id, {
5052
+ id: memo.id,
5053
+ title: clip(memo.userNeed, 80),
5054
+ at: memo.recordedAt
5055
+ });
5056
+ for (const source of memo.distilledFrom ?? []) if (!seen.has(source.id)) seen.set(source.id, source);
5057
+ }
5058
+ return [...seen.values()].toSorted((a, b) => a.at - b.at).slice(-100);
5059
+ }
5060
+ function clip(text, maxLength) {
5061
+ const chars = Array.from(text);
5062
+ return chars.length <= maxLength ? text : chars.slice(0, maxLength).join("");
5063
+ }
4764
5064
  function findDuplicateGroups(memos) {
4765
5065
  const groups = [];
4766
5066
  const used = /* @__PURE__ */ new Set();
@@ -4839,7 +5139,8 @@ function buildDuplicateGroup(cluster) {
4839
5139
  approach: `合并 ${cluster.length} 条相关记录。最新方案: ${newest.approach}`,
4840
5140
  outcome: bestOutcome,
4841
5141
  whatFailed: failures.size > 0 ? [...failures].join("; ") : "none",
4842
- whatWorked: successes.size > 0 ? [...successes].join("; ") : "none"
5142
+ whatWorked: successes.size > 0 ? [...successes].join("; ") : "none",
5143
+ distilled: ""
4843
5144
  },
4844
5145
  reason: `发现 ${cluster.length} 条相似记录(关键词重叠 > ${Math.round(SIMILARITY_THRESHOLD * 100)}%)`
4845
5146
  };
@@ -4902,12 +5203,19 @@ function isOutcomeCompleted(outcome) {
4902
5203
  const lower = outcome.toLowerCase();
4903
5204
  return lower.includes("完成") || lower.includes("done") || lower.includes("completed") || lower.includes("成功") || lower.includes("success");
4904
5205
  }
5206
+ /**
5207
+ * A distilled rule is not a stale experience: pruning it by age would delete
5208
+ * the conclusion /dream exists to accumulate.
5209
+ */
5210
+ function isDistilledRule(memo) {
5211
+ return memo.origin === "dream" || memo.origin === "user" || (memo.distilled ?? "").trim().length > 0;
5212
+ }
4905
5213
  function findResolved(memos) {
4906
- return memos.filter((m) => isOutcomeCompleted(m.outcome) && Date.now() - m.recordedAt > 10080 * 60 * 1e3);
5214
+ return memos.filter((m) => !isDistilledRule(m) && isOutcomeCompleted(m.outcome) && Date.now() - m.recordedAt > 10080 * 60 * 1e3);
4907
5215
  }
4908
5216
  function findStale(memos, staleDays) {
4909
5217
  const threshold = Date.now() - staleDays * 24 * 60 * 60 * 1e3;
4910
- return memos.filter((m) => m.recordedAt < threshold && !isOutcomeCompleted(m.outcome) && !m.outcome.includes("blocked"));
5218
+ return memos.filter((m) => !isDistilledRule(m) && m.recordedAt < threshold && !isOutcomeCompleted(m.outcome) && !m.outcome.includes("blocked"));
4911
5219
  }
4912
5220
  //#endregion
4913
5221
  //#region ../../packages/memory/src/dream.ts
@@ -5977,7 +6285,7 @@ function entryFor(homeDir) {
5977
6285
  await store.init();
5978
6286
  const cacheDir = join$1(homeDir, "cache", "fastembed");
5979
6287
  mkdirSync(cacheDir, { recursive: true });
5980
- store.setEmbeddingEngine(createFastEmbedEngine(cacheDir));
6288
+ store.setEmbeddingEngine(createEmbeddingEngine(cacheDir));
5981
6289
  } catch (error) {
5982
6290
  entries.delete(homeDir);
5983
6291
  throw error;
@@ -5994,7 +6302,7 @@ function entryFor(homeDir) {
5994
6302
  *
5995
6303
  * The map key is the raw homeDir string so the engine cache key
5996
6304
  * join(homeDir, 'cache', 'fastembed') stays byte-identical with every other
5997
- * createFastEmbedEngine call site: same string → same engine instance.
6305
+ * createEmbeddingEngine call site: same string → same engine instance.
5998
6306
  */
5999
6307
  function sharedKnowledgeStore(homeDir) {
6000
6308
  return entryFor(homeDir).store;
@@ -6008,7 +6316,7 @@ function sharedKnowledgeStoreReady(homeDir) {
6008
6316
  const HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
6009
6317
  const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
6010
6318
  /**
6011
- * Soft cap on chunk token count. BGE-small-zh-v1.5 (the fastembed model used
6319
+ * Soft cap on chunk token count. BGE-small-zh-v1.5 (the embedding model used
6012
6320
  * here) has a 512-token sequence limit; chunks longer than that get truncated
6013
6321
  * at the embedding layer, hurting retrieval quality. We split at ~480 to leave
6014
6322
  * headroom for the heading line that gets prepended at embed time.
@@ -6544,14 +6852,78 @@ async function embedOrNulls(engine, texts, skipEmbedding) {
6544
6852
  }
6545
6853
  /** LLM concurrency for extraction — callers and comments below refer to this constant. */
6546
6854
  const LLM_CONCURRENCY = 20;
6547
- const SUPPORTED_EXTENSIONS = new Set([
6855
+ /** Formats read directly as UTF-8 text. */
6856
+ const TEXT_EXTENSIONS = new Set([
6548
6857
  ".md",
6549
6858
  ".markdown",
6550
6859
  ".txt"
6551
6860
  ]);
6552
- /** Read a file and return its content. */
6553
- async function readFileContent(filePath) {
6554
- return readFile(filePath, "utf-8");
6861
+ /**
6862
+ * Binary documents run through the markit converter (they become Markdown,
6863
+ * then take the exact same chunking/embedding path as a `.md` file).
6864
+ */
6865
+ const DOCUMENT_EXTENSIONS = new Set([
6866
+ ".pdf",
6867
+ ".docx",
6868
+ ".xlsx"
6869
+ ]);
6870
+ const SUPPORTED_EXTENSIONS = new Set([...TEXT_EXTENSIONS, ...DOCUMENT_EXTENSIONS]);
6871
+ /**
6872
+ * Hard ceiling for a converted document. Every chunk costs one LLM extraction
6873
+ * call, so an unbounded PDF would silently turn into hundreds of requests.
6874
+ */
6875
+ const MAX_DOCUMENT_BYTES = 50 * 1024 * 1024;
6876
+ /** Lazily-loaded converter instance: only binary ingests pay the import cost. */
6877
+ let markitPromise;
6878
+ function getMarkit() {
6879
+ markitPromise ??= import("./markit-CV3BXOV_.mjs").then(({ Markit }) => new Markit()).catch((error) => {
6880
+ markitPromise = void 0;
6881
+ throw error;
6882
+ });
6883
+ return markitPromise;
6884
+ }
6885
+ /**
6886
+ * The converters record images as `<!-- image: id (page N, WxHpt) -->` when
6887
+ * they are not asked to write them out. Those comments carry no searchable
6888
+ * meaning and would otherwise burn tokens in every chunk they land in.
6889
+ */
6890
+ function stripImagePlaceholders(markdown) {
6891
+ return markdown.replaceAll(/<!--\s*image:[^>]*-->/g, "").replaceAll(/\n{3,}/g, "\n\n").trim();
6892
+ }
6893
+ /**
6894
+ * Read a source file and fingerprint it.
6895
+ *
6896
+ * Text files hash the decoded string — exactly what the previous
6897
+ * `sha256(readFile(path, 'utf-8'))` produced, so existing `contentHash` values
6898
+ * keep matching even for non-UTF-8 text. Binary files hash the raw bytes, which
6899
+ * stays stable across converter upgrades.
6900
+ */
6901
+ async function readSource(filePath) {
6902
+ const ext = getFileExtension(filePath);
6903
+ if (!TEXT_EXTENSIONS.has(ext)) {
6904
+ const { size } = await stat(filePath);
6905
+ if (size > MAX_DOCUMENT_BYTES) throw new Error(`文档超过 ${MAX_DOCUMENT_BYTES / 1024 / 1024}MB 上限,已跳过: ${basename$1(filePath)}`);
6906
+ }
6907
+ const bytes = await readFile(filePath);
6908
+ return {
6909
+ bytes,
6910
+ ext,
6911
+ contentHash: TEXT_EXTENSIONS.has(ext) ? createHash("sha256").update(bytes.toString("utf8")).digest("hex") : createHash("sha256").update(bytes).digest("hex")
6912
+ };
6913
+ }
6914
+ /**
6915
+ * Convert a binary document to Markdown. Only called once the bytes are known
6916
+ * to be new or changed — an unchanged PDF is never re-parsed.
6917
+ */
6918
+ async function convertSource(filePath, bytes, ext, onConverting) {
6919
+ onConverting?.(basename$1(filePath));
6920
+ const { markdown } = await (await getMarkit()).convert(bytes, {
6921
+ extension: ext,
6922
+ filename: basename$1(filePath)
6923
+ });
6924
+ const content = stripImagePlaceholders(markdown ?? "");
6925
+ if (content.length === 0) throw new Error(ext === ".pdf" ? "文档未提取到文字(可能是扫描版 PDF),本功能不含 OCR" : "文档未提取到可索引的文字,文件可能已损坏或为空");
6926
+ return content;
6555
6927
  }
6556
6928
  function getFileExtension(filePath) {
6557
6929
  const lastDot = filePath.lastIndexOf(".");
@@ -6619,6 +6991,10 @@ async function ensureEmbeddingModelMatches(store, engine) {
6619
6991
  * Reports progress via callback.
6620
6992
  */
6621
6993
  async function ingestFile(store, llm, filePath, onProgress) {
6994
+ if (!isSupportedFile(filePath)) {
6995
+ const ext = getFileExtension(filePath);
6996
+ throw new Error(`不支持的文件类型: ${ext === "" ? "(无扩展名)" : ext}`);
6997
+ }
6622
6998
  const engine = store.getEmbeddingEngine();
6623
6999
  const skipEmbed = engine === void 0 || !engine.available;
6624
7000
  if (skipEmbed) onProgress?.({
@@ -6630,8 +7006,7 @@ async function ingestFile(store, llm, filePath, onProgress) {
6630
7006
  stage: "chunking",
6631
7007
  message: `读取并切分文件: ${basename$1(filePath)}`
6632
7008
  });
6633
- const content = await readFileContent(filePath);
6634
- const contentHash = createHash("sha256").update(content).digest("hex");
7009
+ const { bytes, ext, contentHash } = await readSource(filePath);
6635
7010
  const existing = await store.findSourceByFilePath(filePath);
6636
7011
  let replacedOldVersion = false;
6637
7012
  if (existing !== void 0) {
@@ -6653,10 +7028,18 @@ async function ingestFile(store, llm, filePath, onProgress) {
6653
7028
  stage: "chunking",
6654
7029
  message: `检测到内容变更,替换旧版本: ${existing.name}`
6655
7030
  });
7031
+ }
7032
+ const content = TEXT_EXTENSIONS.has(ext) ? bytes.toString("utf8") : await convertSource(filePath, bytes, ext, (file) => {
7033
+ onProgress?.({
7034
+ stage: "converting",
7035
+ message: file
7036
+ });
7037
+ });
7038
+ if (existing !== void 0) {
6656
7039
  await store.deleteSource(existing.id);
6657
7040
  replacedOldVersion = true;
6658
7041
  }
6659
- const sections = chunkContent(content, getFileExtension(filePath));
7042
+ const sections = chunkContent(content, ext);
6660
7043
  store.beginTransaction();
6661
7044
  try {
6662
7045
  if (sections.length === 0) {
@@ -6860,6 +7243,7 @@ async function collectSupportedFiles(dirPath) {
6860
7243
  const files = [];
6861
7244
  const entries = await readdir(dirPath, { withFileTypes: true });
6862
7245
  for (const entry of entries) {
7246
+ if (entry.name.startsWith(".") || entry.name.startsWith("~$")) continue;
6863
7247
  const fullPath = join$1(dirPath, entry.name);
6864
7248
  if (entry.isDirectory()) files.push(...await collectSupportedFiles(fullPath));
6865
7249
  else if (entry.isFile() && isSupportedFile(fullPath)) files.push(fullPath);
@@ -6868,7 +7252,7 @@ async function collectSupportedFiles(dirPath) {
6868
7252
  }
6869
7253
  async function ingestDirectory(store, llm, dirPath, onProgress) {
6870
7254
  const files = await collectSupportedFiles(dirPath);
6871
- if (files.length === 0) throw new Error(`目录下没有支持的文件 (.md, .markdown, .txt): ${dirPath}`);
7255
+ if (files.length === 0) throw new Error(`目录下没有支持的文件 (${[...SUPPORTED_EXTENSIONS].join(", ")}): ${dirPath}`);
6872
7256
  const errors = [];
6873
7257
  let succeeded = 0;
6874
7258
  let created = 0;
@@ -6880,6 +7264,10 @@ async function ingestDirectory(store, llm, dirPath, onProgress) {
6880
7264
  for (let i = 0; i < files.length; i++) {
6881
7265
  const filePath = files[i];
6882
7266
  const fileProgress = (progress) => {
7267
+ if (progress.stage === "converting") {
7268
+ onProgress?.(progress);
7269
+ return;
7270
+ }
6883
7271
  const prefix = `批量摄入 ${i + 1}/${files.length}: ${basename$1(filePath)}`;
6884
7272
  onProgress?.({
6885
7273
  ...progress,
@@ -7254,4 +7642,4 @@ async function chunkVectorFallback(store, queryVec, topK) {
7254
7642
  return results;
7255
7643
  }
7256
7644
  //#endregion
7257
- export { sharedMemoStore as A, buildProjectTagCloud as B, applyConsolidation as C, parseMemoryMemos as D, buildExitExtractionPrompt as E, probeLocalEmbeddingSupport as F, join$1 as G, basename$1 as H, createMemoryMemo as I, relative$1 as J, normalize as K, toSummary as L, clearEmbeddingModelCache as M, createFastEmbedEngine as N, stripMemoryTags as O, hasEmbeddingModelCache as P, generateTags as R, DreamTracker as S, EXIT_EXTRACTION_SYSTEM_PROMPT as T, dirname$2 as U, rankMemos as V, isAbsolute$1 as W, resolve$1 as Y, splitLargeSection as _, isSupportedFile as a, sharedKnowledgeStoreReady as b, buildExtractionUserPrompt as c, extractQueryEntities as d, parseExtractionResponse as f, estimateTokens as g, chunkText as h, ingestFile as i, classifyEmbeddingFailure as j, MemoryMemoStore as k, extractEventFromChunk as l, chunkMarkdown as m, multiSearchWithTrace as n, ENTITY_TYPES as o, rerankEventsWithLlm as p, parse$1 as q, ingestDirectory as r, EXTRACTION_SYSTEM_PROMPT as s, multiSearch as t, extractJsonFromText as u, stripMarkdown as v, buildConsolidationPlan as w, KnowledgeStore as x, sharedKnowledgeStore as y, normalizeTags as z };
7645
+ export { sharedMemoStore as A, buildProjectTagCloud as B, applyConsolidation as C, parseMemoryMemos as D, buildExitExtractionPrompt as E, probeLocalEmbeddingSupport as F, join$1 as G, basename$1 as H, createMemoryMemo as I, relative$1 as J, normalize as K, toSummary as L, clearEmbeddingModelCache as M, createEmbeddingEngine as N, stripMemoryTags as O, hasEmbeddingModelCache as P, generateTags as R, DreamTracker as S, EXIT_EXTRACTION_SYSTEM_PROMPT as T, dirname$2 as U, rankMemos as V, isAbsolute$1 as W, resolve$1 as Y, splitLargeSection as _, isSupportedFile as a, sharedKnowledgeStoreReady as b, buildExtractionUserPrompt as c, extractQueryEntities as d, parseExtractionResponse as f, estimateTokens as g, chunkText as h, ingestFile as i, classifyEmbeddingFailure as j, MemoryMemoStore as k, extractEventFromChunk as l, chunkMarkdown as m, multiSearchWithTrace as n, ENTITY_TYPES as o, rerankEventsWithLlm as p, parse$1 as q, ingestDirectory as r, EXTRACTION_SYSTEM_PROMPT as s, multiSearch as t, extractJsonFromText as u, stripMarkdown as v, buildConsolidationPlan as w, KnowledgeStore as x, sharedKnowledgeStore as y, normalizeTags as z };