rag-memory-epf-mcp 5.1.0 → 5.2.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.
package/dist/index.d.ts CHANGED
@@ -70,6 +70,7 @@ export declare class RAGKnowledgeGraphManager {
70
70
  currentProfileId: number;
71
71
  grandfatherAllowed: boolean;
72
72
  coordinator: BackfillCoordinator | null;
73
+ readonly calendarTimeZone: string;
73
74
  private embeddingCache;
74
75
  private readonly EMBEDDING_CACHE_MAX;
75
76
  private dictionaryCache;
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ import { migrations } from './src/migrations/migrations.js';
32
32
  import { EmbeddingGate, GateNotReadyError, GateDisabledError, TerminalConfigError } from './src/embeddingGate.js';
33
33
  import { resolveModelCacheDir, preflightCacheDir, artifactKey, ModelDownloadLock, handleLoaderFailure } from './src/modelCache.js';
34
34
  import { BackfillCoordinator } from './src/backfillCoordinator.js';
35
+ import { calendarDate, resolveCalendarTimeZone, stampDatePrefix, stripDatePrefix } from './src/observations/date-prefix.js';
35
36
  import os from 'node:os';
36
37
  import { createHash } from 'crypto';
37
38
  import { createRequire } from 'module';
@@ -179,6 +180,9 @@ export class RAGKnowledgeGraphManager {
179
180
  // default model config, or with the explicit trust opt-in (spec §6b guard).
180
181
  grandfatherAllowed = IS_DEFAULT_MODEL_CONFIG || process.env.RAG_MEMORY_TRUST_LEGACY_VECTORS === '1';
181
182
  coordinator = null;
183
+ // The calendar that date-only human labels are written in. Resolved once, here, so an invalid
184
+ // zone fails at construction instead of quietly writing wrong days for weeks.
185
+ calendarTimeZone = resolveCalendarTimeZone(process.env.RAG_MEMORY_CALENDAR_TZ);
182
186
  embeddingCache = new Map();
183
187
  EMBEDDING_CACHE_MAX = 500;
184
188
  dictionaryCache = null;
@@ -574,11 +578,12 @@ export class RAGKnowledgeGraphManager {
574
578
  }
575
579
  // === ORIGINAL MCP FUNCTIONALITY ===
576
580
  _timestampObservation(obs) {
577
- // If observation already has a date prefix like [2026-03-21], skip
578
- if (/^\[\d{4}-\d{2}-\d{2}\]/.test(obs))
579
- return obs;
580
- const today = new Date().toISOString().slice(0, 10);
581
- return `[${today}] ${obs}`;
581
+ // Stamp and dedup share one parser (src/observations/date-prefix.ts). They used to carry
582
+ // separate regexes and disagreed about "[2026-08-11 session16] ...": stamping treated it as
583
+ // undated and prepended a second date, while dedup could not strip it — so the same sentence
584
+ // written in two sessions became two observations. Measured on a live database: 82 rows with
585
+ // two dates, 29 with a day earlier than the day they were written.
586
+ return stampDatePrefix(obs, this.calendarTimeZone);
582
587
  }
583
588
  async createEntities(entities) {
584
589
  if (!this.db)
@@ -590,7 +595,7 @@ export class RAGKnowledgeGraphManager {
590
595
  INSERT OR IGNORE INTO entities (id, name, entityType, observations, metadata)
591
596
  VALUES (?, ?, ?, '[]', ?)
592
597
  `);
593
- const stripDate = (s2) => s2.replace(/^\[\d{4}-\d{2}-\d{2}\]\s*/, '');
598
+ const stripDate = stripDatePrefix; // shared with the stamp path — see date-prefix.ts
594
599
  for (const entity of entities) {
595
600
  const entityId = `entity_${entity.name.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
596
601
  const ts = new Date().toISOString();
@@ -700,7 +705,7 @@ export class RAGKnowledgeGraphManager {
700
705
  // dedup 기준은 v3.6 과 같다: 날짜 prefix 를 뗀 본문이 active 에 이미 있으면
701
706
  // 새 revision 을 만들지 않는다. 다만 v13 에서는 같은 사실이 다른 출처에서 다시
702
707
  // 온 것이므로 그 revision 에 source link 를 더한다(spec §8.3 T13).
703
- const stripDate = (s2) => s2.replace(/^\[\d{4}-\d{2}-\d{2}\]\s*/, '');
708
+ const stripDate = stripDatePrefix; // shared with the stamp path — see date-prefix.ts
704
709
  const activeRows = this.db.prepare(`SELECT observation_id, content FROM entity_observations
705
710
  WHERE entity_id = ? AND status = 'active'`).all(entityId);
706
711
  const activeByBare = new Map(activeRows.map(r => [stripDate(r.content), r.observation_id]));
@@ -1963,7 +1968,10 @@ export class RAGKnowledgeGraphManager {
1963
1968
  // report `unchanged` for a different exclusion, which is the silent-wrong case.
1964
1969
  const content = applyExcludePatterns(options.content !== undefined ? options.content : fsSync.readFileSync(filePath, 'utf-8'), options.excludePattern);
1965
1970
  const bytes = Buffer.byteLength(content, 'utf-8');
1966
- const today = new Date().toISOString().slice(0, 10);
1971
+ // Same calendar as the observation prefix. Deciding this explicitly rather than leaving it
1972
+ // on UTC: both are date-only labels a person reads, and "observations in Seoul days but
1973
+ // documents in UTC days" is not a distinction anyone could explain later.
1974
+ const today = calendarDate(new Date(), this.calendarTimeZone);
1967
1975
  const contentHash = shaHex(content);
1968
1976
  // spec §5.1: content_hash 는 system-owned — user metadata 뒤에 쓴다 (r1: spread 가 덮어쓸 수 있었다).
1969
1977
  const metadata = { source: filePath, updated: today, ...(options.metadata || {}), content_hash: contentHash };
@@ -3452,6 +3460,9 @@ export class RAGKnowledgeGraphManager {
3452
3460
  server: {
3453
3461
  version: PKG_VERSION,
3454
3462
  node: process.versions.node,
3463
+ // Which calendar produced the date-only labels in this database. Surfaced because a
3464
+ // wrong value is otherwise invisible: the labels look plausible either way.
3465
+ calendar_timezone: this.calendarTimeZone,
3455
3466
  embeddings_mode: this.embeddingsMode,
3456
3467
  model: `${EMBEDDING_MODEL}@${MODEL_REVISION}`,
3457
3468
  model_state: gs.state,
@@ -0,0 +1,26 @@
1
+ /** The calendar day of `instant` in `timeZone`, as YYYY-MM-DD. */
2
+ export declare function calendarDate(instant: Date, timeZone: string): string;
3
+ /**
4
+ * Resolve RAG_MEMORY_CALENDAR_TZ. Unset or blank means UTC. An unrecognised zone throws at
5
+ * boot rather than silently falling back — a wrong-but-quiet calendar writes wrong labels for
6
+ * as long as nobody looks, which is the failure this whole module exists to end.
7
+ */
8
+ export declare function resolveCalendarTimeZone(raw: string | undefined | null): string;
9
+ export interface DatePrefix {
10
+ /** YYYY-MM-DD, already validated as a real calendar day. */
11
+ date: string;
12
+ /** Text between the date and the closing bracket, e.g. a session marker. */
13
+ annotation: string | null;
14
+ /** The matched prefix including the brackets, without trailing whitespace. */
15
+ matched: string;
16
+ }
17
+ /** Parse a leading date prefix, or null when the text does not start with one. */
18
+ export declare function parseDatePrefix(content: string): DatePrefix | null;
19
+ /**
20
+ * The dedup key: the text with its date prefix removed. The annotation comes off with the date
21
+ * because it records *when and in which session the line was written*, not what the line claims
22
+ * — the same sentence written in two sessions is one fact, and dedup has to see that.
23
+ */
24
+ export declare function stripDatePrefix(content: string): string;
25
+ /** Prepend today's calendar day unless the text already carries a valid date prefix. */
26
+ export declare function stampDatePrefix(content: string, timeZone: string, now?: Date): string;
@@ -0,0 +1,89 @@
1
+ // One place that decides what a leading "[date]" on an observation means.
2
+ //
3
+ // Why this is a module and not two regexes inline: the stamp path and the dedup path each had
4
+ // their own copy of the pattern, and they disagreed. Stamping asked "does this already start
5
+ // with a date?" while dedup asked "what is this text without its date?", so widening one without
6
+ // the other let the same sentence be stored twice under two session markers. Both now call in
7
+ // here.
8
+ //
9
+ // Why an explicit timezone instead of the process one: a date-only label has no meaning until
10
+ // you say which calendar produced it. The same database is reached from a laptop, from CI and
11
+ // from another country; deriving the day from the ambient TZ makes the stored string mean
12
+ // something different on each. The default stays UTC so the product does not inherit whichever
13
+ // zone its first author happened to sit in — a deployment that wants local days says so.
14
+ /** The calendar day of `instant` in `timeZone`, as YYYY-MM-DD. */
15
+ export function calendarDate(instant, timeZone) {
16
+ const parts = new Intl.DateTimeFormat('en-US', {
17
+ timeZone,
18
+ year: 'numeric',
19
+ month: '2-digit',
20
+ day: '2-digit',
21
+ }).formatToParts(instant);
22
+ const part = (type) => {
23
+ const found = parts.find(p => p.type === type);
24
+ if (!found)
25
+ throw new Error(`calendarDate: missing ${type} for timeZone ${timeZone}`);
26
+ return found.value;
27
+ };
28
+ return `${part('year')}-${part('month')}-${part('day')}`;
29
+ }
30
+ /**
31
+ * Resolve RAG_MEMORY_CALENDAR_TZ. Unset or blank means UTC. An unrecognised zone throws at
32
+ * boot rather than silently falling back — a wrong-but-quiet calendar writes wrong labels for
33
+ * as long as nobody looks, which is the failure this whole module exists to end.
34
+ */
35
+ export function resolveCalendarTimeZone(raw) {
36
+ const value = (raw ?? '').trim();
37
+ if (!value)
38
+ return 'UTC';
39
+ try {
40
+ new Intl.DateTimeFormat('en-US', { timeZone: value });
41
+ }
42
+ catch {
43
+ throw new Error(`RAG_MEMORY_CALENDAR_TZ is not a recognised IANA time zone: ${JSON.stringify(value)}. ` +
44
+ `Use a name like "Asia/Seoul", or leave it unset for UTC.`);
45
+ }
46
+ return value;
47
+ }
48
+ // The annotation may not contain a newline or a bracket: an unclosed or multi-line "[2026-…"
49
+ // is prose that happens to start with a digit, not a prefix. Requiring the closing bracket is
50
+ // what keeps "[2026-08-11 unclosed" and "[2026-08-111]" out.
51
+ const PREFIX_RE = /^\[(\d{4})-(\d{2})-(\d{2})(?:[ \t]([^\]\n]*))?\]/;
52
+ function isRealCalendarDay(year, month, day) {
53
+ const asUtc = new Date(Date.UTC(year, month - 1, day));
54
+ return asUtc.getUTCFullYear() === year
55
+ && asUtc.getUTCMonth() === month - 1
56
+ && asUtc.getUTCDate() === day;
57
+ }
58
+ /** Parse a leading date prefix, or null when the text does not start with one. */
59
+ export function parseDatePrefix(content) {
60
+ const m = PREFIX_RE.exec(content);
61
+ if (!m)
62
+ return null;
63
+ const [matched, year, month, day, annotation] = m;
64
+ if (!isRealCalendarDay(Number(year), Number(month), Number(day)))
65
+ return null;
66
+ const trimmed = annotation === undefined ? null : annotation.trim();
67
+ return {
68
+ date: `${year}-${month}-${day}`,
69
+ annotation: trimmed === '' ? null : trimmed,
70
+ matched,
71
+ };
72
+ }
73
+ /**
74
+ * The dedup key: the text with its date prefix removed. The annotation comes off with the date
75
+ * because it records *when and in which session the line was written*, not what the line claims
76
+ * — the same sentence written in two sessions is one fact, and dedup has to see that.
77
+ */
78
+ export function stripDatePrefix(content) {
79
+ const prefix = parseDatePrefix(content);
80
+ if (!prefix)
81
+ return content;
82
+ return content.slice(prefix.matched.length).replace(/^\s+/, '');
83
+ }
84
+ /** Prepend today's calendar day unless the text already carries a valid date prefix. */
85
+ export function stampDatePrefix(content, timeZone, now = new Date()) {
86
+ if (parseDatePrefix(content))
87
+ return content;
88
+ return `[${calendarDate(now, timeZone)}] ${content}`;
89
+ }
@@ -200,6 +200,11 @@ Observations provide the factual foundation that supports entity existence and p
200
200
  - (!important!) **Entity must exist** - this tool only adds to existing entities
201
201
  - (!important!) Duplicates are filtered - repeating existing text with a **new** sources entry
202
202
  adds evidence to the existing revision and returns null for that position
203
+ - (!important!) A \`[YYYY-MM-DD]\` prefix is prepended unless the text already starts with a valid
204
+ one. The day comes from \`RAG_MEMORY_CALENDAR_TZ\` (default UTC) - see \`calendar_timezone\` in
205
+ getKnowledgeGraphStats. Text may open with \`[YYYY-MM-DD note]\`; the note is treated as recording
206
+ metadata and is ignored when deduplicating, so the same sentence filed under two session markers
207
+ stays one observation
203
208
  - (!important!) Observations are cumulative. **This tool never replaces anything** - use
204
209
  correctObservation to supersede and retractObservation to withdraw
205
210
  - (!important!) **Be specific and factual** - observations should be verifiable statements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rag-memory-epf-mcp",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
4
4
  "engines": {
5
5
  "node": ">=24"
6
6
  },
@@ -45,7 +45,7 @@
45
45
  "prepare": "npm run build",
46
46
  "watch": "tsc --watch",
47
47
  "verify:invariants": "node test/chunk-invariants.test.mjs",
48
- "verify:engine": "node test/engine-smoke.test.mjs && node test/launch-smoke.test.mjs && node test/sync-atomicity.test.mjs && node test/dedup.test.mjs && node test/search-degradation.test.mjs && node test/entity-embed-cap.test.mjs && node test/migration12.test.mjs && node test/model-cache.test.mjs && node test/embedding-gate.test.mjs && node test/lazy-boot.test.mjs && node test/reconciliation.test.mjs && node test/backfill.test.mjs && node test/fts-query.test.mjs && node test/search-contracts.test.mjs && node test/tool-contracts.test.mjs && node test/bounded-exit.test.mjs && node test/observation-schema.test.mjs && node test/observation-migration.test.mjs && node test/observation-lifecycle.test.mjs && node test/observation-contracts.test.mjs && node test/observation-search.test.mjs && node test/observation-cascade.test.mjs && node test/observation-realdata.test.mjs && node test/chunker-c.test.mjs && node test/migration14.test.mjs && node test/chunk-params-validation.test.mjs && node test/vector-reuse.test.mjs && node test/entity-range-linking.test.mjs && node test/stats-chunking.test.mjs && node test/migration14-realdata.test.mjs && node test/migration14-realdata-sync.test.mjs && node test/search-summaries-off.test.mjs && node test/document-return-contracts.test.mjs",
48
+ "verify:engine": "node test/engine-smoke.test.mjs && node test/launch-smoke.test.mjs && node test/sync-atomicity.test.mjs && node test/dedup.test.mjs && node test/search-degradation.test.mjs && node test/entity-embed-cap.test.mjs && node test/migration12.test.mjs && node test/model-cache.test.mjs && node test/embedding-gate.test.mjs && node test/lazy-boot.test.mjs && node test/reconciliation.test.mjs && node test/backfill.test.mjs && node test/fts-query.test.mjs && node test/search-contracts.test.mjs && node test/tool-contracts.test.mjs && node test/bounded-exit.test.mjs && node test/observation-schema.test.mjs && node test/observation-migration.test.mjs && node test/observation-lifecycle.test.mjs && node test/observation-contracts.test.mjs && node test/observation-search.test.mjs && node test/observation-cascade.test.mjs && node test/observation-realdata.test.mjs && node test/chunker-c.test.mjs && node test/migration14.test.mjs && node test/chunk-params-validation.test.mjs && node test/vector-reuse.test.mjs && node test/entity-range-linking.test.mjs && node test/stats-chunking.test.mjs && node test/migration14-realdata.test.mjs && node test/migration14-realdata-sync.test.mjs && node test/search-summaries-off.test.mjs && node test/document-return-contracts.test.mjs && node test/observation-date-prefix.test.mjs",
49
49
  "test": "npm run build && npm run verify:invariants && npm run verify:engine",
50
50
  "prepublishOnly": "npm run build && npm run verify:invariants && npm run verify:engine"
51
51
  },