claude-mem-lite 5.5.1 → 5.6.0

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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "5.5.1",
13
+ "version": "5.6.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "5.5.1",
3
+ "version": "5.6.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/hook.mjs CHANGED
@@ -145,7 +145,7 @@ import {
145
145
  buildUnsavedBugfixHint,
146
146
  countUnsavedBugfixShape,
147
147
  buildCiteRecallNudge as libBuildCiteRecallNudge,
148
- nextCiteLowStreak,
148
+ nextCiteStreakState,
149
149
  } from './lib/cite-back-hint.mjs';
150
150
  import { citeRecallPathFor } from './lib/cite-recall-path.mjs';
151
151
  import { detectUnpersistedDecision } from './lib/persist-reminder.mjs';
@@ -1182,7 +1182,11 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
1182
1182
  sessionId: ccSessionId,
1183
1183
  subagentInjected: sub.injected,
1184
1184
  });
1185
- const n = bumpCitationAccess(db, ids, project, relevant);
1185
+ // sessionId is the ACCESS channel's idempotency key (R11-B-P1-1). Stop fires once
1186
+ // per assistant turn and `ids` above is a rescan of the WHOLE transcript, so
1187
+ // without it every later turn re-credited the same citation — 7.86x on the real
1188
+ // corpus, straight into boostAccessed's `access_count > 3`.
1189
+ const n = bumpCitationAccess(db, ids, project, relevant, { sessionId: ccSessionId });
1186
1190
  debugLog(
1187
1191
  'DEBUG',
1188
1192
  'handleStop',
@@ -1407,11 +1411,19 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
1407
1411
  const dest = citeRecallPathFor(RUNTIME_DIR, project);
1408
1412
  // Carry the consecutive-low-cite streak forward so the SessionStart
1409
1413
  // nag can self-silence after the project has ignored it N times.
1410
- let priorStreak = 0;
1414
+ let prevPayload = null;
1411
1415
  try {
1412
- priorStreak = JSON.parse(readFileSync(dest, 'utf8')).lowStreak || 0;
1416
+ prevPayload = JSON.parse(readFileSync(dest, 'utf8'));
1413
1417
  } catch {}
1414
- const lowStreak = nextCiteLowStreak(priorStreak, stats);
1418
+ // R11-B-P1-2: the streak's unit is the SESSION, which is what the docblock has
1419
+ // always said. Stop fires once per assistant TURN, so incrementing here silenced
1420
+ // the nudge inside the first session — this machine read lowStreak 58 against 26
1421
+ // transcripts before the fix.
1422
+ const { lowStreak, streakBase, lastStreakSession } = nextCiteStreakState(
1423
+ prevPayload,
1424
+ ccSessionId,
1425
+ stats,
1426
+ );
1415
1427
  // G3: finalized-in-conversation + zero deliberate persistence →
1416
1428
  // decisionSignal rides the payload; next SessionStart reminds once.
1417
1429
  let decisionSignal = null;
@@ -1432,7 +1444,16 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
1432
1444
  } catch (e) {
1433
1445
  debugCatch(e, 'handleStop-persist-reminder');
1434
1446
  }
1435
- const payload = { ...stats, ...bugfixStats, lowStreak, decisionSignal, project, savedAt: Date.now() };
1447
+ const payload = {
1448
+ ...stats,
1449
+ ...bugfixStats,
1450
+ lowStreak,
1451
+ streakBase,
1452
+ lastStreakSession,
1453
+ decisionSignal,
1454
+ project,
1455
+ savedAt: Date.now(),
1456
+ };
1436
1457
  writeFileSync(dest, JSON.stringify(payload), { mode: 0o600 });
1437
1458
  } catch (e) {
1438
1459
  debugCatch(e, 'handleStop-cite-recall-persist');
@@ -46,9 +46,21 @@ export const OBS_ID_DIGITS = '\\d{1,7}';
46
46
  * Returned fresh per call rather than shared: a `/g` regex carries `lastIndex`, so one
47
47
  * exported instance reused by two scanners silently starts mid-string in whichever one
48
48
  * runs second.
49
+ *
50
+ * R11-B-P2-3: the lookbehind is what makes "bare" true. This product renders — and
51
+ * teaches the agent and the user to type back — `E#N` (events), `P#N` (user_prompts),
52
+ * `D#N` (deferred) and `S#N` (sessions), and every INJECTED-side extractor already drops
53
+ * those by construction (they fail INJECTED_ROW_RE / FYI_LINE_ID_RE / UPS_ID_RE). The
54
+ * CITED side did not, so `E#501` was read as observation 501. On this machine 26/26 live
55
+ * observation ids are also event ids AND prompt ids, so the collision needs exactly one
56
+ * co-occurrence to land.
57
+ *
58
+ * It does NOT catch every false positive and is not meant to: `issue #1234` and
59
+ * `[link](#42)` are still matched, because a digit preceded by a space or `(` is
60
+ * indistinguishable from a citation at this layer.
49
61
  */
50
62
  export function citationIdRe() {
51
- return new RegExp(`#(${OBS_ID_DIGITS})\\b`, 'g');
63
+ return new RegExp(`(?<![A-Za-z0-9])#(${OBS_ID_DIGITS})\\b`, 'g');
52
64
  }
53
65
 
54
66
  /**
@@ -58,7 +70,16 @@ export function citationIdRe() {
58
70
  * `citationIdRe()` above is a NUMERATOR caliber. On the numerator side a spurious `#1`
59
71
  * costs nothing, because a cited id only counts once it intersects an injected set that
60
72
  * WAS anchored — every injected-side extractor in this module matches a row shape
61
- * (`INJECTED_ROW_RE`, `FYI_LINE_ID_RE`, `UPS_ID_RE`, `SUBAGENT_INJECT_ID_RE`). On the
73
+ * (`INJECTED_ROW_RE`, `FYI_LINE_ID_RE`, `UPS_ID_RE`, `SUBAGENT_INJECT_ID_RE`).
74
+ *
75
+ * That argument has ONE measured exception, and it is why citationIdRe now carries a
76
+ * lookbehind (R11-B-P2-3): `extractUserTypedIds` feeds the access allow-list through the
77
+ * SAME numerator regex, and nothing anchors a user's own message. So a user typing `D#9`
78
+ * and the assistant writing `D#9` intersect on an unanchored token — 8 of 43 credited
79
+ * (session, id) pairs on the real corpus were sourced only that way, 4 of them landing on
80
+ * live observations. Read the argument as "anchored on the injected side", not "safe".
81
+ *
82
+ * On the
62
83
  * denominator side nothing anchors it, so a prose `#1` is a false positive by
63
84
  * construction — it inflates "injected, never cited" and biases the measured rate DOWN.
64
85
  *
@@ -408,7 +429,13 @@ export function assertRelevanceCoversAllFaces(sourced) {
408
429
  * with ids the user typed themselves. Required.
409
430
  * @returns {number} count of rows incremented
410
431
  */
411
- export function bumpCitationAccess(db, ids, project, relevantIds, env = process.env) {
432
+ export function bumpCitationAccess(
433
+ db,
434
+ ids,
435
+ project,
436
+ relevantIds,
437
+ { env = process.env, sessionId = null } = {},
438
+ ) {
412
439
  if (!db || !ids || !project) return 0;
413
440
  // Revert path for the gate (CLAUDE_MEM_CITATION_RELEVANCE_GATE=off). It restores the
414
441
  // pre-v3.84.0 behaviour — every mention credited — including the missing-argument hole,
@@ -428,7 +455,35 @@ export function bumpCitationAccess(db, ids, project, relevantIds, env = process.
428
455
  // crediting a tombstone instead of the row that absorbed it) with no upside to restore.
429
456
  const idList = gateOff ? [...cited] : [...cited].filter((id) => allowed.has(id));
430
457
  if (idList.length === 0) return 0;
431
- const stmt = db.prepare(`
458
+ // R11-B-P1-1: credit once per CC SESSION, not once per call. `Stop` fires on every
459
+ // assistant turn and rescans the entire transcript, so without this key one citation
460
+ // was re-credited on every later turn of the same session — measured 7.86x over the
461
+ // real corpus (338 credits / 43 distinct (session, id) pairs), which then crosses
462
+ // boostAccessed's `access_count > 3` and lifts importance unattended.
463
+ //
464
+ // The key is the same shape the decay channel uses (last_cited_session_id /
465
+ // last_decided_session_id) and deliberately a SEPARATE column: decay resolves a
466
+ // mainOnly id set behind hasMainThreadAssistantText, this channel resolves the whole
467
+ // transcript, so sharing one key would let either channel silence the other.
468
+ //
469
+ // sessionId null → no session scope exists to be idempotent within (non-CC
470
+ // invocation), so the pre-R11 unconditional bump is the only defined behaviour.
471
+ //
472
+ // Scope the guarantee honestly: the column holds the LAST crediting session, not a set,
473
+ // so two same-project CC sessions interleaving their turns flip the stamp between them
474
+ // (A credits → B credits → A's next Stop sees B's stamp and credits again) and the key
475
+ // degrades toward per-turn counting for that pair. That is the same bound the decay
476
+ // channel accepts at :1634 and lib/edge-attribution.mjs:145, and widening it would mean
477
+ // storing a set per row. "Once per session" is exact for the ordinary single-session
478
+ // case and an upper bound of "once per session PAIR interleave" otherwise.
479
+ const scoped = typeof sessionId === 'string' && sessionId.length > 0;
480
+ const stmt = scoped
481
+ ? db.prepare(`
482
+ UPDATE observations SET access_count = access_count + 1, last_accessed_at = ?,
483
+ last_access_session_id = ?
484
+ WHERE id = ? AND project = ? AND COALESCE(last_access_session_id, '') != ?
485
+ `)
486
+ : db.prepare(`
432
487
  UPDATE observations SET access_count = access_count + 1, last_accessed_at = ?
433
488
  WHERE id = ? AND project = ?
434
489
  `);
@@ -436,7 +491,10 @@ export function bumpCitationAccess(db, ids, project, relevantIds, env = process.
436
491
  let n = 0;
437
492
  for (const id of idList) {
438
493
  try {
439
- const result = stmt.run(now, id, project);
494
+ // 0 changes is a SKIP, not a failure: either the row is not this project's, or
495
+ // this session already credited it. Both must leave `n` alone, because the caller
496
+ // logs it as "obs bumped".
497
+ const result = scoped ? stmt.run(now, sessionId, id, project, sessionId) : stmt.run(now, id, project);
440
498
  if (result.changes > 0) n++;
441
499
  } catch (e) {
442
500
  debugCatch(e, `bumpCitationAccess-id-${id}`);
@@ -1427,6 +1485,19 @@ export function redirectSupersededIds(db, project, ids) {
1427
1485
  * rows are currently boost-eligible; 25 of them are cited nowhere in the corpus, and
1428
1486
  * at most 3 could have crossed `access_count > 3` on citations even under an upper
1429
1487
  * bound that ignores the gate entirely. So the path is real and its effect is small.
1488
+ *
1489
+ * THAT BOUND IS RETRACTED (R11-B-P1-1, 2026-09-07). It was computed as "one citation
1490
+ * contributes at most 1", and that premise was false: `Stop` fires once per assistant
1491
+ * TURN and rescans the whole transcript, so before the `last_access_session_id` key one
1492
+ * citation contributed once per REMAINING TURN of its session — 338 credits over 43
1493
+ * distinct (session, id) pairs = 7.86x across 51 real transcripts, single-session worst
1494
+ * case 18.75x. The "at most 3" therefore understated its own quantity by an unmeasured
1495
+ * factor, and no replacement bound is stated here because none has been measured.
1496
+ *
1497
+ * The premise now holds going FORWARD only. Existing rows keep whatever inflated
1498
+ * `access_count` they accumulated — the true count is not recoverable from the inflated
1499
+ * one, so nothing is back-corrected. Any future reading of `access_count` as "how often
1500
+ * this was cited" must treat pre-v5.6.0 values as an upper bound, not a count.
1430
1501
  * It is untouched here because `access_count` has other writers (explicit recall /
1431
1502
  * get / timeline) and is also an input to noisePenaltyClause, so changing it is a
1432
1503
  * different decision with a different blast radius.
@@ -243,6 +243,52 @@ export function nextCiteLowStreak(priorStreak, stats, env = process.env) {
243
243
  return ratioGateFires(stats, env) ? prior + 1 : 0;
244
244
  }
245
245
 
246
+ /**
247
+ * R11-B-P1-2 — advance the self-silence streak by SESSION, which is the unit the
248
+ * docblock above has always claimed and the code never implemented.
249
+ *
250
+ * `Stop` fires once per assistant TURN, and the writer incremented on every fire, so
251
+ * with a median of ~6 turns per session the default silence-after-3 was reached inside
252
+ * the FIRST session and never recovered. Measured on this machine before the fix:
253
+ * `runtime/cite-recall-dev--claude-mem-lite.json` read lowStreak 58 for a project with
254
+ * 26 transcripts on disk — a per-session counter cannot exceed the session count — and
255
+ * 2 of 3 projects had the cite-#NN nudge permanently silenced.
256
+ *
257
+ * Recomputing from the value the session STARTED at (rather than freezing after the
258
+ * first fire) keeps two properties: a session advances the streak by at most 1, and a
259
+ * citation arriving in a later turn still resets it to 0 — which matters because the
260
+ * whole contract is "cite NEXT time you produce user-visible text".
261
+ *
262
+ * @param {object|null} prev parsed previous payload, or null when absent/unreadable
263
+ * @param {string|null} ccSessionId current CC session id
264
+ * @returns {{lowStreak: number, streakBase: number, lastStreakSession: string|null}}
265
+ */
266
+ export function nextCiteStreakState(prev, ccSessionId, stats, env = process.env) {
267
+ let streakBase = 0;
268
+ if (prev && typeof prev === 'object') {
269
+ if (prev.lastStreakSession === undefined) {
270
+ // Written under the pre-R11 TURN semantics. The stored number counted Stop fires,
271
+ // so it is not convertible to sessions — discard it once, here, rather than
272
+ // leaving every existing install permanently silenced by a number that never
273
+ // meant what it was read as.
274
+ streakBase = 0;
275
+ } else if (ccSessionId && prev.lastStreakSession === ccSessionId) {
276
+ streakBase = Number(prev.streakBase) || 0;
277
+ } else {
278
+ streakBase = Number(prev.lowStreak) || 0;
279
+ }
280
+ }
281
+ return {
282
+ lowStreak: nextCiteLowStreak(streakBase, stats, env),
283
+ streakBase,
284
+ // null when there is no CC session to key on: the same-session branch above then
285
+ // never matches, so those invocations keep the old per-fire behaviour. There is no
286
+ // session identity available to do better, and the field's presence is what tells a
287
+ // later read that this file is post-R11.
288
+ lastStreakSession: ccSessionId || null,
289
+ };
290
+ }
291
+
246
292
  // Env opt-outs:
247
293
  // • CLAUDE_MEM_NO_CITE_NUDGE=1 — disables BOTH gates (full silence)
248
294
  // • CLAUDE_MEM_CITE_NUDGE_THRESHOLD — ratio gate threshold (default 0.6)
@@ -13,8 +13,10 @@
13
13
  // Full round-trippable set: content + value-signals (access/cited/uncited/injection/decay)
14
14
  // + branch + timing. `id` + `memory_session_id` are informational (restore remaps id and
15
15
  // buckets under a synthetic restore session). Session-idempotency keys (last_decided/
16
- // last_cited_session_id, demoted_at, optimized_at) are intentionally NOT exported — they are
17
- // meaningless after a row is re-bucketed under a restore session. Also intentionally NOT
16
+ // last_cited/last_access_session_id, demoted_at, optimized_at) are intentionally NOT
17
+ // exported — they are meaningless after a row is re-bucketed under a restore session.
18
+ // last_access_session_id (v48) joins that set for the same reason: it answers "did THIS cc
19
+ // session already credit this row", which no restored session can have done. Also intentionally NOT
18
20
  // exported: `related_ids` (holds observation ids, stale/dangling after restore remaps ids)
19
21
  // and `discovery_tokens` (a derived retrieval metric, rebuilt by the live system; exporting
20
22
  // it would freeze a stale value into backups).
@@ -261,7 +261,7 @@ export function searchPromptsFts(
261
261
  ${project ? 'AND s.project = ?' : ''}
262
262
  ${epochFrom !== null ? 'AND p.created_at_epoch >= ?' : ''}
263
263
  ${epochTo !== null ? 'AND p.created_at_epoch <= ?' : ''}
264
- ORDER BY p.created_at_epoch DESC
264
+ ORDER BY p.created_at_epoch DESC, p.id DESC
265
265
  LIMIT ? OFFSET ?
266
266
  `,
267
267
  )
@@ -977,13 +977,22 @@ export async function coreRunSearchPipeline(ctx, opts) {
977
977
  typeWheres.push('COALESCE(importance, 1) >= ?');
978
978
  typeParams.push(importance);
979
979
  }
980
+ // R11-A-P1-1: `branch` was the one caller-supplied filter this rebuilt WHERE did not
981
+ // carry, and nothing downstream compensates — applyTierFilter only reads tier, and
982
+ // finalizeSearchPage only counts. The leak surfaced exactly when FTS matched nothing,
983
+ // so the wrong-branch rows were the ONLY rows the caller saw, with `total` clamped to
984
+ // them so the page read like a legitimate hit.
985
+ if (branch) {
986
+ typeWheres.push('branch = ?');
987
+ typeParams.push(branch);
988
+ }
980
989
  typeParams.push(limit);
981
990
  const typeRows = db
982
991
  .prepare(
983
992
  `
984
993
  SELECT id, type, title, subtitle, project, created_at, importance, files_modified
985
994
  FROM observations WHERE ${typeWheres.join(' AND ')}
986
- ORDER BY created_at_epoch DESC LIMIT ?
995
+ ORDER BY created_at_epoch DESC, id DESC LIMIT ?
987
996
  `,
988
997
  )
989
998
  .all(...typeParams);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "5.5.1",
3
+ "version": "5.6.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "5.5.1",
9
+ "version": "5.6.0",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "5.5.1",
3
+ "version": "5.6.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
package/schema.mjs CHANGED
@@ -156,7 +156,18 @@ export const CODE_DIR = join(homedir(), '.claude-mem-lite');
156
156
  // every existing install at v46 a new index there would simply never be created. Same trap
157
157
  // the FTS5 migration hit — a DDL change that is not reachable from the version the DB
158
158
  // already reports is a no-op with a convincing diff.
159
- export const CURRENT_SCHEMA_VERSION = 47;
159
+ // v48 (R11-B-P1-1): observations.last_access_session_id — the THIRD per-row session key
160
+ // on this table, and it exists for the same reason as the other two. `Stop` fires once
161
+ // per assistant TURN and rescans the whole transcript, so `bumpCitationAccess` re-credited
162
+ // one citation on every later turn of the same session: real-corpus replay over 51
163
+ // transcripts read 338 credits across 43 distinct (session, id) pairs = 7.86x, single
164
+ // session worst case 18.75x. That feeds boostAccessed (access_count > 3 → importance + 1,
165
+ // in DEFAULT_MAINTAIN_OPS, unattended daily) and suppresses noisePenaltyClause, whose
166
+ // predicate reads `injection_count > access_count * 3`. Additive + nullable: legacy rows
167
+ // read NULL and are credited exactly once more, on their next citation, then stamp.
168
+ // The column holds the LAST crediting session, not a set, so two same-project sessions
169
+ // interleaving their turns flip it between them — see the scope note in bumpCitationAccess.
170
+ export const CURRENT_SCHEMA_VERSION = 48;
160
171
 
161
172
  // Sentinel columns for the LATEST migration set(s). The fast-path uses these
162
173
  // to self-heal half-migrated DBs — schema_version bumped but column ALTERs
@@ -177,6 +188,7 @@ export const CURRENT_SCHEMA_VERSION = 47;
177
188
  // pragma_table_info on a missing table returns zero rows (it does not throw), so
178
189
  // naming any column of the new table is a table-presence check.
179
190
  const LATEST_MIGRATION_COLUMNS = [
191
+ { table: 'observations', column: 'last_access_session_id' }, // v48
180
192
  { table: 'observations', column: 'decay_seen_at_first_cite' }, // v46
181
193
  { table: 'citation_surface_log', column: 'surface' }, // v45
182
194
  { table: 'observations', column: 'scope' }, // v44
@@ -397,6 +409,14 @@ const MIGRATIONS = [
397
409
  // destroy the distinction the column exists to record. Legacy rows stay NULL — they
398
410
  // are not evidence of anything and must not be read as first-cite-at-0.
399
411
  'ALTER TABLE observations ADD COLUMN decay_seen_at_first_cite INTEGER DEFAULT NULL',
412
+ // v48 (R11-B-P1-1): the access-channel idempotency key. Sibling of
413
+ // last_decided_session_id (v40, uncited/streak arm) and last_cited_session_id (v41,
414
+ // promote arm) — three channels fire out of one Stop hook, each needs its own key
415
+ // because they resolve different id sets: decay reads mainOnly, access reads the whole
416
+ // transcript including sidechains, and the decay pair is additionally gated on
417
+ // hasMainThreadAssistantText, so a session can credit access while decay never runs.
418
+ // Sharing a key would make one channel silence the other.
419
+ 'ALTER TABLE observations ADD COLUMN last_access_session_id TEXT DEFAULT NULL',
400
420
  ];
401
421
 
402
422
  /**
package/search-engine.mjs CHANGED
@@ -518,7 +518,8 @@ export function findFtsAnchor(
518
518
  AND (? IS NULL OR o.project = ?)
519
519
  AND ${liveObsFilterSql('o')}
520
520
  ORDER BY ${OBS_BM25}
521
- * ${recencyDecaySql({ tsExpr: 'o.created_at_epoch', halfLifeSql: `${halfLifeMs}.0` })}
521
+ * ${recencyDecaySql({ tsExpr: 'o.created_at_epoch', halfLifeSql: `${halfLifeMs}.0` })},
522
+ o.id DESC
522
523
  LIMIT 1
523
524
  `;
524
525
  const stmt = db.prepare(sql);
@@ -579,7 +580,7 @@ export function searchObservationsHybrid(db, ctx) {
579
580
  `
580
581
  SELECT id, type, title, subtitle, project, created_at, created_at_epoch, files_modified, importance, lesson_learned
581
582
  FROM observations ${where}
582
- ORDER BY created_at_epoch DESC
583
+ ORDER BY created_at_epoch DESC, id DESC
583
584
  LIMIT ? OFFSET ?
584
585
  `,
585
586
  )