@kisev/memomatic 1.0.0-dev.46.gfbe1e4c6992e → 1.0.0-dev.47.gcb218d104bc4

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/README.md CHANGED
@@ -19,18 +19,42 @@ The `@kisev/agentomatic` installer deploys the OpenCode plugin automatically;
19
19
  standalone use runs the CLI:
20
20
 
21
21
  ```bash
22
- memomatic dream --dry-run
22
+ memomatic process # validate the inbox and index accepted entries (no model)
23
+ memomatic dream # full sweep: inbox + sessions + consolidation
23
24
  ```
24
25
 
25
26
  The nightly sweep is scheduled by the systemd user units in `assets/systemd/`
26
27
  (`memomatic-dream.service` and `memomatic-dream.timer`).
27
28
 
29
+ ## Inbox
30
+
31
+ All writes are asynchronous. Skills, agents, and the `memory_write` tool
32
+ append Markdown entry lines to `$XDG_STATE_HOME/memomatic/inbox/`:
33
+
34
+ ```markdown
35
+ - Durable outcome in one sentence. <!-- source: team-retro --> <!-- key: stable-id -->
36
+ ```
37
+
38
+ The next `process` or `dream` pass validates drops, applies `never-save`
39
+ rules, deduplicates exact texts, supersedes entries sharing a `key`, rebuilds
40
+ the SQLite index with batch embeddings, and moves rejected drops to
41
+ `inbox/rejected/`. Producers detect the inbox by presence and skip silently
42
+ when memomatic is absent.
43
+
44
+ Every entry can carry a `source` annotation. Visibility derives from it:
45
+ `team-*`, `gitlab`, and `spec-manage` entries may be quoted in team-facing
46
+ artifacts; every other source (`people-journal`, `stopit`,
47
+ `mattermost-triage`, `task-*`, `docs-*`, `user`) is personal-only. The label
48
+ is exposed in search responses and session bootstrap blocks.
49
+
28
50
  ## Surfaces
29
51
 
30
- - `memomatic` CLI: corpus inspection and the `dream` sweep.
52
+ - `memomatic` CLI: `process`, `dream`, `search`, `status`, `index`.
31
53
  - MCP stdio server with `memory_search`, `memory_get`, `memory_write`, and
32
54
  `memory_forget` tools.
33
- - The OpenCode plugin re-exported by `@kisev/agentomatic`.
55
+ - The OpenCode plugin re-exported by `@kisev/agentomatic`; its bootstrap
56
+ injects curated memory plus project- and trigger-matched recall blocks
57
+ resolved from the session database (`projects` map in `settings.json`).
34
58
 
35
59
  Nothing is deleted without the explicit directives documented in
36
60
  `MEMORY_RULES.md`.
package/README.ru.md CHANGED
@@ -19,17 +19,41 @@ npm install @kisev/memomatic
19
19
  автономное использование — через CLI:
20
20
 
21
21
  ```bash
22
- memomatic dream --dry-run
22
+ memomatic process # валидация inbox и индексация принятого (без модели)
23
+ memomatic dream # полный проход: inbox + сессии + консолидация
23
24
  ```
24
25
 
25
26
  Ночной проход планируют user-юниты systemd из `assets/systemd/`
26
27
  (`memomatic-dream.service` и `memomatic-dream.timer`).
27
28
 
29
+ ## Inbox
30
+
31
+ Все записи асинхронны. Скиллы, агенты и инструмент `memory_write` кладут
32
+ строки записей Markdown в `$XDG_STATE_HOME/memomatic/inbox/`:
33
+
34
+ ```markdown
35
+ - Durable-вывод одним предложением. <!-- source: team-retro --> <!-- key: stable-id -->
36
+ ```
37
+
38
+ Следующий проход `process` или `dream` валидирует дропы, применяет правила
39
+ `never-save`, дедуплицирует точные тексты, замещает записи с тем же `key`,
40
+ пересобирает SQLite-индекс с батч-эмбеддингами и переносит отклонённое в
41
+ `inbox/rejected/`. Продюсеры детектируют inbox по наличию и молча
42
+ пропускают дроп, если memomatic нет.
43
+
44
+ Каждая запись может нести аннотацию `source`. Из неё выводится видимость:
45
+ записи `team-*`, `gitlab` и `spec-manage` можно цитировать в командных
46
+ артефактах; остальные источники (`people-journal`, `stopit`,
47
+ `mattermost-triage`, `task-*`, `docs-*`, `user`) — personal-only. Метка
48
+ возвращается в поиске и в bootstrap-блоках сессий.
49
+
28
50
  ## Поверхности
29
51
 
30
- - CLI `memomatic`: инспекция корпуса и проход `dream`.
52
+ - CLI `memomatic`: `process`, `dream`, `search`, `status`, `index`.
31
53
  - MCP-сервер stdio с инструментами `memory_search`, `memory_get`,
32
54
  `memory_write` и `memory_forget`.
33
- - Плагин OpenCode, реэкспортируемый пакетом `@kisev/agentomatic`.
55
+ - Плагин OpenCode, реэкспортируемый пакетом `@kisev/agentomatic`; его
56
+ bootstrap добавляет к кураторской памяти блоки напоминаний по проекту и
57
+ trigger-фразам (контекст сессии и маппинг `projects` из `settings.json`).
34
58
 
35
59
  Без явных директив, описанных в `MEMORY_RULES.md`, ничего не удаляется.
@@ -0,0 +1,19 @@
1
+ import type { MemomaticContext } from "./service.js";
2
+ export type SessionFacts = {
3
+ directory: string | null;
4
+ title: string | null;
5
+ firstMessage: string | null;
6
+ };
7
+ export type BootstrapOptions = {
8
+ curatedBudgetChars?: number;
9
+ episodicBudgetChars?: number;
10
+ };
11
+ /** Longest-prefix match of a working directory against the projects map. */
12
+ export declare function resolveProject(directory: string | null, projects: Record<string, string>): string | null;
13
+ /**
14
+ * Build the session bootstrap context: curated MEMORY.md and USER.md heads,
15
+ * plus episodic entries matched by project annotation and trigger phrases.
16
+ * Every episodic line carries its source visibility so team-facing artifacts
17
+ * never quote personal-only memory.
18
+ */
19
+ export declare function bootstrapContext(context: MemomaticContext, facts: SessionFacts, options?: BootstrapOptions): Promise<string | null>;
@@ -0,0 +1,84 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { visibilityForSource } from "./visibility.js";
3
+ const DEFAULT_CURATED_BUDGET = 4_000;
4
+ const DEFAULT_EPISODIC_BUDGET = 1_500;
5
+ const MAX_BLOCK_ENTRIES = 8;
6
+ /** Longest-prefix match of a working directory against the projects map. */
7
+ export function resolveProject(directory, projects) {
8
+ if (!directory)
9
+ return null;
10
+ let best = null;
11
+ for (const [prefix, name] of Object.entries(projects)) {
12
+ if (directory === prefix || directory.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`))
13
+ if (!best || prefix.length > best.prefix.length)
14
+ best = { prefix, name };
15
+ }
16
+ return best?.name ?? null;
17
+ }
18
+ function renderEntry(entry) {
19
+ const visibility = visibilityForSource(entry.source);
20
+ const label = entry.source ? `source: ${entry.source}, ${visibility}-only` : `${visibility}-only`;
21
+ const text = entry.text.startsWith("- ") ? entry.text.slice(2) : entry.text;
22
+ return `- ${text} (${label})`;
23
+ }
24
+ function rankEntries(entries) {
25
+ return [...entries].sort((left, right) => {
26
+ if (right.importance !== left.importance)
27
+ return right.importance - left.importance;
28
+ return (right.observedAt ?? 0) - (left.observedAt ?? 0);
29
+ });
30
+ }
31
+ function fitsBudget(lines, budget) {
32
+ const result = [];
33
+ let used = 0;
34
+ for (const line of lines) {
35
+ if (result.length >= MAX_BLOCK_ENTRIES)
36
+ break;
37
+ if (used + line.length > budget)
38
+ break;
39
+ result.push(line);
40
+ used += line.length + 1;
41
+ }
42
+ return result;
43
+ }
44
+ /**
45
+ * Build the session bootstrap context: curated MEMORY.md and USER.md heads,
46
+ * plus episodic entries matched by project annotation and trigger phrases.
47
+ * Every episodic line carries its source visibility so team-facing artifacts
48
+ * never quote personal-only memory.
49
+ */
50
+ export async function bootstrapContext(context, facts, options = {}) {
51
+ const curatedBudget = options.curatedBudgetChars ?? DEFAULT_CURATED_BUDGET;
52
+ const episodicBudget = options.episodicBudgetChars ?? DEFAULT_EPISODIC_BUDGET;
53
+ const blocks = [];
54
+ for (const file of [context.paths.memoryFile, context.paths.userFile]) {
55
+ const content = await readFile(file, "utf8").catch(() => undefined);
56
+ if (content === undefined || !content.trim())
57
+ continue;
58
+ const name = file.slice(file.lastIndexOf("/") + 1);
59
+ blocks.push(`# Memomatic ${name}\n\n${content.trim().slice(0, curatedBudget)}`);
60
+ }
61
+ const entries = context.store.allEntries().filter((entry) => entry.status === null);
62
+ const project = resolveProject(facts.directory, context.settings.projects);
63
+ const usedTexts = new Set();
64
+ if (project) {
65
+ const lines = rankEntries(entries.filter((entry) => entry.kind === "episodic" && entry.project === project)).map((entry) => {
66
+ usedTexts.add(entry.text);
67
+ return renderEntry(entry);
68
+ });
69
+ const fitted = fitsBudget(lines, episodicBudget);
70
+ if (fitted.length)
71
+ blocks.push(`# Memomatic project recall (${project})\n\n${fitted.join("\n")}\n\nEntries marked personal-only must never be quoted in team-facing artifacts.`);
72
+ }
73
+ const haystack = [facts.title ?? "", facts.firstMessage ?? ""].join("\n").toLowerCase();
74
+ if (haystack.trim()) {
75
+ const triggered = rankEntries(entries.filter((entry) => !usedTexts.has(entry.text) &&
76
+ entry.trigger.some((phrase) => phrase.trim() && haystack.includes(phrase.toLowerCase())))).map((entry) => renderEntry(entry));
77
+ const fitted = fitsBudget(triggered, episodicBudget);
78
+ if (fitted.length)
79
+ blocks.push(`# Memomatic triggered recall\n\n${fitted.join("\n")}\n\nEntries marked personal-only must never be quoted in team-facing artifacts.`);
80
+ }
81
+ if (!blocks.length)
82
+ return null;
83
+ return `${blocks.join("\n\n")}\n\nUse memory_search for details and memory_write to queue durable outcomes. Never re-save content that is already present in this memory.`;
84
+ }
package/dist/cli.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { openMemomatic, rebuildIndex, searchMemory } from "./service.js";
3
3
  import { runDream } from "./dream.js";
4
+ import { processInbox, withRunLock } from "./inbox.js";
4
5
  import { OpenCodeExecutor } from "./executor.js";
5
6
  import { handleMcpRequest } from "./mcp.js";
6
7
  const USAGE = `usage: memomatic <command> [args]
7
8
 
8
9
  commands:
10
+ process [--dry-run] validate the inbox and move accepted entries into the corpus
9
11
  dream [--dry-run] run the consolidation sweep (scheduled by memomatic-dream.timer)
10
12
  search <query> search memory from the command line
11
13
  status report corpus and index status
@@ -35,16 +37,20 @@ async function main() {
35
37
  }
36
38
  const context = await openMemomatic();
37
39
  try {
38
- if (command === "dream") {
40
+ if (command === "dream" || command === "process") {
39
41
  const dryRun = rest.includes("--dry-run");
40
- const executor = context.settings.dream.model
41
- ? new OpenCodeExecutor({
42
- model: context.settings.dream.model,
43
- variant: context.settings.dream.variant,
44
- })
45
- : null;
46
- const report = await runDream(context, executor, { dryRun });
47
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
42
+ const result = await withRunLock(context.paths, async () => {
43
+ if (command === "process")
44
+ return processInbox(context, { dryRun });
45
+ const executor = context.settings.dream.model
46
+ ? new OpenCodeExecutor({
47
+ model: context.settings.dream.model,
48
+ variant: context.settings.dream.variant,
49
+ })
50
+ : null;
51
+ return runDream(context, executor, { dryRun });
52
+ });
53
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
48
54
  return;
49
55
  }
50
56
  if (command === "search") {
package/dist/dream.d.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { type InboxReport } from "./inbox.js";
1
2
  import { type ModelExecutor } from "./executor.js";
2
3
  import type { MemomaticContext } from "./service.js";
3
4
  export type DreamReport = {
5
+ inbox: InboxReport;
4
6
  sessionsIngested: number;
5
7
  candidatesExtracted: number;
6
8
  promoted: string[];
package/dist/dream.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { entryLine, parseEntryLine } from "./entries.js";
2
2
  import { appendDailyEntry, appendDreams, readTextIfExists, writeCorpusFile } from "./corpus.js";
3
+ import { processInbox } from "./inbox.js";
3
4
  import { promotionCandidates } from "./gates.js";
4
5
  import { extractJson } from "./executor.js";
5
6
  import { loadRecentSessions, opencodeDatabasePath } from "./ingest.js";
@@ -109,6 +110,16 @@ async function ingestSessions(context, executor) {
109
110
  export async function runDream(context, executor, options = {}) {
110
111
  const dryRun = options.dryRun === true;
111
112
  const report = {
113
+ inbox: {
114
+ filesProcessed: 0,
115
+ filesRejected: 0,
116
+ entriesAppended: 0,
117
+ entriesSuperseded: 0,
118
+ entriesDuplicated: 0,
119
+ linesForbidden: 0,
120
+ linesInvalid: 0,
121
+ dryRun,
122
+ },
112
123
  sessionsIngested: 0,
113
124
  candidatesExtracted: 0,
114
125
  promoted: [],
@@ -120,6 +131,7 @@ export async function runDream(context, executor, options = {}) {
120
131
  dryRun,
121
132
  };
122
133
  await reindex(context.paths, context.settings, context.store);
134
+ report.inbox = await processInbox(context, { dryRun });
123
135
  const ingestion = await ingestSessions(context, executor);
124
136
  report.sessionsIngested = ingestion.sessions;
125
137
  report.candidatesExtracted = ingestion.extracted.length;
@@ -167,6 +179,10 @@ export async function runDream(context, executor, options = {}) {
167
179
  report.archived = await archiveOldEpisodic(context);
168
180
  await reindex(context.paths, context.settings, context.store);
169
181
  const summary = [
182
+ `- inbox files processed: ${report.inbox.filesProcessed}`,
183
+ `- inbox entries appended: ${report.inbox.entriesAppended}`,
184
+ `- inbox entries superseded: ${report.inbox.entriesSuperseded}`,
185
+ `- inbox files rejected: ${report.inbox.filesRejected}`,
170
186
  `- sessions ingested: ${report.sessionsIngested}`,
171
187
  `- candidates extracted: ${report.candidatesExtracted}`,
172
188
  `- promoted: ${report.promoted.length}${report.promoted.length ? ` (${report.promoted.join(", ")})` : ""}`,
package/dist/entries.d.ts CHANGED
@@ -7,6 +7,8 @@ export type EntryAnnotations = {
7
7
  importance?: number;
8
8
  trigger?: string[];
9
9
  pinned?: boolean;
10
+ source?: string;
11
+ target?: "episodic" | "curated" | "user";
10
12
  };
11
13
  export type CorpusEntry = {
12
14
  file: string;
@@ -18,8 +20,12 @@ export declare function parseEntryLine(line: string): {
18
20
  text: string;
19
21
  annotations: EntryAnnotations;
20
22
  } | null;
21
- export declare function serializeAnnotations(annotations: EntryAnnotations): string;
22
- export declare function entryLine(text: string, annotations: EntryAnnotations): string;
23
+ export declare function serializeAnnotations(annotations: EntryAnnotations, options?: {
24
+ dropTarget?: boolean;
25
+ }): string;
26
+ export declare function entryLine(text: string, annotations: EntryAnnotations, options?: {
27
+ dropTarget?: boolean;
28
+ }): string;
23
29
  export declare function entryKey(entry: CorpusEntry): string | null;
24
30
  export declare function entryImportance(entry: CorpusEntry): number;
25
31
  export declare function entryObservedAt(entry: CorpusEntry): number;
package/dist/entries.js CHANGED
@@ -1,3 +1,4 @@
1
+ const SOURCE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
1
2
  const TRAILING_ANNOTATION = /\s*<!--\s*([a-z-]+):\s*([^<]*?)\s*-->\s*$/;
2
3
  export function parseEntryLine(line) {
3
4
  let value = line.trimEnd();
@@ -11,14 +12,26 @@ export function parseEntryLine(line) {
11
12
  const name = match[1];
12
13
  const raw = match[2];
13
14
  let consumed = true;
14
- if (name === "key" || name === "project" || name === "observed") {
15
+ if (name === "key" || name === "project" || name === "observed" || name === "source") {
15
16
  if (name === "observed")
16
17
  annotations.observed = raw;
17
18
  else if (name === "project")
18
19
  annotations.project = raw;
20
+ else if (name === "source") {
21
+ if (SOURCE_PATTERN.test(raw))
22
+ annotations.source = raw;
23
+ else
24
+ consumed = false;
25
+ }
19
26
  else
20
27
  annotations.key = raw;
21
28
  }
29
+ else if (name === "target") {
30
+ if (raw === "episodic" || raw === "curated" || raw === "user")
31
+ annotations.target = raw;
32
+ else
33
+ consumed = false;
34
+ }
22
35
  else if (name === "status") {
23
36
  if (raw === "active" || raw === "superseded")
24
37
  annotations.status = raw;
@@ -60,7 +73,7 @@ export function parseEntryLine(line) {
60
73
  }
61
74
  return { text: value, annotations };
62
75
  }
63
- export function serializeAnnotations(annotations) {
76
+ export function serializeAnnotations(annotations, options = {}) {
64
77
  const parts = [];
65
78
  if (annotations.key)
66
79
  parts.push(`key: ${annotations.key}`);
@@ -78,10 +91,14 @@ export function serializeAnnotations(annotations) {
78
91
  parts.push(`trigger: ${annotations.trigger.join("; ")}`);
79
92
  if (annotations.pinned)
80
93
  parts.push("pinned: true");
94
+ if (annotations.source)
95
+ parts.push(`source: ${annotations.source}`);
96
+ if (!options.dropTarget && annotations.target)
97
+ parts.push(`target: ${annotations.target}`);
81
98
  return parts.map((part) => `<!-- ${part} -->`).join(" ");
82
99
  }
83
- export function entryLine(text, annotations) {
84
- const suffix = serializeAnnotations(annotations);
100
+ export function entryLine(text, annotations, options = {}) {
101
+ const suffix = serializeAnnotations(annotations, options);
85
102
  return `- ${text}${suffix ? ` ${suffix}` : ""}`;
86
103
  }
87
104
  export function entryKey(entry) {
@@ -0,0 +1,32 @@
1
+ import type { MemomaticContext } from "./service.js";
2
+ import type { MemomaticPaths } from "./paths.js";
3
+ export type InboxReport = {
4
+ filesProcessed: number;
5
+ filesRejected: number;
6
+ entriesAppended: number;
7
+ entriesSuperseded: number;
8
+ entriesDuplicated: number;
9
+ linesForbidden: number;
10
+ linesInvalid: number;
11
+ dryRun: boolean;
12
+ };
13
+ export declare function inboxFileName(source: string, now?: Date): string;
14
+ /**
15
+ * Append one inbox drop file. Producers write Markdown entry lines that the
16
+ * deterministic `process` pass later validates, gates, and moves into the
17
+ * corpus. Nothing here touches corpus files.
18
+ */
19
+ export declare function dropToInbox(paths: MemomaticPaths, lines: string[], source: string): Promise<string>;
20
+ /**
21
+ * Deterministic inbox pass: validate entry lines, enforce never-save rules,
22
+ * deduplicate exact texts, supersede entries sharing a key, route user-origin
23
+ * targets, then rebuild the index with embeddings. No model turns.
24
+ */
25
+ export declare function processInbox(context: MemomaticContext, options?: {
26
+ dryRun?: boolean;
27
+ }): Promise<InboxReport>;
28
+ /**
29
+ * Serialize whole runs (dream or process) with a stale-tolerant lock file so a
30
+ * nightly sweep and a manual CLI invocation never mutate the corpus together.
31
+ */
32
+ export declare function withRunLock<T>(paths: MemomaticPaths, work: () => Promise<T>): Promise<T>;
package/dist/inbox.js ADDED
@@ -0,0 +1,203 @@
1
+ import { mkdir, open, readdir, rename, rm, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { writeAtomic } from "@kisev/safe-fs";
5
+ import { appendDailyEntry, readTextIfExists, replaceEntryLine, writeCorpusFile } from "./corpus.js";
6
+ import { entryLine, parseEntryLine } from "./entries.js";
7
+ import { corpusFiles, reindex } from "./search.js";
8
+ import { isForbidden } from "./rules.js";
9
+ const SOURCE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
10
+ const LIST_PREFIX = /^- /;
11
+ /** parseEntryLine keeps the leading "- "; strip it before re-serializing. */
12
+ function entryText(parsed) {
13
+ return LIST_PREFIX.test(parsed.text) ? parsed.text.slice(2) : parsed.text;
14
+ }
15
+ const LOCK_STALE_MS = 30 * 60_000;
16
+ export function inboxFileName(source, now = new Date()) {
17
+ const stamp = now.toISOString().replace(/[-:]/g, "").slice(0, 15);
18
+ return `${source}-${stamp}-${randomBytes(4).toString("hex")}.md`;
19
+ }
20
+ /**
21
+ * Append one inbox drop file. Producers write Markdown entry lines that the
22
+ * deterministic `process` pass later validates, gates, and moves into the
23
+ * corpus. Nothing here touches corpus files.
24
+ */
25
+ export async function dropToInbox(paths, lines, source) {
26
+ if (!SOURCE_PATTERN.test(source))
27
+ throw new Error(`inbox source is invalid: ${source}`);
28
+ const content = `${lines.filter((line) => line.trim()).join("\n")}\n`;
29
+ if (!content.trim())
30
+ throw new Error("inbox drop is empty");
31
+ const file = join(paths.inboxDir, inboxFileName(source));
32
+ await writeAtomic(file, Buffer.from(content, "utf8"), 0o600);
33
+ return file;
34
+ }
35
+ async function scanCorpus(paths) {
36
+ const index = { texts: new Set(), keys: new Map() };
37
+ for (const file of await corpusFiles(paths)) {
38
+ const markdown = (await readTextIfExists(file)) ?? "";
39
+ const lines = markdown.split("\n");
40
+ for (let position = 0; position < lines.length; position += 1) {
41
+ const parsed = parseEntryLine(lines[position]);
42
+ if (!parsed)
43
+ continue;
44
+ index.texts.add(parsed.text);
45
+ if (parsed.annotations.key && parsed.annotations.status !== "superseded")
46
+ index.keys.set(parsed.annotations.key, { file, line: position + 1 });
47
+ }
48
+ }
49
+ return index;
50
+ }
51
+ async function moveRejected(paths, name) {
52
+ await mkdir(paths.rejectedDir, { mode: 0o700, recursive: true });
53
+ await rename(join(paths.inboxDir, name), join(paths.rejectedDir, name));
54
+ }
55
+ async function appendLine(context, file, line) {
56
+ const current = (await readTextIfExists(file)) ?? "";
57
+ await writeCorpusFile(context.paths, file, `${current.trimEnd()}\n${line}\n`);
58
+ }
59
+ function routeFor(annotations) {
60
+ if (annotations.target === "user" && annotations.origin === "user")
61
+ return "user";
62
+ if (annotations.target === "curated" && annotations.origin === "user")
63
+ return "curated";
64
+ return "episodic";
65
+ }
66
+ /**
67
+ * Deterministic inbox pass: validate entry lines, enforce never-save rules,
68
+ * deduplicate exact texts, supersede entries sharing a key, route user-origin
69
+ * targets, then rebuild the index with embeddings. No model turns.
70
+ */
71
+ export async function processInbox(context, options = {}) {
72
+ const dryRun = options.dryRun === true;
73
+ const report = {
74
+ filesProcessed: 0,
75
+ filesRejected: 0,
76
+ entriesAppended: 0,
77
+ entriesSuperseded: 0,
78
+ entriesDuplicated: 0,
79
+ linesForbidden: 0,
80
+ linesInvalid: 0,
81
+ dryRun,
82
+ };
83
+ const names = (await readdir(context.paths.inboxDir).catch(() => []))
84
+ .filter((name) => name.endsWith(".md"))
85
+ .sort();
86
+ if (!names.length)
87
+ return report;
88
+ const corpus = await scanCorpus(context.paths);
89
+ for (const name of names) {
90
+ const markdown = (await readTextIfExists(join(context.paths.inboxDir, name))) ?? "";
91
+ const rawLines = markdown.split("\n");
92
+ const parsedLines = rawLines.filter((line) => line.trim()).map((line) => parseEntryLine(line));
93
+ report.linesInvalid += parsedLines.filter((parsed) => !parsed).length;
94
+ const allowed = parsedLines.filter((parsed) => {
95
+ if (!parsed)
96
+ return false;
97
+ if (isForbidden(parsed.text, context.rules)) {
98
+ report.linesForbidden += 1;
99
+ return false;
100
+ }
101
+ return true;
102
+ });
103
+ if (!allowed.length) {
104
+ if (!dryRun)
105
+ await moveRejected(context.paths, name);
106
+ report.filesRejected += 1;
107
+ continue;
108
+ }
109
+ let accepted = false;
110
+ for (const parsed of allowed) {
111
+ if (corpus.texts.has(parsed.text)) {
112
+ report.entriesDuplicated += 1;
113
+ continue;
114
+ }
115
+ const annotations = {
116
+ ...parsed.annotations,
117
+ target: undefined,
118
+ observed: parsed.annotations.observed ?? new Date().toISOString().slice(0, 10),
119
+ };
120
+ const line = entryLine(entryText(parsed), annotations, { dropTarget: true });
121
+ const route = routeFor(parsed.annotations);
122
+ const existing = route === "episodic" && parsed.annotations.key
123
+ ? corpus.keys.get(parsed.annotations.key)
124
+ : undefined;
125
+ if (existing) {
126
+ if (!dryRun)
127
+ await replaceEntryLine(context.paths, existing.file, existing.line, line);
128
+ report.entriesSuperseded += 1;
129
+ }
130
+ else if (route === "user" || route === "curated") {
131
+ if (!dryRun)
132
+ await appendLine(context, route === "user" ? context.paths.userFile : context.paths.memoryFile, line);
133
+ report.entriesAppended += 1;
134
+ }
135
+ else {
136
+ if (!dryRun)
137
+ await appendDailyEntry(context.paths, line);
138
+ report.entriesAppended += 1;
139
+ }
140
+ corpus.texts.add(parsed.text);
141
+ accepted = true;
142
+ }
143
+ if (accepted) {
144
+ if (!dryRun)
145
+ await unlink(join(context.paths.inboxDir, name)).catch(() => undefined);
146
+ report.filesProcessed += 1;
147
+ }
148
+ else {
149
+ if (!dryRun)
150
+ await moveRejected(context.paths, name);
151
+ report.filesRejected += 1;
152
+ }
153
+ }
154
+ if (!dryRun)
155
+ await reindex(context.paths, context.settings, context.store);
156
+ return report;
157
+ }
158
+ /**
159
+ * Serialize whole runs (dream or process) with a stale-tolerant lock file so a
160
+ * nightly sweep and a manual CLI invocation never mutate the corpus together.
161
+ */
162
+ export async function withRunLock(paths, work) {
163
+ const acquire = async () => {
164
+ try {
165
+ const handle = await open(paths.runLockFile, "wx", 0o600);
166
+ try {
167
+ await handle.writeFile(`${process.pid} ${new Date().toISOString()}\n`);
168
+ }
169
+ finally {
170
+ await handle.close();
171
+ }
172
+ }
173
+ catch (error) {
174
+ if (error.code !== "EEXIST")
175
+ throw error;
176
+ let stale = false;
177
+ try {
178
+ const handle = await open(paths.runLockFile, "r");
179
+ try {
180
+ const info = await handle.stat();
181
+ stale = Date.now() - info.mtimeMs > LOCK_STALE_MS;
182
+ }
183
+ finally {
184
+ await handle.close();
185
+ }
186
+ }
187
+ catch {
188
+ return;
189
+ }
190
+ if (!stale)
191
+ throw new Error("another memomatic run is active");
192
+ await rm(paths.runLockFile, { force: true });
193
+ return acquire();
194
+ }
195
+ };
196
+ await acquire();
197
+ try {
198
+ return await work();
199
+ }
200
+ finally {
201
+ await rm(paths.runLockFile, { force: true }).catch(() => undefined);
202
+ }
203
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  export { writeCorpusFile } from "./corpus.js";
2
2
  export { entryLine, parseEntryLine } from "./entries.js";
3
+ export { visibilityForSource } from "./visibility.js";
4
+ export { dropToInbox, processInbox, withRunLock } from "./inbox.js";
3
5
  export { forgetEntry, getEntry, openMemomatic, searchMemory, writeEntry } from "./service.js";
4
6
  export type { MemomaticContext } from "./service.js";
7
+ export { bootstrapContext, resolveProject } from "./bootstrap.js";
8
+ export { opencodeDatabasePath, sessionFacts } from "./ingest.js";
package/dist/index.js CHANGED
@@ -1,3 +1,7 @@
1
1
  export { writeCorpusFile } from "./corpus.js";
2
2
  export { entryLine, parseEntryLine } from "./entries.js";
3
+ export { visibilityForSource } from "./visibility.js";
4
+ export { dropToInbox, processInbox, withRunLock } from "./inbox.js";
3
5
  export { forgetEntry, getEntry, openMemomatic, searchMemory, writeEntry } from "./service.js";
6
+ export { bootstrapContext, resolveProject } from "./bootstrap.js";
7
+ export { opencodeDatabasePath, sessionFacts } from "./ingest.js";
package/dist/ingest.d.ts CHANGED
@@ -9,6 +9,17 @@ export type IngestSession = {
9
9
  }>;
10
10
  };
11
11
  export declare function opencodeDatabasePath(): string;
12
+ export type SessionFacts = {
13
+ directory: string | null;
14
+ title: string | null;
15
+ firstMessage: string | null;
16
+ };
17
+ /**
18
+ * Resolve session context for the bootstrap block: working directory, title,
19
+ * and the first user message, read-only from the OpenCode database. Returns
20
+ * nulls when the session or database is unavailable.
21
+ */
22
+ export declare function sessionFacts(databaseFile: string, sessionId: string): SessionFacts;
12
23
  export declare function loadRecentSessions(databaseFile: string, afterTimeCreated: number, options?: {
13
24
  maxSessions: number;
14
25
  maxCharsPerSession: number;
package/dist/ingest.js CHANGED
@@ -9,6 +9,61 @@ export function opencodeDatabasePath() {
9
9
  throw new Error("XDG_DATA_HOME must be an absolute path");
10
10
  return join(base, "opencode", "opencode.db");
11
11
  }
12
+ /**
13
+ * Resolve session context for the bootstrap block: working directory, title,
14
+ * and the first user message, read-only from the OpenCode database. Returns
15
+ * nulls when the session or database is unavailable.
16
+ */
17
+ export function sessionFacts(databaseFile, sessionId) {
18
+ let db;
19
+ try {
20
+ db = new DatabaseSync(databaseFile, { readOnly: true });
21
+ }
22
+ catch {
23
+ return { directory: null, title: null, firstMessage: null };
24
+ }
25
+ try {
26
+ const session = db
27
+ .prepare("SELECT directory, title FROM session WHERE id = ?;")
28
+ .get(sessionId);
29
+ let firstMessage = null;
30
+ const rows = db
31
+ .prepare(`SELECT data FROM message WHERE session_id = ? ORDER BY time_created ASC LIMIT 20;`)
32
+ .all(sessionId);
33
+ for (const row of rows) {
34
+ try {
35
+ const parsed = JSON.parse(row.data);
36
+ if (parsed.role !== "user")
37
+ continue;
38
+ const texts = [];
39
+ for (const part of parsed.parts ?? []) {
40
+ const value = part;
41
+ if (value.type === "text" && typeof value.text === "string")
42
+ texts.push(value.text);
43
+ }
44
+ const text = texts.join("\n").trim();
45
+ if (text) {
46
+ firstMessage = text;
47
+ break;
48
+ }
49
+ }
50
+ catch {
51
+ continue;
52
+ }
53
+ }
54
+ return {
55
+ directory: session?.directory ?? null,
56
+ title: session?.title ?? null,
57
+ firstMessage,
58
+ };
59
+ }
60
+ catch {
61
+ return { directory: null, title: null, firstMessage: null };
62
+ }
63
+ finally {
64
+ db.close();
65
+ }
66
+ }
12
67
  export function loadRecentSessions(databaseFile, afterTimeCreated, options = {
13
68
  maxSessions: 20,
14
69
  maxCharsPerSession: 24_000,
package/dist/mcp.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { forgetEntry, getEntry, openMemomatic, searchMemory, writeEntry } from "./service.js";
2
+ import { visibilityForSource } from "./visibility.js";
2
3
  const PROTOCOL_VERSION = "2025-06-18";
3
4
  const TOOLS = [
4
5
  {
@@ -25,7 +26,7 @@ const TOOLS = [
25
26
  name: "memory_get",
26
27
  },
27
28
  {
28
- description: "Save a memory entry. Use for standing decisions with rationale, discoveries, failed attempts with rejection reasons, session outcomes, and action-sensitive boundaries. Respect MEMORY_RULES.md; never-save topics are rejected.",
29
+ description: "Queue a memory entry for the next process/dream pass. Use for standing decisions with rationale, discoveries, failed attempts with rejection reasons, session outcomes, and action-sensitive boundaries. Respect MEMORY_RULES.md; never-save topics are rejected immediately.",
29
30
  inputSchema: {
30
31
  additionalProperties: false,
31
32
  properties: {
@@ -34,6 +35,10 @@ const TOOLS = [
34
35
  origin: { enum: ["user", "agent"], type: "string" },
35
36
  pinned: { type: "boolean" },
36
37
  project: { type: "string" },
38
+ source: {
39
+ description: "Originating skill or producer in kebab-case, e.g. team-retro",
40
+ type: "string",
41
+ },
37
42
  target: { enum: ["episodic", "curated", "user"], type: "string" },
38
43
  text: { type: "string" },
39
44
  trigger: { items: { type: "string" }, type: "array" },
@@ -71,6 +76,8 @@ async function callTool(name, args) {
71
76
  line: hit.entry.line,
72
77
  score: Number(hit.score.toFixed(4)),
73
78
  snippet: hit.snippet,
79
+ source: hit.entry.source,
80
+ visibility: visibilityForSource(hit.entry.source),
74
81
  }));
75
82
  }
76
83
  case "memory_get": {
@@ -87,19 +94,29 @@ async function callTool(name, args) {
87
94
  case "memory_write": {
88
95
  if (typeof args.text !== "string" || !args.text.trim())
89
96
  throw new Error("text is required");
97
+ const source = typeof args.source === "string" && args.source
98
+ ? args.source
99
+ : args.origin === "user"
100
+ ? "user"
101
+ : "agent";
90
102
  const file = await writeEntry(context, {
91
103
  importance: typeof args.importance === "number" ? args.importance : undefined,
92
104
  key: typeof args.key === "string" ? args.key : undefined,
93
105
  origin: args.origin === "user" ? "user" : "agent",
94
106
  pinned: args.pinned === true,
95
107
  project: typeof args.project === "string" ? args.project : undefined,
108
+ source,
96
109
  target: args.target === "curated" || args.target === "user" ? args.target : "episodic",
97
110
  text: args.text,
98
111
  trigger: Array.isArray(args.trigger)
99
112
  ? args.trigger.filter((item) => typeof item === "string")
100
113
  : undefined,
101
114
  });
102
- return { file: file.replace(`${context.paths.stateRoot}/`, ""), saved: true };
115
+ return {
116
+ file: file.replace(`${context.paths.stateRoot}/`, ""),
117
+ flushHint: "run `memomatic process` to index queued entries immediately",
118
+ queued: true,
119
+ };
103
120
  }
104
121
  case "memory_forget": {
105
122
  if (typeof args.file !== "string" || typeof args.line !== "number")
package/dist/paths.d.ts CHANGED
@@ -10,6 +10,9 @@ export type MemomaticPaths = {
10
10
  archiveDir: string;
11
11
  historyDir: string;
12
12
  indexFile: string;
13
+ inboxDir: string;
14
+ rejectedDir: string;
15
+ runLockFile: string;
13
16
  };
14
17
  export declare function memomaticPaths(): MemomaticPaths;
15
18
  export declare function verifyMemomaticRoots(paths: MemomaticPaths): Promise<void>;
package/dist/paths.js CHANGED
@@ -39,6 +39,9 @@ export function memomaticPaths() {
39
39
  archiveDir: join(stateRoot, "archive"),
40
40
  historyDir: join(stateRoot, "history"),
41
41
  indexFile: join(stateRoot, "index.sqlite"),
42
+ inboxDir: join(stateRoot, "inbox"),
43
+ rejectedDir: join(stateRoot, "inbox", "rejected"),
44
+ runLockFile: join(stateRoot, "run.lock"),
42
45
  };
43
46
  }
44
47
  export async function verifyMemomaticRoots(paths) {
package/dist/rules.d.ts CHANGED
@@ -3,6 +3,7 @@ export type MemoryRules = {
3
3
  autoClean: {
4
4
  olderThanDays: number;
5
5
  scope: "episodic";
6
+ source?: string;
6
7
  } | null;
7
8
  };
8
9
  export declare const emptyRules: () => MemoryRules;
package/dist/rules.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  const NEVER_SAVE = /^-\s*never-save:\s*(.+?)\s*$/gim;
3
- const AUTO_CLEAN = /^-\s*auto-clean:\s*older-than=(\d+)d\s+scope=episodic\s*$/im;
3
+ const AUTO_CLEAN = /^-\s*auto-clean:\s*older-than=(\d+)d\s+scope=episodic(?:\s+source=([a-z0-9-]+))?\s*$/im;
4
4
  export const emptyRules = () => ({ neverSave: [], autoClean: null });
5
5
  export function parseRules(markdown) {
6
6
  const rules = emptyRules();
@@ -13,7 +13,11 @@ export function parseRules(markdown) {
13
13
  if (autoClean) {
14
14
  const days = Number.parseInt(autoClean[1], 10);
15
15
  if (Number.isFinite(days) && days > 0)
16
- rules.autoClean = { olderThanDays: days, scope: "episodic" };
16
+ rules.autoClean = {
17
+ olderThanDays: days,
18
+ scope: "episodic",
19
+ source: autoClean[2],
20
+ };
17
21
  }
18
22
  return rules;
19
23
  }
package/dist/search.d.ts CHANGED
@@ -6,6 +6,7 @@ export type SearchHit = {
6
6
  score: number;
7
7
  snippet: string;
8
8
  };
9
+ export declare function corpusFiles(paths: MemomaticPaths): Promise<string[]>;
9
10
  export declare function reindex(paths: MemomaticPaths, settings: MemomaticSettings, store: MemoryStore): Promise<number>;
10
11
  export declare function search(store: MemoryStore, query: string, settings: MemomaticSettings, options?: {
11
12
  includeArchived?: boolean;
package/dist/search.js CHANGED
@@ -4,7 +4,7 @@ import { cosineSimilarity, stableIdFor } from "./store.js";
4
4
  import { entryKind, parseCorpusEntries } from "./corpus.js";
5
5
  import { entryImportance, entryObservedAt } from "./entries.js";
6
6
  import { embedTexts } from "./settings.js";
7
- async function corpusFiles(paths) {
7
+ export async function corpusFiles(paths) {
8
8
  const files = [];
9
9
  for (const name of ["MEMORY.md", "USER.md"]) {
10
10
  const file = join(paths.stateRoot, name);
@@ -38,6 +38,7 @@ export async function reindex(paths, settings, store) {
38
38
  origin: entry.annotations.origin ?? null,
39
39
  observedAt: Number.isNaN(entryObservedAt(entry)) ? null : entryObservedAt(entry),
40
40
  status: entry.annotations.status === "active" ? "active" : null,
41
+ source: entry.annotations.source ?? null,
41
42
  });
42
43
  }
43
44
  }
package/dist/service.d.ts CHANGED
@@ -12,6 +12,7 @@ export type WriteRequest = {
12
12
  origin?: "user" | "agent";
13
13
  target?: "episodic" | "curated" | "user";
14
14
  pinned?: boolean;
15
+ source?: string;
15
16
  };
16
17
  export type MemomaticContext = {
17
18
  paths: MemomaticPaths;
package/dist/service.js CHANGED
@@ -1,10 +1,21 @@
1
1
  import { entryLine } from "./entries.js";
2
- import { appendDailyEntry, archiveFile, readTextIfExists, replaceEntryLine } from "./corpus.js";
2
+ import { archiveFile, readTextIfExists, replaceEntryLine } from "./corpus.js";
3
+ import { dropToInbox } from "./inbox.js";
3
4
  import { isForbidden, loadRules } from "./rules.js";
4
5
  import { memomaticPaths } from "./paths.js";
5
6
  import { loadSettings } from "./settings.js";
6
7
  import { reindex, search } from "./search.js";
7
8
  import { MemoryStore } from "./store.js";
9
+ import { stat } from "node:fs/promises";
10
+ async function exists(path) {
11
+ try {
12
+ await stat(path);
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
8
19
  export async function openMemomatic() {
9
20
  const paths = memomaticPaths();
10
21
  const [settings, rules, store] = await Promise.all([
@@ -21,28 +32,19 @@ export async function writeEntry(context, request) {
21
32
  if (isForbidden(text, context.rules))
22
33
  throw new Error("memory text matches a never-save rule in MEMORY_RULES.md");
23
34
  const origin = request.origin ?? "agent";
24
- const observed = new Date().toISOString().slice(0, 10);
25
35
  const line = entryLine(text, {
26
36
  key: request.key,
27
37
  status: origin === "user" && request.target === "user" ? "active" : undefined,
28
38
  origin,
29
- observed,
39
+ observed: new Date().toISOString().slice(0, 10),
30
40
  project: request.project,
31
41
  importance: request.importance,
32
42
  trigger: request.trigger,
33
43
  pinned: request.pinned,
44
+ source: request.source ?? origin,
45
+ target: request.target,
34
46
  });
35
- if (request.target === "curated" || request.target === "user") {
36
- if (origin !== "user")
37
- throw new Error("curated and user entries require origin=user");
38
- const file = request.target === "user" ? context.paths.userFile : context.paths.memoryFile;
39
- const current = (await readTextIfExists(file)) ?? "";
40
- const next = `${current.trimEnd()}\n${line}\n`;
41
- const { writeCorpusFile } = await import("./corpus.js");
42
- await writeCorpusFile(context.paths, file, next);
43
- return file;
44
- }
45
- return appendDailyEntry(context.paths, line);
47
+ return dropToInbox(context.paths, [line], request.source ?? origin);
46
48
  }
47
49
  export async function searchMemory(context, query) {
48
50
  return search(context.store, query, context.settings);
@@ -86,12 +88,17 @@ export async function archiveOldEpisodic(context) {
86
88
  const archived = [];
87
89
  if (!context.rules.autoClean)
88
90
  return archived;
89
- const cutoff = Date.now() - context.rules.autoClean.olderThanDays * 86_400_000;
91
+ const rule = context.rules.autoClean;
92
+ const cutoff = Date.now() - rule.olderThanDays * 86_400_000;
90
93
  for (const entry of context.store.allEntries()) {
91
94
  if (entry.kind !== "episodic" || !entry.observedAt || entry.observedAt >= cutoff)
92
95
  continue;
96
+ if (rule.source && entry.source !== rule.source)
97
+ continue;
93
98
  if (entry.pinned)
94
99
  continue;
100
+ if (!(await exists(entry.file)))
101
+ continue;
95
102
  const alreadyDone = archived.includes(entry.file);
96
103
  if (!alreadyDone) {
97
104
  await archiveFile(context.paths, entry.file);
@@ -3,6 +3,7 @@ export type MemomaticSettings = {
3
3
  url: string;
4
4
  model: string;
5
5
  } | null;
6
+ projects: Record<string, string>;
6
7
  dream: {
7
8
  model: string | null;
8
9
  variant: string | null;
package/dist/settings.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  export const defaultSettings = () => ({
3
3
  embedding: null,
4
+ projects: {},
4
5
  dream: {
5
6
  model: null,
6
7
  variant: null,
@@ -28,10 +29,19 @@ export function normalizeSettings(raw) {
28
29
  const defaults = defaultSettings();
29
30
  const source = (raw ?? {});
30
31
  const embedding = source.embedding;
32
+ const rawProjects = source.projects;
33
+ const projects = {};
34
+ if (rawProjects && typeof rawProjects === "object") {
35
+ for (const [prefix, name] of Object.entries(rawProjects)) {
36
+ if (typeof name === "string" && name.trim() && prefix.trim())
37
+ projects[prefix] = name.trim();
38
+ }
39
+ }
31
40
  return {
32
41
  embedding: embedding && typeof embedding.url === "string" && typeof embedding.model === "string"
33
42
  ? { url: embedding.url, model: embedding.model }
34
43
  : null,
44
+ projects,
35
45
  dream: mergeSection(defaults.dream, source.dream),
36
46
  search: mergeSection(defaults.search, source.search),
37
47
  archive: mergeSection(defaults.archive, source.archive),
package/dist/store.d.ts CHANGED
@@ -14,6 +14,7 @@ export type IndexedEntry = {
14
14
  origin: "user" | "agent" | null;
15
15
  observedAt: number | null;
16
16
  status: "active" | null;
17
+ source: string | null;
17
18
  };
18
19
  export type VectorRow = {
19
20
  stableId: string;
@@ -25,6 +26,7 @@ export declare class MemoryStore {
25
26
  readonly db: DatabaseSync;
26
27
  private readonly fts;
27
28
  private constructor();
29
+ private migrate;
28
30
  static open(indexFile: string): Promise<MemoryStore>;
29
31
  private ensureFts;
30
32
  hasFts(): boolean;
package/dist/store.js CHANGED
@@ -48,8 +48,14 @@ export class MemoryStore {
48
48
  this.db = new DatabaseSync(indexFile);
49
49
  this.db.exec("PRAGMA journal_mode = WAL;");
50
50
  this.db.exec(SCHEMA);
51
+ this.migrate();
51
52
  this.fts = this.ensureFts();
52
53
  }
54
+ migrate() {
55
+ const columns = this.db.prepare("PRAGMA table_info(entries);").all();
56
+ if (!columns.some((column) => column.name === "source"))
57
+ this.db.exec("ALTER TABLE entries ADD COLUMN source TEXT;");
58
+ }
53
59
  static async open(indexFile) {
54
60
  await mkdir(dirname(indexFile), { mode: 0o700, recursive: true });
55
61
  return new MemoryStore(indexFile);
@@ -78,9 +84,9 @@ export class MemoryStore {
78
84
  continue;
79
85
  seen.add(entry.stableId);
80
86
  this.db
81
- .prepare(`INSERT INTO entries (stable_id, file, line, kind, key, text, trigger_phrases, importance, pinned, project, origin, observed_at, status)
82
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
83
- .run(entry.stableId, entry.file, entry.line, entry.kind, entry.key, entry.text, JSON.stringify(entry.trigger), entry.importance, entry.pinned ? 1 : 0, entry.project, entry.origin, entry.observedAt, entry.status);
87
+ .prepare(`INSERT INTO entries (stable_id, file, line, kind, key, text, trigger_phrases, importance, pinned, project, origin, observed_at, status, source)
88
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
89
+ .run(entry.stableId, entry.file, entry.line, entry.kind, entry.key, entry.text, JSON.stringify(entry.trigger), entry.importance, entry.pinned ? 1 : 0, entry.project, entry.origin, entry.observedAt, entry.status, entry.source ?? null);
84
90
  if (this.fts)
85
91
  this.db
86
92
  .prepare("INSERT INTO entries_fts (stable_id, text) VALUES (?, ?);")
@@ -97,7 +103,7 @@ export class MemoryStore {
97
103
  }
98
104
  allEntries() {
99
105
  return this.db
100
- .prepare("SELECT stable_id, file, line, kind, key, text, trigger_phrases, importance, pinned, project, origin, observed_at, status FROM entries ORDER BY observed_at ASC")
106
+ .prepare("SELECT stable_id, file, line, kind, key, text, trigger_phrases, importance, pinned, project, origin, observed_at, status, source FROM entries ORDER BY observed_at ASC")
101
107
  .all()
102
108
  .map((row) => {
103
109
  const value = row;
@@ -115,6 +121,7 @@ export class MemoryStore {
115
121
  origin: value.origin ?? null,
116
122
  observedAt: value.observed_at ?? null,
117
123
  status: value.status ?? null,
124
+ source: value.source ?? null,
118
125
  };
119
126
  });
120
127
  }
@@ -0,0 +1,7 @@
1
+ export type EntryVisibility = "team" | "personal";
2
+ /**
3
+ * Usage-level visibility derived from the entry source. Team-prefixed skills,
4
+ * the GitLab collector, and spec-manage produce entries citable in
5
+ * team-facing artifacts; every other source stays personal-only.
6
+ */
7
+ export declare function visibilityForSource(source: string | null | undefined): EntryVisibility;
@@ -0,0 +1,13 @@
1
+ const TEAM_CITABLE_SOURCES = ["gitlab", "spec-manage"];
2
+ /**
3
+ * Usage-level visibility derived from the entry source. Team-prefixed skills,
4
+ * the GitLab collector, and spec-manage produce entries citable in
5
+ * team-facing artifacts; every other source stays personal-only.
6
+ */
7
+ export function visibilityForSource(source) {
8
+ if (!source)
9
+ return "personal";
10
+ if (TEAM_CITABLE_SOURCES.includes(source) || source.startsWith("team-"))
11
+ return "team";
12
+ return "personal";
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kisev/memomatic",
3
- "version": "1.0.0-dev.46.gfbe1e4c6992e",
3
+ "version": "1.0.0-dev.47.gcb218d104bc4",
4
4
  "description": "Personal learning memory for agents: tiered Markdown corpus, rebuildable index, dream sweep.",
5
5
  "homepage": "https://github.com/kisev/skills#readme",
6
6
  "bugs": {
@@ -34,7 +34,7 @@
34
34
  "pack:check": "node test/pack-allowlist.mjs"
35
35
  },
36
36
  "dependencies": {
37
- "@kisev/safe-fs": "1.0.0-dev.46.gfbe1e4c6992e"
37
+ "@kisev/safe-fs": "1.0.0-dev.47.gcb218d104bc4"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "22.15.30",