@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 +27 -3
- package/README.ru.md +27 -3
- package/dist/bootstrap.d.ts +19 -0
- package/dist/bootstrap.js +84 -0
- package/dist/cli.js +15 -9
- package/dist/dream.d.ts +2 -0
- package/dist/dream.js +16 -0
- package/dist/entries.d.ts +8 -2
- package/dist/entries.js +21 -4
- package/dist/inbox.d.ts +32 -0
- package/dist/inbox.js +203 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/ingest.d.ts +11 -0
- package/dist/ingest.js +55 -0
- package/dist/mcp.js +19 -2
- package/dist/paths.d.ts +3 -0
- package/dist/paths.js +3 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +6 -2
- package/dist/search.d.ts +1 -0
- package/dist/search.js +2 -1
- package/dist/service.d.ts +1 -0
- package/dist/service.js +22 -15
- package/dist/settings.d.ts +1 -0
- package/dist/settings.js +10 -0
- package/dist/store.d.ts +2 -0
- package/dist/store.js +11 -4
- package/dist/visibility.d.ts +7 -0
- package/dist/visibility.js +13 -0
- package/package.json +2 -2
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
|
|
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:
|
|
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
|
|
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`:
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
22
|
-
|
|
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) {
|
package/dist/inbox.d.ts
ADDED
|
@@ -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: "
|
|
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 {
|
|
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
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
|
|
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 = {
|
|
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
package/dist/service.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { entryLine } from "./entries.js";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
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);
|
package/dist/settings.d.ts
CHANGED
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.
|
|
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.
|
|
37
|
+
"@kisev/safe-fs": "1.0.0-dev.47.gcb218d104bc4"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/node": "22.15.30",
|