moflo 4.9.30 → 4.9.31

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.
@@ -132,6 +132,20 @@ export class FlagEmbedding {
132
132
  const session = await InferenceSession.create(modelPath, {
133
133
  executionProviders: ['cpu'],
134
134
  graphOptimizationLevel: 'all',
135
+ // Suppress ORT's WARNING-level chatter on session bring-up. ORT 1.26.0
136
+ // emits a `[W:onnxruntime ... GetPciBusId] Skipping pci_bus_id` line on
137
+ // Linux Azure VMs whose `/sys/devices/...` filenames don't match the
138
+ // `[0-9a-f]+:[0-9a-f]+:[0-9a-f]+.[0-9a-f]+` PCI pattern; the warning
139
+ // is harmless (we run on the CPU EP only) but leaks to stderr and
140
+ // confuses users into thinking moflo is broken. 0=verbose, 1=info,
141
+ // 2=warning (default), 3=error, 4=fatal — error is the right level
142
+ // because session bring-up genuine failures still surface.
143
+ //
144
+ // Re-audit when bumping fastembed or onnxruntime-node: ORT
145
+ // occasionally promotes deprecation / model-compatibility notices to
146
+ // WARNING that would now be hidden. If a model upgrade ever lands
147
+ // alongside this suppression, drop to 2 once to scan the output.
148
+ logSeverityLevel: 3,
135
149
  });
136
150
  return new FlagEmbedding(tokenizer, session);
137
151
  }
@@ -112,6 +112,30 @@ export async function bridgeStoreEntry(options) {
112
112
  const now = Date.now();
113
113
  const guardResult = await guardValidate(registry, 'store', { key, namespace, size: value.length });
114
114
  if (!guardResult.allowed) {
115
+ // Dedupe rejection means the same `(op, params)` write just succeeded
116
+ // — the caller's data is already durable. Look up the existing row so
117
+ // we can return its id with success:true; this matches what the
118
+ // dedupe semantically means (a no-op, not a failure). Other rejection
119
+ // reasons (rate limit, etc.) remain real failures. Match the literal
120
+ // reason string rather than a substring regex so a future rejection
121
+ // worded with "duplicate mutation" but different semantics doesn't
122
+ // get silently swallowed.
123
+ if (guardResult.reason === 'duplicate mutation within dedupe window') {
124
+ let existingId = null;
125
+ const probe = ctx.db.prepare(`SELECT id FROM memory_entries WHERE namespace = ? AND key = ? AND status = 'active' LIMIT 1`);
126
+ try {
127
+ probe.bind([namespace, key]);
128
+ if (probe.step()) {
129
+ existingId = String(probe.getAsObject().id);
130
+ }
131
+ }
132
+ finally {
133
+ probe.free();
134
+ }
135
+ if (existingId) {
136
+ return { success: true, id: existingId };
137
+ }
138
+ }
115
139
  return { success: false, id, error: `MutationGuard rejected: ${guardResult.reason}` };
116
140
  }
117
141
  const resolved = await resolveBridgeEmbedding(value, options.precomputedEmbedding, options.generateEmbeddingFlag, namespace);
@@ -120,6 +144,48 @@ export async function bridgeStoreEntry(options) {
120
144
  }
121
145
  const { json: embeddingJson, dimensions, model } = resolved;
122
146
  const embeddingResponse = embeddingResponseFrom(resolved);
147
+ // Idempotency guard, mirrors the one in `memory-initializer.ts`'s raw-
148
+ // sql.js fallback. When the daemon route just wrote this exact row but
149
+ // the client missed the ack, we land here with the row already on disk;
150
+ // a plain INSERT would trip UNIQUE and surface as `[moflo] bridge
151
+ // operation failed:` stderr noise even though the data is durable.
152
+ // Probe first so withDb never sees the throw.
153
+ //
154
+ // Limitations carried forward: only `content` is compared, not `tags`
155
+ // or `ttl`. The targeted scenario is the same caller's request being
156
+ // processed twice (daemon write + client retry), where every option is
157
+ // identical by definition — a different caller varying `tags` after a
158
+ // missed-ack would still see this as an idempotent no-op rather than
159
+ // an update. `cached: false, attested: false` because the prior writer
160
+ // already ran post-persist bookkeeping; this process's in-memory cache
161
+ // stays cold for one retrieve until the read path warms it (perf only,
162
+ // not correctness).
163
+ if (!options.upsert) {
164
+ let existingId = null;
165
+ let existingContent = null;
166
+ const probe = ctx.db.prepare(`SELECT id, content FROM memory_entries WHERE namespace = ? AND key = ? AND status = 'active' LIMIT 1`);
167
+ try {
168
+ probe.bind([namespace, key]);
169
+ if (probe.step()) {
170
+ const row = probe.getAsObject();
171
+ existingId = String(row.id);
172
+ existingContent = row.content;
173
+ }
174
+ }
175
+ finally {
176
+ probe.free();
177
+ }
178
+ if (existingId && existingContent === value) {
179
+ return {
180
+ success: true,
181
+ id: existingId,
182
+ embedding: embeddingResponse,
183
+ guarded: true,
184
+ cached: false,
185
+ attested: false,
186
+ };
187
+ }
188
+ }
123
189
  const insertSql = options.upsert
124
190
  ? `INSERT OR REPLACE INTO memory_entries (
125
191
  id, key, namespace, content, type,
@@ -1650,6 +1650,42 @@ export async function storeEntry(options) {
1650
1650
  embeddingModel = embResult.model;
1651
1651
  }
1652
1652
  }
1653
+ // Idempotency guard. By the time we reach the raw-sql.js fallback, an
1654
+ // earlier write attempt — daemon route via `tryDaemonStore`, or bridge
1655
+ // via `bridgeStoreEntry` — may have already persisted this exact row to
1656
+ // disk. If a post-persist throw escaped the bridge's inner guards (#994,
1657
+ // #982), `bridgeStoreEntry` returned null and we landed here. Re-running
1658
+ // a plain INSERT would then trip the UNIQUE constraint on `(namespace,
1659
+ // key)` and surface as `exit 1` even though the data is durable on disk
1660
+ // — exactly the cascade described in `bridge-entries.ts:205`. If the
1661
+ // existing row matches the value the caller asked us to write, treat
1662
+ // this as a successful no-op and propagate the existing id instead of
1663
+ // re-inserting. If the content differs, fall through to INSERT — the
1664
+ // UNIQUE error is then a real "key already taken with other content"
1665
+ // signal that the caller deserves to see.
1666
+ if (!upsert) {
1667
+ let existingRow = null;
1668
+ const probe = db.prepare(`SELECT id, content FROM memory_entries WHERE namespace = ? AND key = ? AND status = 'active' LIMIT 1`);
1669
+ try {
1670
+ probe.bind([namespace, key]);
1671
+ if (probe.step()) {
1672
+ existingRow = probe.getAsObject();
1673
+ }
1674
+ }
1675
+ finally {
1676
+ probe.free();
1677
+ }
1678
+ if (existingRow && existingRow.content === value) {
1679
+ db.close();
1680
+ return {
1681
+ success: true,
1682
+ id: String(existingRow.id),
1683
+ embedding: embeddingJson
1684
+ ? { dimensions: embeddingDimensions, model: embeddingModel }
1685
+ : undefined,
1686
+ };
1687
+ }
1688
+ }
1653
1689
  // Insert or update entry (upsert mode uses REPLACE)
1654
1690
  const insertSql = upsert
1655
1691
  ? `INSERT OR REPLACE INTO memory_entries (
@@ -1,24 +1,31 @@
1
1
  /**
2
2
  * Credential Validation
3
3
  *
4
- * Lightweight, no-config shape checks applied to values pulled from the
5
- * encrypted credential store before they are promoted to `process.env`.
4
+ * Shape checks applied to values pulled from the encrypted credential store
5
+ * before they are promoted to `process.env`. Two layers:
6
6
  *
7
- * Two heuristics, both conservative only invalidate when there is
8
- * positive evidence the stored value is bad. Anything we can't classify
9
- * passes through unchanged.
7
+ * 1. **Author-declared format** (preferred): the YAML prereq sets
8
+ * `format: jwt`, and the validator enforces JWT shape + expiry. Any
9
+ * non-JWT value (e.g. a value with no dots) is rejected outright,
10
+ * catching the failure mode where a stored value isn't even a JWT
11
+ * and the spell would otherwise fail mid-cast with a 401.
10
12
  *
11
- * - JWT-shaped values (3 base64url segments) get their `exp` claim
12
- * parsed and compared to "now". An expired JWT is reported as such.
13
- * - Env keys ending in `_URL` must parse via the WHATWG `URL`
14
- * constructor and have a non-empty host.
13
+ * 2. **Conservative heuristics** (fallback when no format is declared):
14
+ * - JWT-shaped values (3 base64url segments) get their `exp` claim
15
+ * parsed and rejected when expired.
16
+ * - Env keys ending in `_URL` must parse via the WHATWG `URL`
17
+ * constructor and have a non-empty host.
18
+ * Anything else passes through.
15
19
  *
16
- * Story #1007: avoid silently reusing stale stored credentials (e.g.
17
- * Microsoft Graph access tokens, which expire in ~1h) so the resolver
18
- * can fall through to the prompt path and the user understands why.
20
+ * Story #1007: catch expired JWTs that survived past their TTL.
21
+ * Story #1009: extend to catch values that aren't even JWT-shaped when
22
+ * the prereq has declared `format: jwt`.
19
23
  */
20
24
  const VALID_JWT_SEGMENT = /^[A-Za-z0-9_-]+$/;
21
- export function validateStoredCredential(envKey, value) {
25
+ export function validateStoredCredential(envKey, value, format) {
26
+ if (format === 'jwt') {
27
+ return validateJwtFormat(value);
28
+ }
22
29
  if (envKey.endsWith('_URL')) {
23
30
  return validateUrlValue(value);
24
31
  }
@@ -27,6 +34,15 @@ export function validateStoredCredential(envKey, value) {
27
34
  }
28
35
  return { valid: true };
29
36
  }
37
+ function validateJwtFormat(value) {
38
+ if (!looksLikeJwt(value)) {
39
+ return {
40
+ valid: false,
41
+ reason: 'stored value is not a JWT (expected three base64url segments separated by ".")',
42
+ };
43
+ }
44
+ return validateJwtExpiry(value);
45
+ }
30
46
  function validateUrlValue(value) {
31
47
  try {
32
48
  const parsed = new URL(value);
@@ -72,6 +72,7 @@ export function compilePrerequisiteSpec(spec) {
72
72
  description: spec.description,
73
73
  promptOnMissing,
74
74
  envKey,
75
+ format: spec.format,
75
76
  };
76
77
  }
77
78
  function defaultHintForDetect(spec) {
@@ -205,7 +206,7 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
205
206
  const stored = await credentials.get(prereq.envKey);
206
207
  if (typeof stored !== 'string' || stored.length === 0)
207
208
  return;
208
- const validation = validateStoredCredential(prereq.envKey, stored);
209
+ const validation = validateStoredCredential(prereq.envKey, stored, prereq.format);
209
210
  if (!validation.valid) {
210
211
  rejectedFromStore.push({ envKey: prereq.envKey, reason: validation.reason });
211
212
  return;
@@ -6,6 +6,7 @@
6
6
  * deliberately small so step validation can delegate here.
7
7
  */
8
8
  const VALID_DETECT_TYPES = ['env', 'command', 'file'];
9
+ const VALID_FORMATS = ['jwt'];
9
10
  export function validatePrerequisites(prereqs, errors, path) {
10
11
  if (!Array.isArray(prereqs)) {
11
12
  errors.push({ path, message: 'prerequisites must be an array' });
@@ -39,6 +40,13 @@ export function validatePrerequisites(prereqs, errors, path) {
39
40
  if (p.promptOnMissing !== undefined && typeof p.promptOnMissing !== 'boolean') {
40
41
  errors.push({ path: `${pPath}.promptOnMissing`, message: 'promptOnMissing must be a boolean' });
41
42
  }
43
+ if (p.format !== undefined
44
+ && !VALID_FORMATS.includes(p.format)) {
45
+ errors.push({
46
+ path: `${pPath}.format`,
47
+ message: `format must be one of: ${VALID_FORMATS.join(', ')}`,
48
+ });
49
+ }
42
50
  const detect = p.detect;
43
51
  if (!detect || typeof detect !== 'object') {
44
52
  errors.push({ path: `${pPath}.detect`, message: 'detect is required and must be an object' });
@@ -66,6 +74,14 @@ export function validatePrerequisites(prereqs, errors, path) {
66
74
  errors.push({ path: `${pPath}.detect.path`, message: 'detect.path is required for file detector' });
67
75
  }
68
76
  }
77
+ // `format` only applies to stored env values — silently ignoring it on
78
+ // command/file detectors would mask author mistakes.
79
+ if (p.format !== undefined && detect.type !== 'env') {
80
+ errors.push({
81
+ path: `${pPath}.format`,
82
+ message: 'format is only valid on env-type prerequisites',
83
+ });
84
+ }
69
85
  });
70
86
  }
71
87
  //# sourceMappingURL=prerequisites.js.map
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.9.30';
5
+ export const VERSION = '4.9.31';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.9.30",
3
+ "version": "4.9.31",
4
4
  "description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
5
5
  "main": "dist/src/cli/index.js",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "@typescript-eslint/eslint-plugin": "^7.18.0",
85
85
  "@typescript-eslint/parser": "^7.18.0",
86
86
  "eslint": "^8.0.0",
87
- "moflo": "^4.9.29",
87
+ "moflo": "^4.9.30",
88
88
  "tsx": "^4.21.0",
89
89
  "typescript": "^5.9.3",
90
90
  "vitest": "^4.0.0"