moflo 4.9.29 → 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.
- package/dist/src/cli/embeddings/fastembed-inline/index.js +14 -0
- package/dist/src/cli/memory/bridge-entries.js +66 -0
- package/dist/src/cli/memory/memory-initializer.js +36 -0
- package/dist/src/cli/spells/core/credential-validation.js +105 -0
- package/dist/src/cli/spells/core/prerequisite-checker.js +30 -6
- package/dist/src/cli/spells/schema/validators/prerequisites.js +16 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -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 (
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential Validation
|
|
3
|
+
*
|
|
4
|
+
* Shape checks applied to values pulled from the encrypted credential store
|
|
5
|
+
* before they are promoted to `process.env`. Two layers:
|
|
6
|
+
*
|
|
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.
|
|
12
|
+
*
|
|
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.
|
|
19
|
+
*
|
|
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`.
|
|
23
|
+
*/
|
|
24
|
+
const VALID_JWT_SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
25
|
+
export function validateStoredCredential(envKey, value, format) {
|
|
26
|
+
if (format === 'jwt') {
|
|
27
|
+
return validateJwtFormat(value);
|
|
28
|
+
}
|
|
29
|
+
if (envKey.endsWith('_URL')) {
|
|
30
|
+
return validateUrlValue(value);
|
|
31
|
+
}
|
|
32
|
+
if (looksLikeJwt(value)) {
|
|
33
|
+
return validateJwtExpiry(value);
|
|
34
|
+
}
|
|
35
|
+
return { valid: true };
|
|
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
|
+
}
|
|
46
|
+
function validateUrlValue(value) {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = new URL(value);
|
|
49
|
+
if (!parsed.host) {
|
|
50
|
+
return { valid: false, reason: 'stored value is not a valid URL (missing host)' };
|
|
51
|
+
}
|
|
52
|
+
return { valid: true };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { valid: false, reason: 'stored value is not a valid URL' };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function looksLikeJwt(value) {
|
|
59
|
+
const parts = value.split('.');
|
|
60
|
+
if (parts.length !== 3)
|
|
61
|
+
return false;
|
|
62
|
+
return parts.every(p => p.length > 0 && VALID_JWT_SEGMENT.test(p));
|
|
63
|
+
}
|
|
64
|
+
function validateJwtExpiry(value) {
|
|
65
|
+
const exp = readJwtExp(value);
|
|
66
|
+
if (exp == null)
|
|
67
|
+
return { valid: true };
|
|
68
|
+
const expiryMs = exp * 1000;
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
if (expiryMs >= now)
|
|
71
|
+
return { valid: true };
|
|
72
|
+
return { valid: false, reason: `JWT expired ${formatDuration(now - expiryMs)} ago` };
|
|
73
|
+
}
|
|
74
|
+
function readJwtExp(value) {
|
|
75
|
+
try {
|
|
76
|
+
const payload = value.split('.')[1];
|
|
77
|
+
const padLen = (4 - (payload.length % 4)) % 4;
|
|
78
|
+
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(padLen);
|
|
79
|
+
const decoded = Buffer.from(b64, 'base64').toString('utf-8');
|
|
80
|
+
const parsed = JSON.parse(decoded);
|
|
81
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
82
|
+
const exp = parsed.exp;
|
|
83
|
+
if (typeof exp === 'number' && Number.isFinite(exp))
|
|
84
|
+
return exp;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function formatDuration(ms) {
|
|
93
|
+
const s = Math.floor(ms / 1000);
|
|
94
|
+
if (s < 60)
|
|
95
|
+
return `${s}s`;
|
|
96
|
+
const m = Math.floor(s / 60);
|
|
97
|
+
if (m < 60)
|
|
98
|
+
return `${m}m`;
|
|
99
|
+
const h = Math.floor(m / 60);
|
|
100
|
+
if (h < 24)
|
|
101
|
+
return `${h}h ${m % 60}m`;
|
|
102
|
+
const d = Math.floor(h / 24);
|
|
103
|
+
return `${d}d ${h % 24}h`;
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=credential-validation.js.map
|
|
@@ -18,6 +18,7 @@ import { access } from 'node:fs/promises';
|
|
|
18
18
|
import { promisify } from 'node:util';
|
|
19
19
|
import { acquireTTYLock } from './tty-lock.js';
|
|
20
20
|
import { readLineFromStdin } from './stdin-reader.js';
|
|
21
|
+
import { validateStoredCredential } from './credential-validation.js';
|
|
21
22
|
const execFileAsync = promisify(execFile);
|
|
22
23
|
/**
|
|
23
24
|
* Check whether a CLI command is available on the system PATH.
|
|
@@ -71,6 +72,7 @@ export function compilePrerequisiteSpec(spec) {
|
|
|
71
72
|
description: spec.description,
|
|
72
73
|
promptOnMissing,
|
|
73
74
|
envKey,
|
|
75
|
+
format: spec.format,
|
|
74
76
|
};
|
|
75
77
|
}
|
|
76
78
|
function defaultHintForDetect(spec) {
|
|
@@ -190,19 +192,28 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
190
192
|
// lets us skip a re-check of the cheap env detector.
|
|
191
193
|
// `forceCredentialReprompt` bypasses this step so users can rotate or
|
|
192
194
|
// correct stored credentials by re-running through the prompt path.
|
|
195
|
+
// Stored values are shape-validated (#1007): expired JWTs and malformed
|
|
196
|
+
// _URL values are rejected and surface in the preflight banner, so the
|
|
197
|
+
// resolver re-prompts instead of silently feeding garbage to the spell.
|
|
193
198
|
const resolvedFromStoreNames = [];
|
|
194
199
|
const storeResolved = new Set();
|
|
200
|
+
const rejectedFromStore = [];
|
|
195
201
|
if (credentials && !options.forceCredentialReprompt) {
|
|
196
202
|
await Promise.all(unmetIndices.map(async (i) => {
|
|
197
203
|
const prereq = prerequisites[i];
|
|
198
204
|
if (!prereq.envKey)
|
|
199
205
|
return;
|
|
200
206
|
const stored = await credentials.get(prereq.envKey);
|
|
201
|
-
if (typeof stored
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
207
|
+
if (typeof stored !== 'string' || stored.length === 0)
|
|
208
|
+
return;
|
|
209
|
+
const validation = validateStoredCredential(prereq.envKey, stored, prereq.format);
|
|
210
|
+
if (!validation.valid) {
|
|
211
|
+
rejectedFromStore.push({ envKey: prereq.envKey, reason: validation.reason });
|
|
212
|
+
return;
|
|
205
213
|
}
|
|
214
|
+
process.env[prereq.envKey] = stored;
|
|
215
|
+
storeResolved.add(i);
|
|
216
|
+
resolvedFromStoreNames.push(prereq.name);
|
|
206
217
|
}));
|
|
207
218
|
}
|
|
208
219
|
const stillUnmetIdx = unmetIndices.filter(i => !storeResolved.has(i));
|
|
@@ -218,7 +229,7 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
218
229
|
if (promptableEnvKeys.length > 0) {
|
|
219
230
|
return {
|
|
220
231
|
ok: false,
|
|
221
|
-
message: formatMissingCredentialMessage(promptableEnvKeys, stillUnmet),
|
|
232
|
+
message: formatMissingCredentialMessage(promptableEnvKeys, stillUnmet, rejectedFromStore),
|
|
222
233
|
resolvedNames: resolvedFromStoreNames,
|
|
223
234
|
errorCode: 'MISSING_CREDENTIAL',
|
|
224
235
|
missingCredentials: promptableEnvKeys,
|
|
@@ -231,6 +242,12 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
231
242
|
};
|
|
232
243
|
}
|
|
233
244
|
printPreflightBanner(log, stillUnmet.length);
|
|
245
|
+
if (rejectedFromStore.length > 0) {
|
|
246
|
+
for (const r of rejectedFromStore) {
|
|
247
|
+
log(` Stored ${r.envKey} rejected — ${r.reason}. Re-enter below.`);
|
|
248
|
+
}
|
|
249
|
+
log('');
|
|
250
|
+
}
|
|
234
251
|
const promptLine = options.promptLine ?? readLineFromStdin;
|
|
235
252
|
const promptedNames = [];
|
|
236
253
|
const promptableSet = new Set(promptable);
|
|
@@ -309,13 +326,20 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
|
|
|
309
326
|
}
|
|
310
327
|
return { ok: true, resolvedNames: [...resolvedFromStoreNames, ...promptedNames] };
|
|
311
328
|
}
|
|
312
|
-
function formatMissingCredentialMessage(envKeys, prereqs) {
|
|
329
|
+
function formatMissingCredentialMessage(envKeys, prereqs, rejectedFromStore) {
|
|
313
330
|
const lines = ['Missing credentials (cannot prompt — non-interactive run):'];
|
|
314
331
|
for (const key of envKeys) {
|
|
315
332
|
const prereq = prereqs.find(p => p.envKey === key);
|
|
316
333
|
const label = `${prereq?.name ?? key} (${key})`;
|
|
317
334
|
appendPrereqLine(lines, label, prereq?.installHint, prereq?.url);
|
|
318
335
|
}
|
|
336
|
+
if (rejectedFromStore.length > 0) {
|
|
337
|
+
lines.push('');
|
|
338
|
+
lines.push('Stored value(s) rejected as stale or invalid:');
|
|
339
|
+
for (const r of rejectedFromStore) {
|
|
340
|
+
lines.push(` - ${r.envKey}: ${r.reason}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
319
343
|
lines.push('');
|
|
320
344
|
lines.push('Prime these by casting the spell once interactively, or run:');
|
|
321
345
|
lines.push(' flo spell credentials set <name>');
|
|
@@ -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
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.9.
|
|
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.
|
|
87
|
+
"moflo": "^4.9.30",
|
|
88
88
|
"tsx": "^4.21.0",
|
|
89
89
|
"typescript": "^5.9.3",
|
|
90
90
|
"vitest": "^4.0.0"
|