open-memex 0.6.2 → 0.7.0-alpha.11
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/AGENTS.md +25 -7
- package/CHANGELOG.md +103 -1
- package/README.md +54 -16
- package/README.zh-CN.md +30 -15
- package/dist/.build-stamp +1 -0
- package/dist/capture/keywords.js +41 -8
- package/dist/cli.js +259 -40
- package/dist/config.js +102 -18
- package/dist/distill-agents.js +9 -1
- package/dist/doctor.js +180 -79
- package/dist/export.js +12 -2
- package/dist/index.js +20 -112
- package/dist/init.js +103 -11
- package/dist/inventory-html.js +150 -0
- package/dist/inventory.js +176 -0
- package/dist/mcp.js +21 -5
- package/dist/opencode-v2.js +43 -0
- package/dist/paths.js +34 -2
- package/dist/plugin-core.js +138 -0
- package/dist/retrieve/display.js +161 -0
- package/dist/retrieve/inject.js +1 -1
- package/dist/retrieve/search.js +44 -5
- package/dist/review.js +9 -3
- package/dist/store/db.js +11 -9
- package/dist/store/lifecycle.js +20 -4
- package/dist/store/markdown.js +53 -7
- package/dist/store/migrate.js +14 -3
- package/dist/store/sync.js +5 -3
- package/dist/store/v2migrate.js +2 -2
- package/dist/submit.js +13 -5
- package/dist/tools/memory.js +28 -0
- package/dist/tools/ops.js +127 -16
- package/docs/CURATOR.md +7 -3
- package/docs/V2-DESIGN.md +335 -32
- package/docs/memory-visibility-design.md +130 -0
- package/package.json +2 -2
- package/scripts/build-stamp.mjs +63 -0
- package/scripts/smoke-mcp.ts +47 -1
- package/scripts/smoke-pure.ts +237 -3
- package/scripts/test-full.ts +362 -5
- package/skills/open-memex/SKILL.md +53 -1
- package/src/capture/keywords.ts +56 -6
- package/src/cli.ts +282 -39
- package/src/config.ts +104 -17
- package/src/distill-agents.ts +19 -0
- package/src/doctor.ts +181 -71
- package/src/export.ts +14 -2
- package/src/index.ts +25 -112
- package/src/init.ts +108 -11
- package/src/inventory-html.ts +191 -0
- package/src/inventory.ts +242 -0
- package/src/mcp.ts +21 -5
- package/src/opencode-v2.ts +119 -0
- package/src/paths.ts +35 -1
- package/src/plugin-core.ts +152 -0
- package/src/retrieve/display.ts +174 -0
- package/src/retrieve/inject.ts +1 -1
- package/src/retrieve/search.ts +63 -6
- package/src/review.ts +9 -2
- package/src/store/db.ts +11 -9
- package/src/store/lifecycle.ts +24 -3
- package/src/store/markdown.ts +60 -7
- package/src/store/migrate.ts +15 -2
- package/src/store/sync.ts +6 -3
- package/src/store/v2migrate.ts +2 -1
- package/src/submit.ts +12 -4
- package/src/tools/memory.ts +28 -0
- package/src/tools/ops.ts +147 -17
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
PLUGIN_ID,
|
|
4
|
+
bootstrapPlugin,
|
|
5
|
+
captureFromText,
|
|
6
|
+
injectOnce,
|
|
7
|
+
type PluginState,
|
|
8
|
+
} from "./plugin-core.ts";
|
|
9
|
+
import { makeTools } from "./tools/memory.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* OpenCode 2 adapter (D60). The v2 host structurally decodes the plugin's
|
|
13
|
+
* default export as `{ id, setup }` — `Plugin.define` is an identity
|
|
14
|
+
* wrapper, so a plain object with no runtime import of `@opencode/plugin`
|
|
15
|
+
* loads identically. Importing the SDK at runtime would repeat the D58
|
|
16
|
+
* failure (devDependency, unresolvable from a global install, silent
|
|
17
|
+
* skip); the context is duck-typed here from the 2.0.x surface instead.
|
|
18
|
+
*
|
|
19
|
+
* v1/v2 hook mapping used below:
|
|
20
|
+
* tool map → ctx.tool.transform (JSON Schema in,
|
|
21
|
+
* { content } out)
|
|
22
|
+
* chat.message (keyword capture) → ctx.session.hook("prompt") — fires
|
|
23
|
+
* once per submitted prompt
|
|
24
|
+
* experimental.chat.system.transform → ctx.session.hook("context") — runs
|
|
25
|
+
* before each model request; the
|
|
26
|
+
* system array is mutable
|
|
27
|
+
*
|
|
28
|
+
* Tool registration must opt out of code mode (`options.codemode: false`,
|
|
29
|
+
* verified on 2.0.1): by default the host exposes plugin tools only
|
|
30
|
+
* through an `execute` meta-tool that runs model-written JS, and a model
|
|
31
|
+
* that calls the tool by name instead gets "Unknown tool" — flaky,
|
|
32
|
+
* model-dependent behavior. With the opt-out the five tools are plain
|
|
33
|
+
* function calls, same as on v1 and MCP.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
interface V2ToolInfo {
|
|
37
|
+
name: string;
|
|
38
|
+
description: string;
|
|
39
|
+
input: unknown;
|
|
40
|
+
options?: { codemode?: boolean };
|
|
41
|
+
execute: (input: Record<string, unknown>) => Promise<{ content: string }>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface V2Registration {
|
|
45
|
+
dispose?: () => Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface V2PluginContext {
|
|
49
|
+
app?: { version?: string };
|
|
50
|
+
location?: { directory?: string; project?: { directory?: string } };
|
|
51
|
+
tool: {
|
|
52
|
+
transform: (cb: (editor: { add: (tool: V2ToolInfo) => void }) => void) => Promise<unknown>;
|
|
53
|
+
};
|
|
54
|
+
session: {
|
|
55
|
+
hook: (
|
|
56
|
+
name: "prompt" | "context",
|
|
57
|
+
cb: (event: never) => void | Promise<void>,
|
|
58
|
+
) => Promise<V2Registration>;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface V2PromptEvent {
|
|
63
|
+
sessionID: string;
|
|
64
|
+
prompt?: { text?: string };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface V2ContextEvent {
|
|
68
|
+
sessionID: string;
|
|
69
|
+
system: unknown[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Register the five memory tools on a v2 host. */
|
|
73
|
+
async function registerTools(ctx: V2PluginContext, state: PluginState): Promise<void> {
|
|
74
|
+
const tools = makeTools(() => state.scope, state.cfg);
|
|
75
|
+
await ctx.tool.transform((editor) => {
|
|
76
|
+
for (const [name, t] of Object.entries(tools)) {
|
|
77
|
+
editor.add({
|
|
78
|
+
name,
|
|
79
|
+
description: t.description,
|
|
80
|
+
input: z.toJSONSchema(z.object(t.args)),
|
|
81
|
+
options: { codemode: false },
|
|
82
|
+
execute: async (input) => {
|
|
83
|
+
const r = await t.execute(input as never, undefined as never);
|
|
84
|
+
const content =
|
|
85
|
+
typeof r === "string" ? r : ((r as { output?: string }).output ?? JSON.stringify(r));
|
|
86
|
+
return { content };
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const v2Plugin = {
|
|
94
|
+
id: PLUGIN_ID,
|
|
95
|
+
async setup(ctx: V2PluginContext) {
|
|
96
|
+
const root = ctx.location?.directory ?? ctx.location?.project?.directory ?? process.cwd();
|
|
97
|
+
const state = bootstrapPlugin(root);
|
|
98
|
+
const registrations: V2Registration[] = [];
|
|
99
|
+
|
|
100
|
+
await registerTools(ctx, state);
|
|
101
|
+
|
|
102
|
+
registrations.push(
|
|
103
|
+
await ctx.session.hook("prompt", (ev: V2PromptEvent) => {
|
|
104
|
+
captureFromText(ev.prompt?.text ?? "", state);
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
registrations.push(
|
|
108
|
+
await ctx.session.hook("context", (ev: V2ContextEvent) => {
|
|
109
|
+
injectOnce(ev.sessionID ?? "", state, (block) => {
|
|
110
|
+
ev.system.push({ type: "text", text: block });
|
|
111
|
+
});
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return () => {
|
|
116
|
+
for (const r of registrations) void r.dispose?.();
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
};
|
package/src/paths.ts
CHANGED
|
@@ -3,8 +3,23 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* D62: the data-root override env var. `OPEN_MEMEX_HOME` is the current
|
|
8
|
+
* name; `MY_O_MEMORY_HOME` (pre-rename package name) is still honored as a
|
|
9
|
+
* fallback so existing setups and scripts keep working. New name wins
|
|
10
|
+
* when both are set.
|
|
11
|
+
*/
|
|
12
|
+
export function homeOverride(): { dir: string; via: "OPEN_MEMEX_HOME" | "MY_O_MEMORY_HOME" } | null {
|
|
13
|
+
if (process.env.OPEN_MEMEX_HOME)
|
|
14
|
+
return { dir: path.resolve(process.env.OPEN_MEMEX_HOME), via: "OPEN_MEMEX_HOME" };
|
|
15
|
+
if (process.env.MY_O_MEMORY_HOME)
|
|
16
|
+
return { dir: path.resolve(process.env.MY_O_MEMORY_HOME), via: "MY_O_MEMORY_HOME" };
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
function dataRoot(): string {
|
|
7
|
-
|
|
21
|
+
const override = homeOverride();
|
|
22
|
+
if (override) return override.dir;
|
|
8
23
|
if (process.platform === "win32") {
|
|
9
24
|
const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
|
|
10
25
|
return path.join(appData, "open-memex");
|
|
@@ -81,6 +96,25 @@ export function inRepoMemoriesDir(root: string, memoryDir: string): string {
|
|
|
81
96
|
}
|
|
82
97
|
|
|
83
98
|
/** Same as `inRepoMemoriesDir` but never creates the directory. */
|
|
99
|
+
/**
|
|
100
|
+
* True when a memory file lives inside the user's git working tree (the
|
|
101
|
+
* in-repo `.ai/open-memex/` copy) rather than in the local data root.
|
|
102
|
+
*
|
|
103
|
+
* D70: both destructive/corrective surfaces need this — the hard delete
|
|
104
|
+
* already said "commit the deletion so the team sees it" (D64), and the
|
|
105
|
+
* soft hide has to say the same, because a retraction that never gets
|
|
106
|
+
* committed leaves the memory standing on every teammate's clone.
|
|
107
|
+
*/
|
|
108
|
+
export function isInRepoMemoryFile(filePath: string): boolean {
|
|
109
|
+
if (!filePath) return false;
|
|
110
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
111
|
+
return normalized.includes("/.ai/open-memex/") && !normalized.startsWith(root());
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function root(): string {
|
|
115
|
+
return paths().root.replace(/\\/g, "/");
|
|
116
|
+
}
|
|
117
|
+
|
|
84
118
|
export function inRepoMemoriesDirPath(root: string, memoryDir: string): string {
|
|
85
119
|
return path.join(root, memoryDir);
|
|
86
120
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { loadConfig, type MyOMemoryConfig } from "./config.ts";
|
|
2
|
+
import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE, type Scope } from "./scope.ts";
|
|
3
|
+
import { db } from "./store/db.ts";
|
|
4
|
+
import { syncScope, upsertFromFile } from "./store/sync.ts";
|
|
5
|
+
import { scopeHasFiles } from "./store/migrate.ts";
|
|
6
|
+
import {
|
|
7
|
+
writeMemoryFile,
|
|
8
|
+
readMemoryFile,
|
|
9
|
+
ulid,
|
|
10
|
+
msToRfc3339,
|
|
11
|
+
type Frontmatter,
|
|
12
|
+
} from "./store/markdown.ts";
|
|
13
|
+
import { buildContextBlock } from "./retrieve/inject.ts";
|
|
14
|
+
import { detectKeywords } from "./capture/keywords.ts";
|
|
15
|
+
import { findDuplicates } from "./store/lifecycle.ts";
|
|
16
|
+
import { redact } from "./redact.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Host-agnostic plugin core (D60). Both opencode host generations adapt to
|
|
20
|
+
* this module: the v1 plugin (src/index.ts `server`) and the v2 plugin
|
|
21
|
+
* (src/opencode-v2.ts `setup`). Host-specific glue — tool registration and
|
|
22
|
+
* hook shapes — stays in the adapters; everything memory-shaped lives here.
|
|
23
|
+
*/
|
|
24
|
+
export const PLUGIN_ID = "open-memex";
|
|
25
|
+
|
|
26
|
+
export interface PluginState {
|
|
27
|
+
cfg: MyOMemoryConfig;
|
|
28
|
+
scope: Scope;
|
|
29
|
+
/** Sessions that already received the one-time context injection. */
|
|
30
|
+
injectedSessions: Set<string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the project scope for a host working directory and bring the
|
|
35
|
+
* index up to date. Never throws: a broken store must not keep the host
|
|
36
|
+
* from starting — failures land in the host's log instead.
|
|
37
|
+
*/
|
|
38
|
+
export function bootstrapPlugin(root: string): PluginState {
|
|
39
|
+
const cfg = loadConfig();
|
|
40
|
+
const scope = resolveProjectScope(root);
|
|
41
|
+
try {
|
|
42
|
+
db();
|
|
43
|
+
syncScope(scope.key);
|
|
44
|
+
syncScope(PERSONAL_SCOPE.key);
|
|
45
|
+
if (cfg.logLevel === "debug") {
|
|
46
|
+
console.log(`[open-memex] loaded. scope=${scope.key}`);
|
|
47
|
+
}
|
|
48
|
+
// If a git remote was added *after* memories were first captured, the
|
|
49
|
+
// scope key changes (cwd-hash -> origin-hash). Detect the legacy dir on
|
|
50
|
+
// disk and tell the user how to migrate. We do NOT auto-migrate: two
|
|
51
|
+
// different repos at the same cwd would collide.
|
|
52
|
+
const cwdScope = resolveCwdScope(root);
|
|
53
|
+
if (cwdScope.key !== scope.key && scopeHasFiles(cwdScope.key)) {
|
|
54
|
+
console.warn(
|
|
55
|
+
`[open-memex] found memories under legacy scope key "${cwdScope.key}". ` +
|
|
56
|
+
`Current project scope is "${scope.key}". ` +
|
|
57
|
+
`To move them: npm run cli -- migrate --from ${cwdScope.key}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error("[open-memex] init failed:", err);
|
|
62
|
+
}
|
|
63
|
+
return { cfg, scope, injectedSessions: new Set<string>() };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Keyword capture (§ capture): scan one user message for "remember …"
|
|
68
|
+
* triggers and save each hit. Shared by the v1 `chat.message` hook and
|
|
69
|
+
* the v2 session `prompt` hook — both hand us the message text.
|
|
70
|
+
*/
|
|
71
|
+
export function captureFromText(text: string, state: PluginState): void {
|
|
72
|
+
const { cfg, scope } = state;
|
|
73
|
+
if (!cfg.keywordCaptureEnabled || !text) return;
|
|
74
|
+
const hits = detectKeywords(text, cfg);
|
|
75
|
+
for (const h of hits) {
|
|
76
|
+
const { content, hadSecret, matchedPattern } = redact(h.content, cfg.redactPatterns);
|
|
77
|
+
// Secrets are masked (first 4 chars kept) and the capture proceeds;
|
|
78
|
+
// skip only when nothing usable remains.
|
|
79
|
+
if (content.length === 0) continue;
|
|
80
|
+
if (hadSecret && cfg.logLevel === "debug") {
|
|
81
|
+
console.log(`[open-memex] keyword capture masked secret (${matchedPattern})`);
|
|
82
|
+
}
|
|
83
|
+
// Personal patterns ("remember for me" / "记住(个人)") force the personal scope.
|
|
84
|
+
const target = h.personal ? PERSONAL_SCOPE : scope;
|
|
85
|
+
// Dedup (§3.4): skip exact duplicates captured before.
|
|
86
|
+
if (findDuplicates(target.key, content).exact) continue;
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const rfc = msToRfc3339(now);
|
|
89
|
+
const fm: Frontmatter = {
|
|
90
|
+
id: ulid(),
|
|
91
|
+
schema_version: 2,
|
|
92
|
+
scope_key: target.key,
|
|
93
|
+
scope: target.kind === "project" ? "project" : "personal",
|
|
94
|
+
visibility: target.kind === "project" ? "internal" : "private",
|
|
95
|
+
project_name: target.projectName,
|
|
96
|
+
type: "fact",
|
|
97
|
+
role: "knowledge",
|
|
98
|
+
importance: "normal",
|
|
99
|
+
status: "active",
|
|
100
|
+
tags: ["keyword"],
|
|
101
|
+
source: "keyword",
|
|
102
|
+
created_at: rfc,
|
|
103
|
+
updated_at: rfc,
|
|
104
|
+
supersedes: null,
|
|
105
|
+
superseded_by: null,
|
|
106
|
+
review_state: "draft",
|
|
107
|
+
proposed_by: null,
|
|
108
|
+
approved_by: null,
|
|
109
|
+
derived_from: null,
|
|
110
|
+
review_note: null,
|
|
111
|
+
review_history: [],
|
|
112
|
+
};
|
|
113
|
+
try {
|
|
114
|
+
const { filePath } = writeMemoryFile(fm, content);
|
|
115
|
+
const mf = readMemoryFile(filePath);
|
|
116
|
+
if (mf) upsertFromFile(mf);
|
|
117
|
+
// Capture feedback: always visible (not debug-only) — the user said
|
|
118
|
+
// "记住…", they should see that it landed. The opencode plugin API
|
|
119
|
+
// offers no toast channel, so the plugin log is the feedback surface.
|
|
120
|
+
const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
|
|
121
|
+
console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error("[open-memex] keyword capture failed:", err);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* First-turn context injection: exactly once per session. The host adapter
|
|
130
|
+
* supplies `push` (v1: output.system.push; v2: the session context hook's
|
|
131
|
+
* system array). Returns true when a block was pushed.
|
|
132
|
+
*/
|
|
133
|
+
export function injectOnce(
|
|
134
|
+
sessionID: string,
|
|
135
|
+
state: PluginState,
|
|
136
|
+
push: (block: string) => void,
|
|
137
|
+
): boolean {
|
|
138
|
+
const { cfg, scope } = state;
|
|
139
|
+
if (!cfg.injectOnFirstTurn) return false;
|
|
140
|
+
if (state.injectedSessions.has(sessionID)) return false;
|
|
141
|
+
state.injectedSessions.add(sessionID);
|
|
142
|
+
try {
|
|
143
|
+
const block = buildContextBlock(scope, cfg);
|
|
144
|
+
if (block) {
|
|
145
|
+
push(block);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error("[open-memex] context injection failed:", err);
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display helpers (D68): how memory provenance, age, and state are
|
|
3
|
+
* rendered on the user-facing surfaces — the `memory_list` inventory an
|
|
4
|
+
* agent shows its user, and the `open-memex inventory` reports.
|
|
5
|
+
*
|
|
6
|
+
* Two rules from the 3-C design (v2) live here:
|
|
7
|
+
* - The list format is STRUCTURED data (number + id + created= + source=
|
|
8
|
+
* + state tags). The plain-language renderings ("you said this",
|
|
9
|
+
* "3 days ago") live in the guidance surfaces (tool descriptions,
|
|
10
|
+
* SKILL.md, MCP handshake) so an agent rephrases them in the
|
|
11
|
+
* conversation's language — the raw enums never get localized or
|
|
12
|
+
* reworded inside the data itself.
|
|
13
|
+
* - Nothing truncates silently: callers pair these lines with the true
|
|
14
|
+
* totals so a cut list says so.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Human age for a saved memory: "just now", "3 days ago", else a date. */
|
|
18
|
+
export function relativeTime(ts: number, now = Date.now()): string {
|
|
19
|
+
if (!ts || Number.isNaN(ts)) return "";
|
|
20
|
+
const diff = now - ts;
|
|
21
|
+
if (diff < 0) return "just now";
|
|
22
|
+
const minute = 60_000;
|
|
23
|
+
const hour = 60 * minute;
|
|
24
|
+
const day = 24 * hour;
|
|
25
|
+
if (diff < minute) return "just now";
|
|
26
|
+
if (diff < hour) {
|
|
27
|
+
const n = Math.floor(diff / minute);
|
|
28
|
+
return `${n} minute${n === 1 ? "" : "s"} ago`;
|
|
29
|
+
}
|
|
30
|
+
if (diff < day) {
|
|
31
|
+
const n = Math.floor(diff / hour);
|
|
32
|
+
return `${n} hour${n === 1 ? "" : "s"} ago`;
|
|
33
|
+
}
|
|
34
|
+
if (diff < 30 * day) {
|
|
35
|
+
const n = Math.floor(diff / day);
|
|
36
|
+
return `${n} day${n === 1 ? "" : "s"} ago`;
|
|
37
|
+
}
|
|
38
|
+
const d = new Date(ts);
|
|
39
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
40
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
41
|
+
return `on ${d.getFullYear()}-${mm}-${dd}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Compact age token for the structured list format: `3d`, `12h`, `now`. */
|
|
45
|
+
export function ageToken(ts: number | undefined, now = Date.now()): string {
|
|
46
|
+
if (!ts || Number.isNaN(ts)) return "?";
|
|
47
|
+
const diff = now - ts;
|
|
48
|
+
if (diff < 60_000) return "now";
|
|
49
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;
|
|
50
|
+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`;
|
|
51
|
+
if (diff < 60 * 86_400_000) return `${Math.floor(diff / 86_400_000)}d`;
|
|
52
|
+
const d = new Date(ts);
|
|
53
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
54
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
55
|
+
return `${d.getFullYear()}-${mm}-${dd}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Provenance in the user's words (D68). Used by prose surfaces (HTML
|
|
60
|
+
* report); the structured list keeps the raw `source=` enum instead.
|
|
61
|
+
* The distinction that matters to the user is *whose words these were* —
|
|
62
|
+
* said outright, asked to be saved, auto-captured, or inferred by the
|
|
63
|
+
* agent and possibly wrong.
|
|
64
|
+
*/
|
|
65
|
+
export function sourceLabel(source: string | undefined): string {
|
|
66
|
+
switch (source) {
|
|
67
|
+
case "user":
|
|
68
|
+
return "you said this";
|
|
69
|
+
case "cli":
|
|
70
|
+
return "you saved this from the command line";
|
|
71
|
+
case "tool":
|
|
72
|
+
return "you asked me to save this";
|
|
73
|
+
case "keyword":
|
|
74
|
+
return "auto-captured from your own wording";
|
|
75
|
+
case "inference":
|
|
76
|
+
return "I inferred this — not in your words, check me";
|
|
77
|
+
case "import":
|
|
78
|
+
return "imported from another machine";
|
|
79
|
+
default:
|
|
80
|
+
return source ? `saved (${source})` : "saved";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Review state in plain words for project scopes (D68). Personal
|
|
86
|
+
* memories are always `draft` by design; callers must not show this
|
|
87
|
+
* for them — "not yet shared" would be alarming noise there.
|
|
88
|
+
*/
|
|
89
|
+
export function reviewStateLabel(reviewState: string | undefined): string {
|
|
90
|
+
switch (reviewState) {
|
|
91
|
+
case "draft":
|
|
92
|
+
return "still in your outbox — not in the repo yet";
|
|
93
|
+
case "proposed":
|
|
94
|
+
return "shared with the project, waiting for review";
|
|
95
|
+
case "approved":
|
|
96
|
+
return "approved, not yet published";
|
|
97
|
+
case "published":
|
|
98
|
+
return "published to the repo";
|
|
99
|
+
case "rejected":
|
|
100
|
+
return "reviewed and rejected";
|
|
101
|
+
default:
|
|
102
|
+
return reviewState ?? "draft";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Escape user content for HTML. Memory text is arbitrary user input. */
|
|
107
|
+
export function escapeHtml(s: string): string {
|
|
108
|
+
return s
|
|
109
|
+
.replace(/&/g, "&")
|
|
110
|
+
.replace(/</g, "<")
|
|
111
|
+
.replace(/>/g, ">")
|
|
112
|
+
.replace(/"/g, """)
|
|
113
|
+
.replace(/'/g, "'");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface InventoryHit {
|
|
117
|
+
id: string;
|
|
118
|
+
type: string;
|
|
119
|
+
snippet: string;
|
|
120
|
+
source?: string;
|
|
121
|
+
created_at?: number;
|
|
122
|
+
updated_at?: number;
|
|
123
|
+
status: string;
|
|
124
|
+
review_state?: string;
|
|
125
|
+
scope_key: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* One structured inventory line (D68):
|
|
130
|
+
* 3. [fact] id=01K6… created=3d source=user [draft] — body text
|
|
131
|
+
* Number and id always appear together: the number is for the user's
|
|
132
|
+
* mouth ("delete #3"), the id is the machine contract. `created=` is
|
|
133
|
+
* when the memory was learned; `updated=` appears only when it moved
|
|
134
|
+
* materially later (supersede/promote touch updated_at).
|
|
135
|
+
*
|
|
136
|
+
* D70: `index` must be a running counter across every section of one
|
|
137
|
+
* listing — a per-section counter produces two "#1"s in a single
|
|
138
|
+
* `scope: both` output, which makes "delete #3" a guess.
|
|
139
|
+
*/
|
|
140
|
+
export function formatInventoryLine(
|
|
141
|
+
index: number,
|
|
142
|
+
hit: InventoryHit,
|
|
143
|
+
personalKey: string,
|
|
144
|
+
now = Date.now(),
|
|
145
|
+
): string {
|
|
146
|
+
const parts = [
|
|
147
|
+
`${index + 1}.`,
|
|
148
|
+
`[${hit.type}]`,
|
|
149
|
+
`id=${hit.id}`,
|
|
150
|
+
`created=${ageToken(hit.created_at ?? hit.updated_at, now)}`,
|
|
151
|
+
`source=${hit.source || "?"}`,
|
|
152
|
+
];
|
|
153
|
+
const created = hit.created_at ?? hit.updated_at ?? 0;
|
|
154
|
+
if (hit.updated_at && hit.updated_at - created >= 86_400_000) {
|
|
155
|
+
parts.push(`updated=${ageToken(hit.updated_at, now)}`);
|
|
156
|
+
}
|
|
157
|
+
if (hit.scope_key !== personalKey) {
|
|
158
|
+
const state = hit.review_state ?? "draft";
|
|
159
|
+
if (state !== "published") parts.push(`[${state}]`);
|
|
160
|
+
}
|
|
161
|
+
if (hit.status !== "active") parts.push(`[${hit.status}]`);
|
|
162
|
+
const body = hit.snippet.replace(/\s+/g, " ").trim();
|
|
163
|
+
return `${parts.join(" ")} — ${body}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Truncation disclosure (D68): a cut list always says so.
|
|
168
|
+
* D70: the wording names the surface that can show everything — the CLI —
|
|
169
|
+
* so a hidden entry is always reachable.
|
|
170
|
+
*/
|
|
171
|
+
export function truncationNote(shown: number, total: number, cli = "open-memex"): string | null {
|
|
172
|
+
if (total <= shown) return null;
|
|
173
|
+
return `… and ${total - shown} more not shown (raise limit up to 100, run \`${cli} list --limit 100\`, or use memory_search to find something specific).`;
|
|
174
|
+
}
|
package/src/retrieve/inject.ts
CHANGED
|
@@ -28,7 +28,7 @@ export function buildContextBlock(scope: Scope, cfg: MyOMemoryConfig): string |
|
|
|
28
28
|
|
|
29
29
|
lines.push(
|
|
30
30
|
"",
|
|
31
|
-
"Use the `memory_search` tool to look up more. Use `memory_add` to save new facts. Do not mention this block to the user unless asked.",
|
|
31
|
+
"Use the `memory_search` tool to look up more. Use `memory_add` to save new facts. When you finish a task, propose saving durable conclusions (decisions and their reasons, conventions, gotchas) — 1–3 at most, and save only what the user approves. Do not mention this block to the user unless asked.",
|
|
32
32
|
);
|
|
33
33
|
|
|
34
34
|
return lines.join("\n");
|
package/src/retrieve/search.ts
CHANGED
|
@@ -7,11 +7,16 @@ export interface SearchHit {
|
|
|
7
7
|
project_name: string;
|
|
8
8
|
type: string;
|
|
9
9
|
tags: string[];
|
|
10
|
+
/** D61: capture-time aliases stored on this memory (search hits only). */
|
|
11
|
+
aliases: string[];
|
|
10
12
|
snippet: string;
|
|
11
13
|
score: number;
|
|
12
14
|
updated_at: number;
|
|
13
15
|
status: string;
|
|
14
16
|
review_state: string;
|
|
17
|
+
/** D68: provenance + creation time for the plain-language inventory. */
|
|
18
|
+
source: string;
|
|
19
|
+
created_at: number;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
interface RawRow {
|
|
@@ -20,26 +25,40 @@ interface RawRow {
|
|
|
20
25
|
project_name: string;
|
|
21
26
|
type: string;
|
|
22
27
|
tags: string;
|
|
28
|
+
aliases?: string;
|
|
23
29
|
updated_at: number;
|
|
24
30
|
snippet: string;
|
|
25
31
|
score: number;
|
|
26
32
|
status: string;
|
|
27
33
|
superseded_by: string | null;
|
|
28
34
|
review_state: string;
|
|
35
|
+
source?: string;
|
|
36
|
+
created_at?: number;
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
function toHit(r: RawRow): SearchHit {
|
|
40
|
+
const aliases = r.aliases ? r.aliases.split(",").map((a) => a.trim()).filter(Boolean) : [];
|
|
41
|
+
let snippet = r.snippet ?? "";
|
|
42
|
+
// D61: the snippet highlights matches in the body column only. When the
|
|
43
|
+
// match came through an alias (body shows no highlight), say so — the
|
|
44
|
+
// hit otherwise looks unrelated to the query.
|
|
45
|
+
if (aliases.length > 0 && !/\[[^\]]+\]/.test(snippet)) {
|
|
46
|
+
snippet += ` (aka: ${aliases.join(", ")})`;
|
|
47
|
+
}
|
|
32
48
|
return {
|
|
33
49
|
id: r.id,
|
|
34
50
|
scope_key: r.scope_key,
|
|
35
51
|
project_name: r.project_name,
|
|
36
52
|
type: r.type,
|
|
37
53
|
tags: r.tags ? r.tags.split(",").filter(Boolean) : [],
|
|
38
|
-
|
|
54
|
+
aliases,
|
|
55
|
+
snippet,
|
|
39
56
|
score: r.score,
|
|
40
57
|
updated_at: r.updated_at,
|
|
41
58
|
status: r.status,
|
|
42
59
|
review_state: r.review_state ?? "draft",
|
|
60
|
+
source: r.source ?? "",
|
|
61
|
+
created_at: r.created_at ?? r.updated_at ?? 0,
|
|
43
62
|
};
|
|
44
63
|
}
|
|
45
64
|
|
|
@@ -87,7 +106,7 @@ function resolveVisible(rows: RawRow[], limit: number, stats?: SearchStats): Sea
|
|
|
87
106
|
const r = db()
|
|
88
107
|
.prepare(
|
|
89
108
|
`SELECT id, scope_key, project_name, type, tags, updated_at, status,
|
|
90
|
-
superseded_by, review_state, substr(content, 1, 240) AS snippet
|
|
109
|
+
superseded_by, review_state, source, created_at, substr(content, 1, 240) AS snippet
|
|
91
110
|
FROM memories WHERE id = ?`,
|
|
92
111
|
)
|
|
93
112
|
.get(id) as
|
|
@@ -161,8 +180,8 @@ export function search(
|
|
|
161
180
|
const typeFilter = opts.type ? ` AND m.type = ?` : "";
|
|
162
181
|
|
|
163
182
|
const sql = `
|
|
164
|
-
SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.updated_at,
|
|
165
|
-
m.status, m.superseded_by, m.review_state,
|
|
183
|
+
SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.aliases, m.updated_at,
|
|
184
|
+
m.status, m.superseded_by, m.review_state, m.source, m.created_at,
|
|
166
185
|
snippet(memories_fts, 0, '[', ']', ' ... ', 12) AS snippet,
|
|
167
186
|
bm25(memories_fts) AS score
|
|
168
187
|
FROM memories_fts
|
|
@@ -188,6 +207,8 @@ export function search(
|
|
|
188
207
|
status: string;
|
|
189
208
|
superseded_by: string | null;
|
|
190
209
|
review_state: string;
|
|
210
|
+
source: string;
|
|
211
|
+
created_at: number;
|
|
191
212
|
snippet: string;
|
|
192
213
|
score: number;
|
|
193
214
|
}>;
|
|
@@ -200,14 +221,14 @@ export function search(
|
|
|
200
221
|
|
|
201
222
|
export function list(
|
|
202
223
|
scopeKey: string,
|
|
203
|
-
opts: { type?: string; limit?: number } = {},
|
|
224
|
+
opts: { type?: string; limit?: number; include?: "active" | "all" } = {},
|
|
204
225
|
): SearchHit[] {
|
|
205
226
|
const limit = Math.max(1, Math.min(opts.limit ?? 20, 100));
|
|
206
227
|
const fetchLimit = Math.min(limit * 3 + 10, 150);
|
|
207
228
|
const typeFilter = opts.type ? ` AND type = ?` : "";
|
|
208
229
|
const sql = `
|
|
209
230
|
SELECT id, scope_key, project_name, type, tags, updated_at, status,
|
|
210
|
-
superseded_by, review_state, substr(content, 1, 240) AS snippet
|
|
231
|
+
superseded_by, review_state, source, created_at, substr(content, 1, 240) AS snippet
|
|
211
232
|
FROM memories
|
|
212
233
|
WHERE scope_key = ?${typeFilter}
|
|
213
234
|
ORDER BY updated_at DESC
|
|
@@ -229,9 +250,45 @@ export function list(
|
|
|
229
250
|
status: string;
|
|
230
251
|
superseded_by: string | null;
|
|
231
252
|
review_state: string;
|
|
253
|
+
source: string;
|
|
254
|
+
created_at: number;
|
|
232
255
|
snippet: string;
|
|
233
256
|
}>;
|
|
234
257
|
|
|
235
258
|
const raw: RawRow[] = rows.map((r) => ({ ...r, score: 1 }));
|
|
259
|
+
if (opts.include === "all") {
|
|
260
|
+
// D68: the audit view — every row as stored (superseded versions,
|
|
261
|
+
// retracted, archived), no chain collapsing, capped at limit.
|
|
262
|
+
return raw.slice(0, limit).map(toHit);
|
|
263
|
+
}
|
|
236
264
|
return resolveVisible(raw, limit);
|
|
237
265
|
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* True totals for the inventory's truncation disclosure (D68). Under
|
|
269
|
+
* `active` the visible set is the newest of each chain, which in a
|
|
270
|
+
* consistent store is exactly the rows whose status is active/deprecated.
|
|
271
|
+
*
|
|
272
|
+
* D70: one deliberate exception — a `superseded` row whose chain target is
|
|
273
|
+
* missing (a dangling pointer, which the read path self-heals with a
|
|
274
|
+
* warning) is returned as a visible hit by resolveVisible but is not
|
|
275
|
+
* counted here, so the total can read one low in that state. Not worth
|
|
276
|
+
* widening the query for a self-healing case; the disclosure stays honest
|
|
277
|
+
* in every state a user can reach deliberately.
|
|
278
|
+
*/
|
|
279
|
+
export function countList(
|
|
280
|
+
scopeKey: string,
|
|
281
|
+
opts: { type?: string; include?: "active" | "all" } = {},
|
|
282
|
+
): number {
|
|
283
|
+
const typeFilter = opts.type ? ` AND type = ?` : "";
|
|
284
|
+
const statusFilter =
|
|
285
|
+
opts.include === "all" ? "" : ` AND status IN ('active', 'deprecated')`;
|
|
286
|
+
const params: unknown[] = [scopeKey];
|
|
287
|
+
if (opts.type) params.push(opts.type);
|
|
288
|
+
const row = db()
|
|
289
|
+
.prepare(
|
|
290
|
+
`SELECT COUNT(*) AS n FROM memories WHERE scope_key = ?${statusFilter}${typeFilter}`,
|
|
291
|
+
)
|
|
292
|
+
.get(...(params as any[])) as { n: number };
|
|
293
|
+
return row.n;
|
|
294
|
+
}
|
package/src/review.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { resolveProjectScope } from "./scope.ts";
|
|
|
27
27
|
import {
|
|
28
28
|
parse as parseMemory,
|
|
29
29
|
serialize,
|
|
30
|
+
atomicWriteTextSync,
|
|
30
31
|
ulid,
|
|
31
32
|
writeMemoryFile,
|
|
32
33
|
type Frontmatter,
|
|
@@ -107,6 +108,12 @@ function copyPersonalToProject(
|
|
|
107
108
|
id: ulid(),
|
|
108
109
|
scope: "project",
|
|
109
110
|
scope_key: project.key,
|
|
111
|
+
// D64: the copy is headed for the team. Source visibility (usually
|
|
112
|
+
// "private") must not ride along: export excludes private by default,
|
|
113
|
+
// so a private copy would silently vanish from every bundle the team
|
|
114
|
+
// builds. Proposing IS the act of sharing — the copy is internal;
|
|
115
|
+
// the personal original keeps its own visibility, untouched.
|
|
116
|
+
visibility: "internal",
|
|
110
117
|
project_name: project.projectName,
|
|
111
118
|
created_at: now,
|
|
112
119
|
updated_at: now,
|
|
@@ -240,7 +247,7 @@ export function promoteMemory(
|
|
|
240
247
|
},
|
|
241
248
|
],
|
|
242
249
|
};
|
|
243
|
-
|
|
250
|
+
atomicWriteTextSync(mf.filePath, serialize(fm, mf.body));
|
|
244
251
|
upsertFromFile({ fm, body: mf.body, filePath: mf.filePath, mtimeMs: Date.now() });
|
|
245
252
|
return { id, from, to, history: fm.review_history };
|
|
246
253
|
}
|
|
@@ -494,7 +501,7 @@ export function resolveConflict(target: string): ResolveOutcome {
|
|
|
494
501
|
if (conflicts.length > 0) return { ok: false, filePath, conflicts };
|
|
495
502
|
|
|
496
503
|
const fm = merged as unknown as Frontmatter;
|
|
497
|
-
|
|
504
|
+
atomicWriteTextSync(filePath, serialize(fm, mergedBody));
|
|
498
505
|
upsertFromFile({ fm, body: mergedBody, filePath, mtimeMs: Date.now() });
|
|
499
506
|
return { ok: true, filePath, autoMerged: [...new Set(autoMerged)] };
|
|
500
507
|
}
|