@plur-ai/core 0.9.3 → 0.9.5

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/dist/index.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import {
2
2
  computeIdf,
3
+ embedderStatus,
3
4
  embeddingSearch,
4
5
  embeddingSearchWithScores,
5
6
  engramSearchText,
6
7
  ftsScore,
7
8
  ftsTokenize,
8
- searchEngrams
9
- } from "./chunk-IGGMRXAB.js";
9
+ resetEmbedder,
10
+ searchEngrams,
11
+ setEmbeddingsEnabled
12
+ } from "./chunk-ZY4R3VEG.js";
10
13
  import {
11
14
  EngramSchemaPassthrough,
12
15
  appendHistory,
@@ -26,19 +29,18 @@ import {
26
29
  readHistoryForEngram,
27
30
  saveEngrams,
28
31
  storePrefix
29
- } from "./chunk-3ZPTRZE3.js";
32
+ } from "./chunk-EQPTF4JZ.js";
30
33
  import {
31
34
  atomicWrite,
32
35
  getSyncStatus,
33
36
  sync,
34
37
  withLock
35
38
  } from "./chunk-PRK3B7WR.js";
36
- import "./chunk-2ZDO52B4.js";
37
39
 
38
40
  // src/index.ts
39
41
  import * as fs4 from "fs";
40
- import * as os from "os";
41
- import * as path3 from "path";
42
+ import { tmpdir } from "os";
43
+ import { join as join5, dirname as dirname2, basename as basename2 } from "path";
42
44
  import yaml6 from "js-yaml";
43
45
 
44
46
  // src/storage.ts
@@ -175,6 +177,7 @@ var IndexedStorage = class {
175
177
  allSyncedIds.add(e.id);
176
178
  }
177
179
  for (const store of this.stores) {
180
+ if (!store.path) continue;
178
181
  validSources.add(store.path);
179
182
  const storeEngrams = loadEngrams(store.path);
180
183
  const prefix = storePrefix(store.scope);
@@ -221,11 +224,17 @@ import yaml from "js-yaml";
221
224
  // src/schemas/config.ts
222
225
  import { z } from "zod";
223
226
  var StoreEntrySchema = z.object({
224
- path: z.string(),
227
+ path: z.string().optional(),
228
+ url: z.string().url().optional(),
229
+ token: z.string().optional(),
230
+ // Bearer for remote stores; ignored for path
225
231
  scope: z.string(),
226
232
  shared: z.boolean().default(false),
227
233
  readonly: z.boolean().default(false)
228
- });
234
+ }).refine(
235
+ (s) => Boolean(s.path) !== Boolean(s.url),
236
+ { message: "StoreEntry requires exactly one of path or url" }
237
+ );
229
238
  var LlmTierConfigSchema = z.object({
230
239
  dedup_tier: z.enum(["fast", "balanced", "thorough"]).default("fast"),
231
240
  profile_tier: z.enum(["fast", "balanced", "thorough"]).default("balanced"),
@@ -244,6 +253,9 @@ var StorageConfigSchema = z.object({
244
253
  backend: z.enum(["yaml", "sqlite"]).default("yaml"),
245
254
  path: z.string().optional()
246
255
  }).partial();
256
+ var EmbeddingsConfigSchema = z.object({
257
+ enabled: z.boolean().default(true)
258
+ }).partial();
247
259
  var PlurConfigSchema = z.object({
248
260
  auto_learn: z.boolean().default(true),
249
261
  auto_capture: z.boolean().default(true),
@@ -261,6 +273,7 @@ var PlurConfigSchema = z.object({
261
273
  allow_secrets: z.boolean().default(false),
262
274
  index: z.boolean().default(true),
263
275
  storage: StorageConfigSchema.default({}),
276
+ embeddings: EmbeddingsConfigSchema.default({}),
264
277
  stores: z.array(StoreEntrySchema).default([]),
265
278
  llm: LlmTierConfigSchema.default({}),
266
279
  profile: ProfileConfigSchema.default({}),
@@ -450,6 +463,7 @@ var DEFAULT_MAX_TOKENS = 8e3;
450
463
  var DEFAULT_MIN_RELEVANCE = 0.3;
451
464
  var MAX_PER_PACK = 5;
452
465
  var MAX_PER_DOMAIN = 10;
466
+ var PINNED_TOKEN_BUDGET_RATIO = 0.5;
453
467
  var DIP19_CONSIDER_MAX = 5;
454
468
  var DIP19_CONSIDER_BUDGET = 200;
455
469
  function getPackMetadata(manifest) {
@@ -528,7 +542,11 @@ function scoreEngram(engram, promptLower, promptWords, packMatchTerms, scopeFilt
528
542
  for (const word of promptWords) {
529
543
  if (statementWords.has(word)) termHits += 0.5;
530
544
  }
531
- if (termHits === 0) return 0;
545
+ const isPinned = engram.pinned === true;
546
+ if (termHits === 0 && !isPinned) return 0;
547
+ if (termHits === 0 && isPinned) {
548
+ termHits = 0.5;
549
+ }
532
550
  let rs = isPack ? engram.activation.retrieval_strength : decayedStrength(engram.activation.retrieval_strength, daysSince(engram.activation.last_accessed));
533
551
  if (!isPack) {
534
552
  const fb = engram.feedback_signals;
@@ -543,6 +561,7 @@ function scoreEngram(engram, promptLower, promptWords, packMatchTerms, scopeFilt
543
561
  else if (netFeedback < 0) score *= Math.max(1 + netFeedback * 0.1, 0.5);
544
562
  }
545
563
  if (engram.consolidated) score *= 1.1;
564
+ if (isPinned) score *= 2;
546
565
  const emotionalWeight = engram.episodic?.emotional_weight ?? 5;
547
566
  score *= 1 + (emotionalWeight - 5) * 0.04;
548
567
  return score;
@@ -552,7 +571,21 @@ function fillTokenBudget(scored, maxTokens) {
552
571
  const packCounts = /* @__PURE__ */ new Map();
553
572
  const domainCounts = /* @__PURE__ */ new Map();
554
573
  let tokensUsed = 0;
555
- for (const engram of scored) {
574
+ const pinned = scored.filter((e) => e.pinned === true);
575
+ const unpinned = scored.filter((e) => e.pinned !== true);
576
+ const pinnedBudget = Math.floor(maxTokens * PINNED_TOKEN_BUDGET_RATIO);
577
+ for (const engram of pinned) {
578
+ const cost = estimateTokens(engram);
579
+ if (tokensUsed + cost > maxTokens) continue;
580
+ if (tokensUsed + cost > pinnedBudget) continue;
581
+ result.push(engram);
582
+ tokensUsed += cost;
583
+ const pack = engram.pack ?? "__personal__";
584
+ packCounts.set(pack, (packCounts.get(pack) ?? 0) + 1);
585
+ const topDomain = (engram.domain ?? "__none__").split(".")[0];
586
+ domainCounts.set(topDomain, (domainCounts.get(topDomain) ?? 0) + 1);
587
+ }
588
+ for (const engram of unpinned) {
556
589
  const cost = estimateTokens(engram);
557
590
  if (tokensUsed + cost > maxTokens) continue;
558
591
  const pack = engram.pack ?? "__personal__";
@@ -586,7 +619,7 @@ function selectAndSpread(ctx, personalEngrams, packs, config, embeddingBoosts) {
586
619
  engramMap.set(engram.id, engram);
587
620
  let raw = scoreEngram(engram, promptLower, promptWords, [], ctx.scope, false);
588
621
  const embBoost = embeddingBoosts?.get(engram.id) ?? 0;
589
- if (raw === 0 && embBoost > 0.3) {
622
+ if (raw === 0 && embBoost > 0.5) {
590
623
  raw = embBoost * 2;
591
624
  } else if (raw > 0 && embBoost > 0) {
592
625
  raw += embBoost;
@@ -611,7 +644,7 @@ function selectAndSpread(ctx, personalEngrams, packs, config, embeddingBoosts) {
611
644
  engramMap.set(engram.id, engram);
612
645
  let raw = scoreEngram(engram, promptLower, promptWords, matchTerms, ctx.scope, true);
613
646
  const embBoost = embeddingBoosts?.get(engram.id) ?? 0;
614
- if (raw === 0 && embBoost > 0.3) {
647
+ if (raw === 0 && embBoost > 0.5) {
615
648
  raw = embBoost * 2;
616
649
  } else if (raw > 0 && embBoost > 0) {
617
650
  raw += embBoost;
@@ -631,7 +664,7 @@ function selectAndSpread(ctx, personalEngrams, packs, config, embeddingBoosts) {
631
664
  aBoosts.set(e.id, aBoost);
632
665
  e.score = e.keyword_match + aBoost;
633
666
  }
634
- const filtered = scored.filter((s) => s.score >= minRelevance);
667
+ const filtered = scored.filter((s) => s.pinned === true || s.score >= minRelevance);
635
668
  filtered.sort((a, b) => b.score - a.score);
636
669
  const { selected: directives, tokens_used: directiveTokens } = fillTokenBudget(filtered, maxTokens);
637
670
  const directiveIds = new Set(directives.map((e) => e.id));
@@ -772,8 +805,8 @@ function generateEpisodeId() {
772
805
  const rand = Math.random().toString(36).slice(2, 6);
773
806
  return `EP-${ts}-${rand}`;
774
807
  }
775
- function captureEpisode(path4, summary, context) {
776
- const episodes = loadEpisodes(path4);
808
+ function captureEpisode(path3, summary, context) {
809
+ const episodes = loadEpisodes(path3);
777
810
  const episode = {
778
811
  id: generateEpisodeId(),
779
812
  summary,
@@ -784,11 +817,11 @@ function captureEpisode(path4, summary, context) {
784
817
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
785
818
  };
786
819
  episodes.push(episode);
787
- atomicWrite(path4, yaml2.dump(episodes, { lineWidth: 120, noRefs: true }));
820
+ atomicWrite(path3, yaml2.dump(episodes, { lineWidth: 120, noRefs: true }));
788
821
  return episode;
789
822
  }
790
- function queryTimeline(path4, query) {
791
- let episodes = loadEpisodes(path4);
823
+ function queryTimeline(path3, query) {
824
+ let episodes = loadEpisodes(path3);
792
825
  if (query?.since) episodes = episodes.filter((e) => new Date(e.timestamp) >= query.since);
793
826
  if (query?.until) episodes = episodes.filter((e) => new Date(e.timestamp) <= query.until);
794
827
  if (query?.agent) episodes = episodes.filter((e) => e.agent === query.agent);
@@ -799,10 +832,10 @@ function queryTimeline(path4, query) {
799
832
  }
800
833
  return episodes;
801
834
  }
802
- function loadEpisodes(path4) {
803
- if (!existsSync4(path4)) return [];
835
+ function loadEpisodes(path3) {
836
+ if (!existsSync4(path3)) return [];
804
837
  try {
805
- const raw = yaml2.load(readFileSync2(path4, "utf8"));
838
+ const raw = yaml2.load(readFileSync2(path3, "utf8"));
806
839
  return Array.isArray(raw) ? raw : [];
807
840
  } catch {
808
841
  return [];
@@ -903,7 +936,13 @@ function isAggregationQuery(query) {
903
936
  return AGGREGATION_PATTERNS.some((p) => p.test(query));
904
937
  }
905
938
  async function hybridSearch(engrams, query, limit, storagePath) {
906
- if (engrams.length === 0) return [];
939
+ const result = await hybridSearchWithMeta(engrams, query, limit, storagePath);
940
+ return result.engrams;
941
+ }
942
+ async function hybridSearchWithMeta(engrams, query, limit, storagePath) {
943
+ if (engrams.length === 0) {
944
+ return { engrams: [], mode: "hybrid", embedderError: null };
945
+ }
907
946
  const exhaustive = isAggregationQuery(query);
908
947
  const effectiveLimit = exhaustive ? Math.max(limit, 50) : limit;
909
948
  const bm25Limit = Math.min(engrams.length, exhaustive ? effectiveLimit * 5 : effectiveLimit * 3);
@@ -912,8 +951,23 @@ async function hybridSearch(engrams, query, limit, storagePath) {
912
951
  Promise.resolve(searchEngrams(engrams, query, bm25Limit)),
913
952
  embeddingSearch(engrams, query, embLimit, storagePath)
914
953
  ]);
954
+ const status = embedderStatus();
955
+ let mode;
956
+ let embedderError = null;
957
+ if (status.disabled) {
958
+ mode = "bm25-only";
959
+ } else if (!status.available || embResults.length === 0 && !!status.lastError) {
960
+ mode = "hybrid-degraded";
961
+ embedderError = status.lastError;
962
+ } else {
963
+ mode = "hybrid";
964
+ }
915
965
  const merged = rrfMerge([bm25Results, embResults]);
916
- return merged.slice(0, effectiveLimit);
966
+ return {
967
+ engrams: merged.slice(0, effectiveLimit),
968
+ mode,
969
+ embedderError
970
+ };
917
971
  }
918
972
 
919
973
  // src/query-expansion.ts
@@ -1469,6 +1523,137 @@ function computePackHash(packDir) {
1469
1523
  return hash.digest("hex");
1470
1524
  }
1471
1525
 
1526
+ // src/store/remote-store.ts
1527
+ var RemoteStore = class {
1528
+ constructor(url, token, scope, opts = {}) {
1529
+ this.url = url;
1530
+ this.token = token;
1531
+ this.scope = scope;
1532
+ this.opts = opts;
1533
+ }
1534
+ cache = null;
1535
+ inFlight = null;
1536
+ get apiBase() {
1537
+ return this.url.replace(/\/sse\/?$/, "").replace(/\/$/, "") + "/api/v1";
1538
+ }
1539
+ get ttlMs() {
1540
+ return this.opts.ttlMs ?? 6e4;
1541
+ }
1542
+ headers(extra = {}) {
1543
+ return {
1544
+ Authorization: `Bearer ${this.token}`,
1545
+ Accept: "application/json",
1546
+ ...extra
1547
+ };
1548
+ }
1549
+ /**
1550
+ * Load all engrams visible to this token at this scope. Cached up to
1551
+ * ttlMs; in-flight calls deduplicate to avoid thundering-herd on
1552
+ * the remote when 5 things ask for engrams at once.
1553
+ */
1554
+ async load() {
1555
+ const now = Date.now();
1556
+ if (this.cache && now - this.cache.ts < this.ttlMs) return this.cache.engrams;
1557
+ if (this.inFlight) return this.inFlight;
1558
+ this.inFlight = (async () => {
1559
+ try {
1560
+ const all = [];
1561
+ let offset = 0;
1562
+ const limit = 200;
1563
+ const maxPages = 50;
1564
+ for (let i = 0; i < maxPages; i++) {
1565
+ const u = `${this.apiBase}/engrams?scope=${encodeURIComponent(this.scope)}&limit=${limit}&offset=${offset}`;
1566
+ const r = await fetch(u, { headers: this.headers() });
1567
+ if (!r.ok) {
1568
+ if (r.status >= 500) console.error(`[plur:remote-store] ${this.url} returned ${r.status} loading scope ${this.scope}`);
1569
+ break;
1570
+ }
1571
+ const body = await r.json();
1572
+ for (const row of body.rows) {
1573
+ const d = row.data ?? {};
1574
+ all.push({
1575
+ id: row.id,
1576
+ scope: row.scope,
1577
+ status: row.status,
1578
+ ...d
1579
+ });
1580
+ }
1581
+ if (all.length >= body.total_count || body.rows.length < limit) break;
1582
+ offset += limit;
1583
+ }
1584
+ this.cache = { ts: Date.now(), engrams: all };
1585
+ return all;
1586
+ } catch (err) {
1587
+ console.error(`[plur:remote-store] ${this.url} load failed: ${err.message}`);
1588
+ return this.cache?.engrams ?? [];
1589
+ } finally {
1590
+ this.inFlight = null;
1591
+ }
1592
+ })();
1593
+ return this.inFlight;
1594
+ }
1595
+ /**
1596
+ * Append a single engram to the remote store. POST /api/v1/engrams
1597
+ * carries statement + scope + domain + type — the server handles
1598
+ * ID assignment, content_hash, status.
1599
+ */
1600
+ async append(engram) {
1601
+ const body = JSON.stringify({
1602
+ statement: engram.statement,
1603
+ scope: engram.scope,
1604
+ domain: engram.domain,
1605
+ type: engram.type
1606
+ });
1607
+ const r = await fetch(`${this.apiBase}/engrams`, {
1608
+ method: "POST",
1609
+ headers: this.headers({ "Content-Type": "application/json" }),
1610
+ body
1611
+ });
1612
+ if (!r.ok) {
1613
+ const text = await r.text().catch(() => "");
1614
+ throw new Error(`Remote store append failed: ${r.status} ${text}`);
1615
+ }
1616
+ this.cache = null;
1617
+ }
1618
+ /**
1619
+ * `save(all)` — used by migrations to bulk-replace. Not supported
1620
+ * on remote: the server keeps an audit trail and we don't want a
1621
+ * single client to be able to nuke + replace the whole store. Throws.
1622
+ */
1623
+ async save(_engrams) {
1624
+ throw new Error("Remote store does not support bulk save() \u2014 use append()/remove() per engram");
1625
+ }
1626
+ async getById(id) {
1627
+ try {
1628
+ const r = await fetch(`${this.apiBase}/engrams/${encodeURIComponent(id)}`, { headers: this.headers() });
1629
+ if (r.status === 404) return null;
1630
+ if (!r.ok) return null;
1631
+ const row = await r.json();
1632
+ return { id: row.id, scope: row.scope, status: row.status, ...row.data ?? {} };
1633
+ } catch {
1634
+ return null;
1635
+ }
1636
+ }
1637
+ /** Remove → DELETE /api/v1/engrams/:id (server soft-retires). */
1638
+ async remove(id) {
1639
+ const r = await fetch(`${this.apiBase}/engrams/${encodeURIComponent(id)}`, {
1640
+ method: "DELETE",
1641
+ headers: this.headers()
1642
+ });
1643
+ if (!r.ok) return false;
1644
+ this.cache = null;
1645
+ return true;
1646
+ }
1647
+ async count(filter) {
1648
+ const all = await this.load();
1649
+ if (filter?.status) return all.filter((e) => e.status === filter.status).length;
1650
+ return all.length;
1651
+ }
1652
+ async close() {
1653
+ this.cache = null;
1654
+ }
1655
+ };
1656
+
1472
1657
  // src/meta/sanitize.ts
1473
1658
  function sanitizeForPrompt(text) {
1474
1659
  return text.replace(/```/g, "~~~").replace(/\n{3,}/g, "\n\n").replace(/^(system|assistant|user):/gim, "$1 -").slice(0, 2e3);
@@ -1585,7 +1770,7 @@ async function computeSimilarityMatrix(templates) {
1585
1770
  const n = templates.length;
1586
1771
  const matrix = Array.from({ length: n }, () => Array(n).fill(0));
1587
1772
  try {
1588
- const { embed, cosineSimilarity } = await import("./embeddings-ZRT6IRPA.js");
1773
+ const { embed, cosineSimilarity } = await import("./embeddings-3EXLC3EH.js");
1589
1774
  const embeddings = [];
1590
1775
  for (const t of templates) {
1591
1776
  embeddings.push(await embed(t));
@@ -2936,6 +3121,192 @@ function isNewer(a, b) {
2936
3121
  return false;
2937
3122
  }
2938
3123
 
3124
+ // src/schemas/capsule.ts
3125
+ import { z as z3 } from "zod";
3126
+ var CAPSULE_MAGIC = Buffer.from([80, 76, 85, 82]);
3127
+ var CAPSULE_MAGIC_HEX = "50 4c 55 52";
3128
+ var FORMAT_VERSION_V1 = 1;
3129
+ var SUPPORTED_FORMAT_VERSIONS = [FORMAT_VERSION_V1];
3130
+ var CAPSULE_FLAGS = {
3131
+ SIGNED: 1 << 0,
3132
+ COMPRESSED: 1 << 1
3133
+ };
3134
+ var CAPSULE_FLAG_RESERVED_MASK = 65532;
3135
+ var PREAMBLE_LEN = 12;
3136
+ var CAPSULE_SIZE_LIMITS = {
3137
+ SOFT_BYTES: 100 * 1024 * 1024,
3138
+ HARD_BYTES: 1024 * 1024 * 1024
3139
+ };
3140
+ var ED25519_SIG_LEN = 64;
3141
+ var ManifestSummarySchema = z3.object({
3142
+ name: z3.string().min(1),
3143
+ version: z3.string().min(1),
3144
+ creator: z3.string().optional(),
3145
+ engram_count: z3.number().int().min(0),
3146
+ domain: z3.string().optional(),
3147
+ license: z3.string().default("cc-by-sa-4.0")
3148
+ });
3149
+ var PayloadDescriptorSchema = z3.object({
3150
+ compression: z3.enum(["gzip", "none"]),
3151
+ size_compressed: z3.number().int().min(0),
3152
+ size_uncompressed: z3.number().int().min(0),
3153
+ sha256: z3.string().regex(/^[0-9a-f]{64}$/, "sha256 must be 64 lowercase hex chars")
3154
+ });
3155
+ var ProducerSchema = z3.object({
3156
+ tool: z3.string().min(1),
3157
+ version: z3.string().min(1),
3158
+ agent_id: z3.string().optional()
3159
+ });
3160
+ var SignerSchema = z3.object({
3161
+ algo: z3.literal("ed25519"),
3162
+ public_key: z3.string().min(1),
3163
+ key_id: z3.string().optional()
3164
+ });
3165
+ var CapsuleHeaderSchema = z3.object({
3166
+ schema: z3.literal("plur.capsule/1"),
3167
+ product_type: z3.enum(["engram-pack", "skill"]),
3168
+ manifest_summary: ManifestSummarySchema,
3169
+ payload: PayloadDescriptorSchema,
3170
+ created_at: z3.string().datetime({ offset: true }),
3171
+ producer: ProducerSchema,
3172
+ signer: SignerSchema.nullable().default(null)
3173
+ });
3174
+ function parseCapsulePreamble(buf) {
3175
+ if (buf.length < PREAMBLE_LEN) {
3176
+ throw new Error(`capsule: truncated preamble (got ${buf.length} bytes, need ${PREAMBLE_LEN})`);
3177
+ }
3178
+ if (buf.compare(CAPSULE_MAGIC, 0, 4, 0, 4) !== 0) {
3179
+ throw new Error(`capsule: bad magic (expected ${CAPSULE_MAGIC_HEX})`);
3180
+ }
3181
+ const formatVersion = buf.readUInt16LE(4);
3182
+ if (!SUPPORTED_FORMAT_VERSIONS.includes(formatVersion)) {
3183
+ throw new Error(`capsule: unsupported FormatVersion 0x${formatVersion.toString(16).padStart(4, "0")}`);
3184
+ }
3185
+ const flags = buf.readUInt16LE(6);
3186
+ if ((flags & CAPSULE_FLAG_RESERVED_MASK) !== 0) {
3187
+ throw new Error(`capsule: reserved flag bits set (flags=0x${flags.toString(16).padStart(4, "0")})`);
3188
+ }
3189
+ const headerLen = buf.readUInt32LE(8);
3190
+ if (headerLen === 0) {
3191
+ throw new Error("capsule: HeaderLen must be > 0");
3192
+ }
3193
+ if (headerLen > CAPSULE_SIZE_LIMITS.HARD_BYTES) {
3194
+ throw new Error(`capsule: HeaderLen ${headerLen} exceeds hard size limit`);
3195
+ }
3196
+ return { formatVersion, flags, headerLen };
3197
+ }
3198
+ function serializeCapsulePreamble(p) {
3199
+ const buf = Buffer.alloc(PREAMBLE_LEN);
3200
+ CAPSULE_MAGIC.copy(buf, 0);
3201
+ buf.writeUInt16LE(p.formatVersion, 4);
3202
+ buf.writeUInt16LE(p.flags, 6);
3203
+ buf.writeUInt32LE(p.headerLen, 8);
3204
+ return buf;
3205
+ }
3206
+ function hasFlag(flags, flag) {
3207
+ return (flags & flag) === flag;
3208
+ }
3209
+
3210
+ // src/capsule.ts
3211
+ import { createHash as createHash2 } from "crypto";
3212
+ function writeCapsule(opts) {
3213
+ const compression = opts.compression ?? "gzip";
3214
+ const productType = opts.productType ?? "engram-pack";
3215
+ const createdAt = opts.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
3216
+ const signer = opts.signer ?? null;
3217
+ if (signer !== null && (!opts.signature || opts.signature.length !== ED25519_SIG_LEN)) {
3218
+ throw new Error(`writeCapsule: signer set but signature missing or not ${ED25519_SIG_LEN} bytes`);
3219
+ }
3220
+ if (signer === null && opts.signature) {
3221
+ throw new Error("writeCapsule: signature provided without signer \u2014 refuse ambiguous envelope");
3222
+ }
3223
+ const sha256 = createHash2("sha256").update(opts.payload).digest("hex");
3224
+ const sizeCompressed = opts.payload.length;
3225
+ const sizeUncompressed = opts.sizeUncompressed ?? sizeCompressed;
3226
+ const header = CapsuleHeaderSchema.parse({
3227
+ schema: "plur.capsule/1",
3228
+ product_type: productType,
3229
+ manifest_summary: opts.manifestSummary,
3230
+ payload: {
3231
+ compression,
3232
+ size_compressed: sizeCompressed,
3233
+ size_uncompressed: sizeUncompressed,
3234
+ sha256
3235
+ },
3236
+ created_at: createdAt,
3237
+ producer: opts.producer,
3238
+ signer
3239
+ });
3240
+ const headerJson = Buffer.from(JSON.stringify(header), "utf-8");
3241
+ let flags = 0;
3242
+ if (compression === "gzip") flags |= CAPSULE_FLAGS.COMPRESSED;
3243
+ if (signer !== null) flags |= CAPSULE_FLAGS.SIGNED;
3244
+ const preamble = serializeCapsulePreamble({
3245
+ formatVersion: FORMAT_VERSION_V1,
3246
+ flags,
3247
+ headerLen: headerJson.length
3248
+ });
3249
+ const totalLen = PREAMBLE_LEN + headerJson.length + opts.payload.length + (signer !== null ? ED25519_SIG_LEN : 0);
3250
+ if (totalLen > CAPSULE_SIZE_LIMITS.HARD_BYTES) {
3251
+ throw new Error(`writeCapsule: capsule size ${totalLen} exceeds hard limit`);
3252
+ }
3253
+ const parts = [preamble, headerJson, opts.payload];
3254
+ if (signer !== null && opts.signature) parts.push(opts.signature);
3255
+ return Buffer.concat(parts, totalLen);
3256
+ }
3257
+ function readCapsule(buf) {
3258
+ if (buf.length > CAPSULE_SIZE_LIMITS.HARD_BYTES) {
3259
+ throw new Error(`readCapsule: capsule size ${buf.length} exceeds hard limit`);
3260
+ }
3261
+ const preamble = parseCapsulePreamble(buf);
3262
+ const headerStart = PREAMBLE_LEN;
3263
+ const headerEnd = headerStart + preamble.headerLen;
3264
+ if (buf.length < headerEnd) {
3265
+ throw new Error(`readCapsule: truncated header (need ${headerEnd} bytes, got ${buf.length})`);
3266
+ }
3267
+ const headerJson = buf.subarray(headerStart, headerEnd).toString("utf-8");
3268
+ let parsedHeader;
3269
+ try {
3270
+ parsedHeader = JSON.parse(headerJson);
3271
+ } catch (err) {
3272
+ throw new Error(`readCapsule: malformed header JSON \u2014 ${err.message}`);
3273
+ }
3274
+ const header = CapsuleHeaderSchema.parse(parsedHeader);
3275
+ const isSigned = hasFlag(preamble.flags, CAPSULE_FLAGS.SIGNED);
3276
+ const sigLen = isSigned ? ED25519_SIG_LEN : 0;
3277
+ const payloadEnd = buf.length - sigLen;
3278
+ if (payloadEnd < headerEnd) {
3279
+ throw new Error("readCapsule: payload region underflow");
3280
+ }
3281
+ const payload = buf.subarray(headerEnd, payloadEnd);
3282
+ const signature = isSigned ? buf.subarray(payloadEnd) : null;
3283
+ if (payload.length !== header.payload.size_compressed) {
3284
+ throw new Error(
3285
+ `readCapsule: payload size mismatch (header=${header.payload.size_compressed}, actual=${payload.length})`
3286
+ );
3287
+ }
3288
+ const actualSha = createHash2("sha256").update(payload).digest("hex");
3289
+ if (actualSha !== header.payload.sha256) {
3290
+ throw new Error(`readCapsule: integrity mismatch (header=${header.payload.sha256}, actual=${actualSha})`);
3291
+ }
3292
+ const compressionFlagSet = hasFlag(preamble.flags, CAPSULE_FLAGS.COMPRESSED);
3293
+ const headerSaysCompressed = header.payload.compression === "gzip";
3294
+ if (compressionFlagSet !== headerSaysCompressed) {
3295
+ throw new Error(
3296
+ `readCapsule: COMPRESSED flag (${compressionFlagSet}) disagrees with header.payload.compression (${header.payload.compression})`
3297
+ );
3298
+ }
3299
+ return { header, payload: Buffer.from(payload), signature: signature ? Buffer.from(signature) : null };
3300
+ }
3301
+ function verifyCapsuleIntegrity(buf) {
3302
+ try {
3303
+ readCapsule(buf);
3304
+ return true;
3305
+ } catch {
3306
+ return false;
3307
+ }
3308
+ }
3309
+
2939
3310
  // src/index.ts
2940
3311
  var COMMITMENT_MULTIPLIER = {
2941
3312
  locked: 1,
@@ -2973,6 +3344,9 @@ var Plur = class {
2973
3344
  if (this.config.index) {
2974
3345
  this.indexedStorage = new IndexedStorage(this.paths.engrams, this.paths.db, this.config.stores);
2975
3346
  }
3347
+ if (this.config.embeddings?.enabled === false) {
3348
+ setEmbeddingsEnabled(false, "embeddings disabled in config.yaml (embeddings.enabled = false)");
3349
+ }
2976
3350
  }
2977
3351
  /**
2978
3352
  * Load engrams from primary store + all configured stores, with mtime-based caching.
@@ -2984,7 +3358,7 @@ var Plur = class {
2984
3358
  const stores = this.config.stores ?? [];
2985
3359
  const all = [...primary];
2986
3360
  for (const store of stores) {
2987
- const storeEngrams = this._loadCached(store.path);
3361
+ const storeEngrams = store.url ? this._loadRemoteCached(store) : this._loadCached(store.path);
2988
3362
  const prefix = storePrefix(store.scope);
2989
3363
  for (const e of storeEngrams) {
2990
3364
  if (e.scope !== "global" && e.scope !== store.scope && !e.scope.startsWith(store.scope)) {
@@ -3014,19 +3388,45 @@ var Plur = class {
3014
3388
  return all;
3015
3389
  }
3016
3390
  /** Load engrams from a path with mtime-based caching */
3017
- _loadCached(path4) {
3391
+ _loadCached(path3) {
3018
3392
  let mtime;
3019
3393
  try {
3020
- mtime = fs4.statSync(path4, { bigint: true }).mtimeNs;
3394
+ mtime = fs4.statSync(path3, { bigint: true }).mtimeNs;
3021
3395
  } catch {
3022
3396
  return [];
3023
3397
  }
3024
- const cached = this._engramCache.get(path4);
3398
+ const cached = this._engramCache.get(path3);
3025
3399
  if (cached && cached.mtime === mtime) return cached.engrams;
3026
- const engrams = loadEngrams(path4);
3027
- this._engramCache.set(path4, { mtime, engrams });
3400
+ const engrams = loadEngrams(path3);
3401
+ this._engramCache.set(path3, { mtime, engrams });
3028
3402
  return engrams;
3029
3403
  }
3404
+ /**
3405
+ * Per-instance pool of RemoteStore drivers, keyed by url+scope.
3406
+ * RemoteStore holds its own internal TTL cache so repeated load()
3407
+ * within ttlMs returns the same array without a network call.
3408
+ *
3409
+ * Note `_loadAllEngrams` is sync but RemoteStore.load() is async.
3410
+ * We bridge that by returning whatever's in the driver's cache
3411
+ * synchronously and triggering a background refresh on cache miss.
3412
+ * The first call after server start returns [] for that store; the
3413
+ * call after the first refresh sees the data. For our pilot this
3414
+ * is acceptable — recall is expected to be tried more than once
3415
+ * in any real session.
3416
+ */
3417
+ _remoteStores = /* @__PURE__ */ new Map();
3418
+ _loadRemoteCached(store) {
3419
+ const key = `${store.url}::${store.scope}`;
3420
+ let driver = this._remoteStores.get(key);
3421
+ if (!driver) {
3422
+ driver = new RemoteStore(store.url, store.token ?? "", store.scope);
3423
+ this._remoteStores.set(key, driver);
3424
+ }
3425
+ const cached = driver.cache;
3426
+ void driver.load().catch(() => {
3427
+ });
3428
+ return cached?.engrams ?? [];
3429
+ }
3030
3430
  /**
3031
3431
  * Write engrams to disk and invalidate the cache for that path.
3032
3432
  *
@@ -3038,9 +3438,9 @@ var Plur = class {
3038
3438
  * invalidation on write removes the filesystem as a source of cache
3039
3439
  * freshness and closes the race. See issue #25.
3040
3440
  */
3041
- _writeEngrams(path4, engrams) {
3042
- saveEngrams(path4, engrams);
3043
- this._engramCache.delete(path4);
3441
+ _writeEngrams(path3, engrams) {
3442
+ saveEngrams(path3, engrams);
3443
+ this._engramCache.delete(path3);
3044
3444
  }
3045
3445
  /** Find which store owns an engram by ID. For namespaced IDs, strips prefix to find in store. */
3046
3446
  _findEngramStore(id) {
@@ -3050,6 +3450,7 @@ var Plur = class {
3050
3450
  }
3051
3451
  const stores = this.config.stores ?? [];
3052
3452
  for (const store of stores) {
3453
+ if (!store.path) continue;
3053
3454
  const prefix = storePrefix(store.scope);
3054
3455
  const nsPattern = new RegExp(`^(ENG|ABS|META)-${prefix}-`);
3055
3456
  if (nsPattern.test(id)) {
@@ -3168,7 +3569,8 @@ var Plur = class {
3168
3569
  narrower: [],
3169
3570
  related: [],
3170
3571
  conflicts: conflictIds
3171
- } : void 0
3572
+ } : void 0,
3573
+ pinned: context?.pinned === true ? true : void 0
3172
3574
  };
3173
3575
  engrams.push(engram);
3174
3576
  this._writeEngrams(this.paths.engrams, engrams);
@@ -3201,12 +3603,12 @@ var Plur = class {
3201
3603
  }
3202
3604
  /** Async learn with LLM-driven deduplication (Ideas 1+2+19). */
3203
3605
  async learnAsync(statement, context) {
3204
- const { learnAsync: learnAsyncImpl } = await import("./learn-async-R5KZ5EWF.js");
3606
+ const { learnAsync: learnAsyncImpl } = await import("./learn-async-IISWD7HC.js");
3205
3607
  return learnAsyncImpl(this._learnAsyncDeps(), statement, context);
3206
3608
  }
3207
3609
  /** Batch learn with LLM dedup. */
3208
3610
  async learnBatch(statements, llm) {
3209
- const { learnBatch: learnBatchImpl } = await import("./learn-async-R5KZ5EWF.js");
3611
+ const { learnBatch: learnBatchImpl } = await import("./learn-async-IISWD7HC.js");
3210
3612
  return learnBatchImpl(this._learnAsyncDeps(), statements, llm);
3211
3613
  }
3212
3614
  /**
@@ -3247,6 +3649,26 @@ var Plur = class {
3247
3649
  this._reactivateResults(results);
3248
3650
  return results;
3249
3651
  }
3652
+ /**
3653
+ * Hybrid search with diagnostic metadata — returns both the engrams and
3654
+ * whether embeddings actually contributed (mode: "hybrid" vs "hybrid-degraded").
3655
+ * Use this when you want to surface degraded-mode warnings to users.
3656
+ */
3657
+ async recallHybridWithMeta(query, options) {
3658
+ const filtered = this._filterEngrams(options);
3659
+ const limit = options?.limit ?? 20;
3660
+ const result = await hybridSearchWithMeta(filtered, query, limit, this.paths.root);
3661
+ this._reactivateResults(result.engrams);
3662
+ return result;
3663
+ }
3664
+ /** Inspect embedder availability without forcing a load. */
3665
+ embedderStatus() {
3666
+ return embedderStatus();
3667
+ }
3668
+ /** Reset cached embedder failure state — next call will retry the model load. */
3669
+ resetEmbedder() {
3670
+ resetEmbedder();
3671
+ }
3250
3672
  /** Embedding search returning {engram, score}[] with cosine similarity scores. Async, no API calls. */
3251
3673
  async similaritySearch(query, options) {
3252
3674
  const filtered = this._filterEngrams(options);
@@ -3375,11 +3797,16 @@ var Plur = class {
3375
3797
  let embeddingBoosts;
3376
3798
  try {
3377
3799
  const engrams = this._loadAllEngrams().filter((e) => e.status === "active");
3378
- const results = await embeddingSearch(engrams, task, engrams.length, this.paths.root);
3800
+ const results = await embeddingSearchWithScores(
3801
+ engrams,
3802
+ task,
3803
+ engrams.length,
3804
+ this.paths.root
3805
+ );
3379
3806
  if (results.length > 0) {
3380
3807
  embeddingBoosts = /* @__PURE__ */ new Map();
3381
- for (let i = 0; i < results.length; i++) {
3382
- embeddingBoosts.set(results[i].id, 1 / (1 + i * 0.1));
3808
+ for (const r of results) {
3809
+ embeddingBoosts.set(r.engram.id, r.score);
3383
3810
  }
3384
3811
  }
3385
3812
  } catch {
@@ -3510,6 +3937,28 @@ var Plur = class {
3510
3937
  return true;
3511
3938
  });
3512
3939
  }
3940
+ /**
3941
+ * Toggle the always-load (pinned) flag for an engram.
3942
+ * Returns the updated engram on success, null if not found.
3943
+ */
3944
+ setPinned(id, pinned) {
3945
+ return withLock(this.paths.engrams, () => {
3946
+ const engrams = loadEngrams(this.paths.engrams);
3947
+ const idx = engrams.findIndex((e2) => e2.id === id);
3948
+ if (idx === -1) return null;
3949
+ const e = engrams[idx];
3950
+ const updated = { ...e, pinned: pinned === true ? true : void 0 };
3951
+ engrams[idx] = updated;
3952
+ this._writeEngrams(this.paths.engrams, engrams);
3953
+ this._syncIndex();
3954
+ return updated;
3955
+ });
3956
+ }
3957
+ /** List engrams that have pinned: true. */
3958
+ listPinned() {
3959
+ const all = this._loadAllEngrams();
3960
+ return all.filter((e) => e.pinned === true && e.status === "active");
3961
+ }
3513
3962
  /** Set engram status to 'retired'. Supports primary and store engrams. */
3514
3963
  forget(id, reason) {
3515
3964
  const foundInPrimary = withLock(this.paths.engrams, () => {
@@ -3846,17 +4295,38 @@ Generate an improved version of the procedure that prevents this failure. Return
3846
4295
  versioned_engram_count: versionedCount
3847
4296
  };
3848
4297
  }
3849
- /** Register an additional engram store. */
4298
+ /**
4299
+ * Register an additional engram store.
4300
+ *
4301
+ * Two shapes — exactly one of `pathOrUrl` semantics applies:
4302
+ * - filesystem (default): pass a path. `options.url` undefined.
4303
+ * - remote (PLUR Enterprise / any compatible REST API):
4304
+ * pass any string for the first arg (it goes into a slot we
4305
+ * never read), set `options.url` + `options.token`.
4306
+ *
4307
+ * Backwards compatible: existing call sites that pass a filesystem
4308
+ * path keep working.
4309
+ */
3850
4310
  addStore(storePath, scope, options) {
3851
4311
  const config = loadConfig(this.paths.config);
3852
- const existing = config.stores?.find((s) => s.path === storePath);
4312
+ const isRemote = Boolean(options?.url);
4313
+ const dedupKey = isRemote ? options.url : storePath;
4314
+ const existing = config.stores?.find((s) => isRemote ? s.url === dedupKey : s.path === dedupKey);
3853
4315
  if (existing) return;
3854
- const stores = [...config.stores ?? [], {
4316
+ const newEntry = isRemote ? {
4317
+ url: options.url,
4318
+ token: options.token,
4319
+ scope,
4320
+ shared: options?.shared ?? true,
4321
+ // remote stores are shared by definition
4322
+ readonly: options?.readonly ?? false
4323
+ } : {
3855
4324
  path: storePath,
3856
4325
  scope,
3857
4326
  shared: options?.shared ?? false,
3858
4327
  readonly: options?.readonly ?? false
3859
- }];
4328
+ };
4329
+ const stores = [...config.stores ?? [], newEntry];
3860
4330
  let configData = {};
3861
4331
  try {
3862
4332
  const raw = fs4.readFileSync(this.paths.config, "utf8");
@@ -3875,20 +4345,19 @@ Generate an improved version of the procedure that prevents this failure. Return
3875
4345
  autoDiscoverStores(cwd) {
3876
4346
  const startDir = cwd || process.cwd();
3877
4347
  const discovered = [];
3878
- const tmpDir = os.tmpdir();
4348
+ const tmpDir = tmpdir();
3879
4349
  if (this.paths.root.startsWith(tmpDir) || this.paths.root.startsWith("/tmp/")) {
3880
4350
  return discovered;
3881
4351
  }
3882
4352
  const knownPaths = new Set((this.config.stores ?? []).map((s) => s.path));
3883
- const primaryDir = path3.dirname(this.paths.engrams);
4353
+ const primaryDir = dirname2(this.paths.engrams);
3884
4354
  let dir = startDir;
3885
- const { join: join5, dirname: dirname3, basename: basename2 } = path3;
3886
4355
  const visited = /* @__PURE__ */ new Set();
3887
4356
  while (dir && !visited.has(dir)) {
3888
4357
  visited.add(dir);
3889
4358
  const candidate = join5(dir, ".plur", "engrams.yaml");
3890
4359
  if (join5(dir, ".plur") === primaryDir) {
3891
- dir = dirname3(dir);
4360
+ dir = dirname2(dir);
3892
4361
  continue;
3893
4362
  }
3894
4363
  if (fs4.existsSync(candidate) && !knownPaths.has(candidate)) {
@@ -3907,7 +4376,7 @@ Generate an improved version of the procedure that prevents this failure. Return
3907
4376
  logger.info(`Auto-discovered project store: ${candidate} (${scope})`);
3908
4377
  }
3909
4378
  if (fs4.existsSync(join5(dir, ".git"))) break;
3910
- const parent = dirname3(dir);
4379
+ const parent = dirname2(dir);
3911
4380
  if (parent === dir) break;
3912
4381
  dir = parent;
3913
4382
  }
@@ -3925,29 +4394,57 @@ Generate an improved version of the procedure that prevents this failure. Return
3925
4394
  };
3926
4395
  const additional = stores.map((s) => {
3927
4396
  let count = 0;
3928
- try {
3929
- count = this._loadCached(s.path).filter((e) => e.status !== "retired").length;
3930
- } catch {
4397
+ if (s.url) {
4398
+ try {
4399
+ count = this._loadRemoteCached(s).filter((e) => e.status !== "retired").length;
4400
+ } catch {
4401
+ }
4402
+ } else if (s.path) {
4403
+ try {
4404
+ count = this._loadCached(s.path).filter((e) => e.status !== "retired").length;
4405
+ } catch {
4406
+ }
3931
4407
  }
3932
- return { ...s, engram_count: count };
4408
+ return {
4409
+ path: s.path,
4410
+ url: s.url,
4411
+ scope: s.scope,
4412
+ shared: s.shared,
4413
+ readonly: s.readonly,
4414
+ engram_count: count
4415
+ };
3933
4416
  });
3934
4417
  return [primary, ...additional];
3935
4418
  }
3936
4419
  };
3937
4420
  export {
3938
4421
  ALL_MIGRATIONS,
4422
+ CAPSULE_FLAGS,
4423
+ CAPSULE_FLAG_RESERVED_MASK,
4424
+ CAPSULE_MAGIC,
4425
+ CAPSULE_MAGIC_HEX,
4426
+ CAPSULE_SIZE_LIMITS,
3939
4427
  COMMITMENT_MULTIPLIER,
3940
4428
  CURRENT_SCHEMA_VERSION,
4429
+ CapsuleHeaderSchema,
3941
4430
  DomainCoverageSchema,
4431
+ ED25519_SIG_LEN,
3942
4432
  EvidenceEntrySchema,
4433
+ FORMAT_VERSION_V1,
3943
4434
  FalsificationSchema,
3944
4435
  HierarchyPositionSchema,
3945
4436
  IndexedStorage,
4437
+ ManifestSummarySchema,
3946
4438
  MetaConfidenceSchema,
3947
4439
  MetaFieldSchema,
3948
4440
  PLATITUDE_PATTERNS,
4441
+ PREAMBLE_LEN,
4442
+ PayloadDescriptorSchema,
3949
4443
  Plur,
4444
+ ProducerSchema,
4445
+ SUPPORTED_FORMAT_VERSIONS,
3950
4446
  SessionBreadcrumbs,
4447
+ SignerSchema,
3951
4448
  SqliteStore,
3952
4449
  StructuralTemplateSchema,
3953
4450
  YamlStore,
@@ -3986,6 +4483,7 @@ export {
3986
4483
  getCachedUpdateCheck,
3987
4484
  getProfileForInjection,
3988
4485
  getSchemaVersion,
4486
+ hasFlag,
3989
4487
  isPlatitude,
3990
4488
  listHistoryMonths,
3991
4489
  loadProfileCache,
@@ -3994,8 +4492,10 @@ export {
3994
4492
  needsSummary,
3995
4493
  normalizeStatement,
3996
4494
  organizeHierarchy,
4495
+ parseCapsulePreamble,
3997
4496
  parseDedupResponse,
3998
4497
  profileNeedsRegeneration,
4498
+ readCapsule,
3999
4499
  readHistory,
4000
4500
  readHistoryForEngram,
4001
4501
  recallAuto,
@@ -4005,9 +4505,12 @@ export {
4005
4505
  saveProfileCache,
4006
4506
  selectModel,
4007
4507
  selectModelForOperation,
4508
+ serializeCapsulePreamble,
4008
4509
  setSchemaVersion,
4009
4510
  strengthToStatus,
4010
4511
  tokenSimilarity,
4011
4512
  validateMetaEngram,
4012
- withAsyncLock
4513
+ verifyCapsuleIntegrity,
4514
+ withAsyncLock,
4515
+ writeCapsule
4013
4516
  };