omo-memory 0.1.12 → 0.1.14
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 +18 -0
- package/dist/autoUpdate.js +84 -0
- package/dist/cli.js +18 -1
- package/dist/globalMemory.js +22 -1
- package/dist/graphTui.js +26 -31
- package/dist/graphTuiCanvas.js +104 -0
- package/dist/ontologyGraph.js +4 -41
- package/dist/ontologyGraphEdges.js +86 -0
- package/docs/adapter-integration.md +4 -2
- package/package.json +4 -2
- package/scripts/omo-memory-user-prompt.mjs +107 -0
package/README.md
CHANGED
|
@@ -39,6 +39,7 @@ After the package is published to npm, use the same package for CLI and MCP:
|
|
|
39
39
|
|
|
40
40
|
```sh
|
|
41
41
|
npx -y omo-memory init
|
|
42
|
+
npx -y omo-memory update
|
|
42
43
|
npx -y omo-memory global scan --root .
|
|
43
44
|
npx -y omo-memory global migrate --root . --global-db ~/.omo/memory/global.sqlite
|
|
44
45
|
npx -y omo-memory session bootstrap --host codex --adapter lazycodex --limit 5
|
|
@@ -105,6 +106,7 @@ The response contains a new `sessionId` and project metadata only. It deliberate
|
|
|
105
106
|
```
|
|
106
107
|
|
|
107
108
|
This is local routing, not transcript scraping. OMO Memory does not automatically read full Codex or Grok transcripts. Hooks should record concise user actions, decisions, QA evidence, and handoffs; they should retrieve memory only when the user explicitly asks for OMO Memory or when the current user input can be matched to recorded intent.
|
|
109
|
+
The packaged `scripts/omo-memory-user-prompt.mjs` helper is the supported UserPromptSubmit hook target for adapters that can invoke a command with the hook payload on stdin. It records only the current user prompt as a redacted `user_prompt` event, ignores assistant output, and exits successfully without blocking the host when OMO Memory is unavailable.
|
|
108
110
|
|
|
109
111
|
Use explicit retrieval for memory reads:
|
|
110
112
|
|
|
@@ -140,6 +142,22 @@ Initial stdio MCP tools:
|
|
|
140
142
|
- `memory_ontology_supersede`
|
|
141
143
|
- `memory_ontology_recall`
|
|
142
144
|
|
|
145
|
+
## Updates
|
|
146
|
+
|
|
147
|
+
Installed CLI commands automatically launch a quiet background `npm install -g omo-memory@latest` at most once per day. MCP startup does not run the updater, so stdio handshakes stay clean.
|
|
148
|
+
|
|
149
|
+
Manual update:
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
omo-memory update
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Disable automatic update for pinned environments:
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
OMO_MEMORY_AUTO_UPDATE=0 omo-memory doctor
|
|
159
|
+
```
|
|
160
|
+
|
|
143
161
|
## Second-brain layer
|
|
144
162
|
|
|
145
163
|
The base ledger remains project-local and chronological: sessions, events, handoffs, and explicit recall. The second-brain layer adds deterministic ontology tables and lifecycle commands:
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
const PACKAGE_NAME = "omo-memory";
|
|
6
|
+
const DEFAULT_INTERVAL_MS = 86_400_000;
|
|
7
|
+
const STATE_PATH = join(homedir(), ".omo", "memory", "auto-update.json");
|
|
8
|
+
export function maybeRunAutoUpdate(currentVersion, nowMs = Date.now()) {
|
|
9
|
+
const statePath = process.env["OMO_MEMORY_UPDATE_STATE"] ?? STATE_PATH;
|
|
10
|
+
if (!shouldAttemptAutoUpdate({ nowMs, statePath }))
|
|
11
|
+
return;
|
|
12
|
+
writeAttemptStamp(statePath, nowMs);
|
|
13
|
+
const child = spawn(npmCommand(), installArgs(), {
|
|
14
|
+
detached: true,
|
|
15
|
+
stdio: "ignore",
|
|
16
|
+
env: updateEnv(currentVersion),
|
|
17
|
+
});
|
|
18
|
+
child.unref();
|
|
19
|
+
}
|
|
20
|
+
export function runAutoUpdate(currentVersion) {
|
|
21
|
+
const command = npmCommand();
|
|
22
|
+
const args = installArgs();
|
|
23
|
+
const result = spawnSync(command, args, { encoding: "utf8", env: updateEnv(currentVersion) });
|
|
24
|
+
return {
|
|
25
|
+
ok: result.status === 0,
|
|
26
|
+
packageName: PACKAGE_NAME,
|
|
27
|
+
currentVersion,
|
|
28
|
+
command: [command, ...args],
|
|
29
|
+
status: result.status,
|
|
30
|
+
stdout: result.stdout,
|
|
31
|
+
stderr: result.stderr,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function shouldAttemptAutoUpdate(input) {
|
|
35
|
+
if (process.env["OMO_MEMORY_AUTO_UPDATE"] === "0")
|
|
36
|
+
return false;
|
|
37
|
+
if (process.env["OMO_MEMORY_AUTO_UPDATE"] === "false")
|
|
38
|
+
return false;
|
|
39
|
+
if (process.env["OMO_MEMORY_AUTO_UPDATE_CHILD"] === "1")
|
|
40
|
+
return false;
|
|
41
|
+
const intervalMs = updateIntervalMs();
|
|
42
|
+
if (!existsSync(input.statePath))
|
|
43
|
+
return true;
|
|
44
|
+
const lastAttemptMs = readLastAttemptMs(input.statePath);
|
|
45
|
+
return lastAttemptMs === null || input.nowMs - lastAttemptMs >= intervalMs;
|
|
46
|
+
}
|
|
47
|
+
function updateIntervalMs() {
|
|
48
|
+
const raw = process.env["OMO_MEMORY_AUTO_UPDATE_INTERVAL_MS"];
|
|
49
|
+
if (raw === undefined)
|
|
50
|
+
return DEFAULT_INTERVAL_MS;
|
|
51
|
+
const parsed = Number(raw);
|
|
52
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_INTERVAL_MS;
|
|
53
|
+
}
|
|
54
|
+
function readLastAttemptMs(statePath) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(readFileSync(statePath, "utf8"));
|
|
57
|
+
if (!isRecord(parsed))
|
|
58
|
+
return null;
|
|
59
|
+
const value = parsed["lastAttemptMs"];
|
|
60
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error instanceof Error)
|
|
64
|
+
return null;
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function writeAttemptStamp(statePath, nowMs) {
|
|
69
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
70
|
+
writeFileSync(statePath, `${JSON.stringify({ lastAttemptMs: nowMs })}\n`);
|
|
71
|
+
}
|
|
72
|
+
function npmCommand() {
|
|
73
|
+
return process.env["OMO_MEMORY_NPM_COMMAND"] ?? "npm";
|
|
74
|
+
}
|
|
75
|
+
function installArgs() {
|
|
76
|
+
const target = process.env["OMO_MEMORY_UPDATE_TARGET"] ?? `${PACKAGE_NAME}@latest`;
|
|
77
|
+
return ["install", "-g", target];
|
|
78
|
+
}
|
|
79
|
+
function updateEnv(currentVersion) {
|
|
80
|
+
return { ...process.env, OMO_MEMORY_AUTO_UPDATE_CHILD: "1", OMO_MEMORY_CURRENT_VERSION: currentVersion };
|
|
81
|
+
}
|
|
82
|
+
function isRecord(value) {
|
|
83
|
+
return value !== null && typeof value === "object";
|
|
84
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { maybeRunAutoUpdate, runAutoUpdate } from "./autoUpdate.js";
|
|
3
4
|
import { applyConceptExtraction } from "./conceptExtraction.js";
|
|
4
5
|
import { migrateToGlobalMemory, scanForMemoryDbs } from "./globalMemory.js";
|
|
5
6
|
import { runGraphTui } from "./graphTui.js";
|
|
@@ -21,6 +22,12 @@ async function main(argv) {
|
|
|
21
22
|
await runMcpServer();
|
|
22
23
|
return;
|
|
23
24
|
}
|
|
25
|
+
const currentVersion = readPackageVersion();
|
|
26
|
+
if (command === "update") {
|
|
27
|
+
process.stdout.write(`${JSON.stringify(runAutoUpdate(currentVersion), null, 2)}\n`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
maybeRunAutoUpdate(currentVersion);
|
|
24
31
|
if (command === "graph" && subcommand === "tui") {
|
|
25
32
|
const query = readFlag(rest, "--query");
|
|
26
33
|
await runGraphTui({ dbPath: readFlag(rest, "--db") ?? defaultDbPath(), ...(query === undefined ? {} : { query }) });
|
|
@@ -200,7 +207,17 @@ function fail(message) {
|
|
|
200
207
|
throw new Error(message);
|
|
201
208
|
}
|
|
202
209
|
function printHelp() {
|
|
203
|
-
process.stdout.write(`OMO Memory\n\nCommands:\n omo-memory init\n omo-memory doctor\n omo-memory export\n omo-memory purge --yes\n omo-memory global scan --root <path> [--json]\n omo-memory global migrate --root <path> --global-db <path> [--json]\n omo-memory ontology candidates\n omo-memory ontology score\n omo-memory ontology promote --concept <label|id> [--summary <text>] [--body <text>]\n omo-memory ontology demote --id <durable-id>\n omo-memory ontology supersede --id <durable-id> [--summary <text>]\n omo-memory ontology recall --query <text> [--limit <n>]\n omo-memory session start --host <codex|opencode|grok|unknown> --adapter <name>\n omo-memory session bootstrap --host <codex|opencode|grok|unknown> --adapter <name> [--limit <n>]\n omo-memory event record --type <type> --summary <text> [--session-id <id>]\n omo-memory recent [--limit <n>]\n omo-memory recall --query <text> [--limit <n>]\n omo-memory handoff write (--summary <text> | --summary-file <path>) [--session-id <id>]\n omo-memory graph tui [--db <path>] [--query <text>] (requires Bun on PATH)\n omo-memory mcp\n`);
|
|
210
|
+
process.stdout.write(`OMO Memory\n\nCommands:\n omo-memory init\n omo-memory doctor\n omo-memory update\n omo-memory export\n omo-memory purge --yes\n omo-memory global scan --root <path> [--json]\n omo-memory global migrate --root <path> --global-db <path> [--json]\n omo-memory ontology candidates\n omo-memory ontology score\n omo-memory ontology promote --concept <label|id> [--summary <text>] [--body <text>]\n omo-memory ontology demote --id <durable-id>\n omo-memory ontology supersede --id <durable-id> [--summary <text>]\n omo-memory ontology recall --query <text> [--limit <n>]\n omo-memory session start --host <codex|opencode|grok|unknown> --adapter <name>\n omo-memory session bootstrap --host <codex|opencode|grok|unknown> --adapter <name> [--limit <n>]\n omo-memory event record --type <type> --summary <text> [--session-id <id>]\n omo-memory recent [--limit <n>]\n omo-memory recall --query <text> [--limit <n>]\n omo-memory handoff write (--summary <text> | --summary-file <path>) [--session-id <id>]\n omo-memory graph tui [--db <path>] [--query <text>] (requires Bun on PATH)\n omo-memory mcp\n`);
|
|
211
|
+
}
|
|
212
|
+
function readPackageVersion() {
|
|
213
|
+
const rawPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
214
|
+
if (!isObject(rawPackage))
|
|
215
|
+
return "0.0.0";
|
|
216
|
+
const version = rawPackage["version"];
|
|
217
|
+
return typeof version === "string" && version.length > 0 ? version : "0.0.0";
|
|
218
|
+
}
|
|
219
|
+
function isObject(value) {
|
|
220
|
+
return value !== null && typeof value === "object";
|
|
204
221
|
}
|
|
205
222
|
main(process.argv.slice(2)).catch((error) => {
|
|
206
223
|
const message = error instanceof Error ? error.message : String(error);
|
package/dist/globalMemory.js
CHANGED
|
@@ -6,6 +6,22 @@ import { migrate } from "./memoryDb.js";
|
|
|
6
6
|
import { resolveProjectContext } from "./projectContext.js";
|
|
7
7
|
const STATE_DB_SUFFIX = join(".omo", "memory", "state.sqlite");
|
|
8
8
|
const REQUIRED_TABLES = ["schema_meta", "projects", "events"];
|
|
9
|
+
const PRUNED_DIR_NAMES = new Set([
|
|
10
|
+
".cache",
|
|
11
|
+
".git",
|
|
12
|
+
".hg",
|
|
13
|
+
".next",
|
|
14
|
+
".pnpm-store",
|
|
15
|
+
".turbo",
|
|
16
|
+
".venv",
|
|
17
|
+
".yarn",
|
|
18
|
+
"Library",
|
|
19
|
+
"build",
|
|
20
|
+
"dist",
|
|
21
|
+
"node_modules",
|
|
22
|
+
"target",
|
|
23
|
+
"vendor",
|
|
24
|
+
]);
|
|
9
25
|
export function initGlobalMemory(globalDbPath) {
|
|
10
26
|
mkdirSync(dirname(globalDbPath), { recursive: true });
|
|
11
27
|
const db = new Database(globalDbPath);
|
|
@@ -105,7 +121,7 @@ function findStateDbs(rootPath) {
|
|
|
105
121
|
}
|
|
106
122
|
for (const entry of entries) {
|
|
107
123
|
const entryPath = join(path, entry.name);
|
|
108
|
-
if (entry.isDirectory())
|
|
124
|
+
if (entry.isDirectory() && shouldVisitDirectory(entry.name, entryPath))
|
|
109
125
|
visit(entryPath);
|
|
110
126
|
else if (entry.isFile() && entryPath.endsWith(STATE_DB_SUFFIX))
|
|
111
127
|
dbPaths.push(entryPath);
|
|
@@ -114,6 +130,11 @@ function findStateDbs(rootPath) {
|
|
|
114
130
|
visit(rootPath);
|
|
115
131
|
return dbPaths.sort();
|
|
116
132
|
}
|
|
133
|
+
function shouldVisitDirectory(name, path) {
|
|
134
|
+
if (path.endsWith(join(".omo", "memory")))
|
|
135
|
+
return true;
|
|
136
|
+
return !PRUNED_DIR_NAMES.has(name);
|
|
137
|
+
}
|
|
117
138
|
function scanSourceDb(dbPath) {
|
|
118
139
|
let db;
|
|
119
140
|
try {
|
package/dist/graphTui.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { BoxRenderable, createCliRenderer, TextRenderable } from "@opentui/core";
|
|
3
|
+
import { classCode, graphContent } from "./graphTuiCanvas.js";
|
|
3
4
|
const SNAPSHOT_ENV = "OMO_MEMORY_GRAPH_TUI_SNAPSHOT";
|
|
4
5
|
const COLORS = {
|
|
5
6
|
background: "#101419",
|
|
@@ -76,7 +77,7 @@ export async function runGraphTui(options) {
|
|
|
76
77
|
graph = loadGraph(state);
|
|
77
78
|
state = { ...state, selectedId: graph.detail?.id ?? null };
|
|
78
79
|
title.content = titleText(graph, state.options.query);
|
|
79
|
-
nodesText.content =
|
|
80
|
+
nodesText.content = graphContent(graph);
|
|
80
81
|
detailText.content = detailContent(graph);
|
|
81
82
|
footer.content = "q quit | ArrowUp/ArrowDown/Tab select | Legend: D durable, W working, T temporary, E ephemeral";
|
|
82
83
|
renderer.requestRender();
|
|
@@ -133,13 +134,7 @@ async function createGraphSnapshot(options) {
|
|
|
133
134
|
dbPath: options.dbPath,
|
|
134
135
|
...(options.query === undefined ? {} : { query: options.query }),
|
|
135
136
|
});
|
|
136
|
-
const details = graph.
|
|
137
|
-
.map((node) => projectOntologyGraph({
|
|
138
|
-
dbPath: options.dbPath,
|
|
139
|
-
...(options.query === undefined ? {} : { query: options.query }),
|
|
140
|
-
selectedId: node.id,
|
|
141
|
-
}).detail)
|
|
142
|
-
.filter((detail) => detail !== null);
|
|
137
|
+
const details = graph.detail === null ? [] : [graph.detail];
|
|
143
138
|
return { graph, details };
|
|
144
139
|
}
|
|
145
140
|
function parseSnapshot(raw) {
|
|
@@ -160,23 +155,37 @@ function isGraphSnapshot(value) {
|
|
|
160
155
|
}
|
|
161
156
|
function loadGraph(state) {
|
|
162
157
|
const selectedId = state.selectedId ?? state.snapshot.graph.detail?.id ?? state.snapshot.graph.nodes[0]?.id ?? null;
|
|
163
|
-
const
|
|
158
|
+
const selectedNode = selectedId === null ? undefined : state.snapshot.graph.nodes.find((node) => node.id === selectedId);
|
|
159
|
+
const detail = selectedId === null ? state.snapshot.graph.detail : (state.snapshot.details.find((item) => item.id === selectedId) ?? nodeDetail(selectedNode) ?? null);
|
|
164
160
|
return {
|
|
165
161
|
...state.snapshot.graph,
|
|
166
162
|
nodes: state.snapshot.graph.nodes.map((node) => ({ ...node, selected: node.id === selectedId })),
|
|
167
163
|
detail,
|
|
168
164
|
};
|
|
169
165
|
}
|
|
166
|
+
function nodeDetail(node) {
|
|
167
|
+
if (node === undefined)
|
|
168
|
+
return null;
|
|
169
|
+
return {
|
|
170
|
+
id: node.id,
|
|
171
|
+
label: node.label,
|
|
172
|
+
kind: node.kind,
|
|
173
|
+
description: node.description,
|
|
174
|
+
aliases: node.aliases,
|
|
175
|
+
retentionClass: node.retentionClass,
|
|
176
|
+
score: node.score,
|
|
177
|
+
scoreLabel: node.scoreLabel,
|
|
178
|
+
refCount: node.refCount,
|
|
179
|
+
projectSpread: node.projectSpread,
|
|
180
|
+
firstSeen: node.firstSeen,
|
|
181
|
+
lastSeen: node.lastSeen,
|
|
182
|
+
project: node.project,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
170
185
|
function titleText(graph, query) {
|
|
171
186
|
const queryLabel = query === undefined ? "all concepts" : `query "${query}"`;
|
|
172
187
|
return `OMO Ontology Graph - ${queryLabel} - ${graph.nodes.length} nodes / ${graph.edges.length} edges`;
|
|
173
188
|
}
|
|
174
|
-
function nodesContent(graph) {
|
|
175
|
-
if (graph.nodes.length === 0)
|
|
176
|
-
return graph.message ?? "No ontology graph data is available yet.";
|
|
177
|
-
const edges = graph.edges.map((edge) => ` ${edge.sourceId} -> ${edge.targetId} [${edge.label}]`);
|
|
178
|
-
return [...graph.nodes.map((node) => nodeLine(node)), "", "Relations", ...(edges.length === 0 ? [" none in current filter"] : edges)].join("\n");
|
|
179
|
-
}
|
|
180
189
|
function detailContent(graph) {
|
|
181
190
|
if (graph.detail === null)
|
|
182
191
|
return graph.message ?? "No ontology graph data is available yet.";
|
|
@@ -203,8 +212,8 @@ function writeCaptureFrame(graph, query) {
|
|
|
203
212
|
process.stdout.write([
|
|
204
213
|
titleText(graph, query),
|
|
205
214
|
"",
|
|
206
|
-
"
|
|
207
|
-
|
|
215
|
+
"Graph",
|
|
216
|
+
graphContent(graph),
|
|
208
217
|
"",
|
|
209
218
|
"Detail",
|
|
210
219
|
detailContent(graph),
|
|
@@ -213,20 +222,6 @@ function writeCaptureFrame(graph, query) {
|
|
|
213
222
|
"",
|
|
214
223
|
].join("\n"));
|
|
215
224
|
}
|
|
216
|
-
function nodeLine(node) {
|
|
217
|
-
const marker = node.selected ? ">" : " ";
|
|
218
|
-
return `${marker} ${classCode(node.retentionClass)} ${node.label} (${node.kind}, ${node.scoreLabel}, refs ${node.refCount})`;
|
|
219
|
-
}
|
|
220
|
-
function classCode(retentionClass) {
|
|
221
|
-
const normalized = retentionClass.toLowerCase();
|
|
222
|
-
if (normalized === "durable")
|
|
223
|
-
return "D";
|
|
224
|
-
if (normalized === "temporary")
|
|
225
|
-
return "T";
|
|
226
|
-
if (normalized === "ephemeral")
|
|
227
|
-
return "E";
|
|
228
|
-
return "W";
|
|
229
|
-
}
|
|
230
225
|
function nextSelectedId(nodes, selectedId, keyName) {
|
|
231
226
|
if (nodes.length === 0)
|
|
232
227
|
return null;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const GRAPH_WIDTH = 66;
|
|
2
|
+
const GRAPH_HEIGHT = 24;
|
|
3
|
+
const NODE_LIMIT = 28;
|
|
4
|
+
export function graphContent(graph) {
|
|
5
|
+
if (graph.nodes.length === 0)
|
|
6
|
+
return graph.message ?? "No ontology graph data is available yet.";
|
|
7
|
+
const drawnNodes = graph.nodes.slice(0, NODE_LIMIT);
|
|
8
|
+
const positions = layoutNodes(drawnNodes);
|
|
9
|
+
const rows = Array.from({ length: GRAPH_HEIGHT }, () => Array.from({ length: GRAPH_WIDTH }, () => " "));
|
|
10
|
+
for (const edge of graph.edges) {
|
|
11
|
+
const source = positions.get(edge.sourceId);
|
|
12
|
+
const target = positions.get(edge.targetId);
|
|
13
|
+
if (source === undefined || target === undefined)
|
|
14
|
+
continue;
|
|
15
|
+
drawEdge(rows, source, target);
|
|
16
|
+
}
|
|
17
|
+
for (const node of drawnNodes) {
|
|
18
|
+
const position = positions.get(node.id);
|
|
19
|
+
if (position === undefined)
|
|
20
|
+
continue;
|
|
21
|
+
drawNode(rows, node, position);
|
|
22
|
+
}
|
|
23
|
+
const graphLines = rows.map((row) => row.join("").trimEnd());
|
|
24
|
+
const legend = drawnNodes.map((node) => nodeLine(node));
|
|
25
|
+
return [...graphLines, "", "Nodes", ...legend, "", "Relations", ...relationLines(graph, positions)].join("\n");
|
|
26
|
+
}
|
|
27
|
+
export function nodeLine(node) {
|
|
28
|
+
const marker = node.selected ? ">" : " ";
|
|
29
|
+
return `${marker} ${classCode(node.retentionClass)} ${node.label} (${node.kind}, ${node.scoreLabel}, refs ${node.refCount})`;
|
|
30
|
+
}
|
|
31
|
+
export function classCode(retentionClass) {
|
|
32
|
+
const normalized = retentionClass.toLowerCase();
|
|
33
|
+
if (normalized === "durable")
|
|
34
|
+
return "D";
|
|
35
|
+
if (normalized === "temporary")
|
|
36
|
+
return "T";
|
|
37
|
+
if (normalized === "ephemeral")
|
|
38
|
+
return "E";
|
|
39
|
+
return "W";
|
|
40
|
+
}
|
|
41
|
+
function layoutNodes(nodes) {
|
|
42
|
+
const centerX = Math.floor(GRAPH_WIDTH / 2);
|
|
43
|
+
const centerY = Math.floor(GRAPH_HEIGHT / 2);
|
|
44
|
+
const radiusX = Math.max(8, Math.floor(GRAPH_WIDTH / 2) - 8);
|
|
45
|
+
const radiusY = Math.max(4, Math.floor(GRAPH_HEIGHT / 2) - 3);
|
|
46
|
+
const positions = new Map();
|
|
47
|
+
nodes.forEach((node, index) => {
|
|
48
|
+
const angle = (2 * Math.PI * index) / Math.max(1, nodes.length);
|
|
49
|
+
const scorePull = Math.max(0.55, 1 - Math.min(90, node.score) / 220);
|
|
50
|
+
positions.set(node.id, {
|
|
51
|
+
x: clamp(Math.round(centerX + Math.cos(angle) * radiusX * scorePull), 2, GRAPH_WIDTH - 3),
|
|
52
|
+
y: clamp(Math.round(centerY + Math.sin(angle) * radiusY * scorePull), 1, GRAPH_HEIGHT - 2),
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
return positions;
|
|
56
|
+
}
|
|
57
|
+
function drawEdge(rows, source, target) {
|
|
58
|
+
const steps = Math.max(Math.abs(target.x - source.x), Math.abs(target.y - source.y), 1);
|
|
59
|
+
for (let index = 1; index < steps; index += 1) {
|
|
60
|
+
const x = Math.round(source.x + ((target.x - source.x) * index) / steps);
|
|
61
|
+
const y = Math.round(source.y + ((target.y - source.y) * index) / steps);
|
|
62
|
+
const row = rows[y];
|
|
63
|
+
const current = row?.[x];
|
|
64
|
+
if (row === undefined || current === undefined || current !== " ")
|
|
65
|
+
continue;
|
|
66
|
+
row[x] = edgeGlyph(source, target);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function edgeGlyph(source, target) {
|
|
70
|
+
const dx = target.x - source.x;
|
|
71
|
+
const dy = target.y - source.y;
|
|
72
|
+
if (Math.abs(dx) > Math.abs(dy) * 2)
|
|
73
|
+
return "-";
|
|
74
|
+
if (Math.abs(dy) > Math.abs(dx) * 2)
|
|
75
|
+
return "|";
|
|
76
|
+
return dx * dy > 0 ? "\\" : "/";
|
|
77
|
+
}
|
|
78
|
+
function drawNode(rows, node, point) {
|
|
79
|
+
const glyph = node.selected ? "●" : classCode(node.retentionClass);
|
|
80
|
+
const label = `${glyph}${shortLabel(node.label)}`;
|
|
81
|
+
const startX = clamp(point.x - Math.floor(label.length / 2), 0, Math.max(0, GRAPH_WIDTH - label.length));
|
|
82
|
+
for (let index = 0; index < label.length; index += 1) {
|
|
83
|
+
const row = rows[point.y];
|
|
84
|
+
if (row === undefined)
|
|
85
|
+
continue;
|
|
86
|
+
row[startX + index] = label[index] ?? " ";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function shortLabel(label) {
|
|
90
|
+
const compact = label.replace(/\s+/g, " ").trim();
|
|
91
|
+
return compact.length <= 11 ? compact : compact.slice(0, 10);
|
|
92
|
+
}
|
|
93
|
+
function relationLines(graph, positions) {
|
|
94
|
+
const visible = graph.edges.filter((edge) => positions.has(edge.sourceId) && positions.has(edge.targetId)).slice(0, 12);
|
|
95
|
+
if (visible.length === 0)
|
|
96
|
+
return [" none in current filter"];
|
|
97
|
+
return visible.map((edge) => ` ${nodeLabel(graph, edge.sourceId)} -> ${nodeLabel(graph, edge.targetId)} [${edge.label}]`);
|
|
98
|
+
}
|
|
99
|
+
function nodeLabel(graph, nodeId) {
|
|
100
|
+
return graph.nodes.find((node) => node.id === nodeId)?.label ?? nodeId;
|
|
101
|
+
}
|
|
102
|
+
function clamp(value, min, max) {
|
|
103
|
+
return Math.min(max, Math.max(min, value));
|
|
104
|
+
}
|
package/dist/ontologyGraph.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { migrate, openMemoryDb } from "./memoryDb.js";
|
|
2
|
+
import { readGraphEdges } from "./ontologyGraphEdges.js";
|
|
2
3
|
import { redactSecrets, sanitizeGitRemote } from "./privacy.js";
|
|
3
4
|
function isRecord(value) {
|
|
4
5
|
return value !== null && typeof value === "object";
|
|
@@ -55,21 +56,6 @@ function parseConceptRow(value) {
|
|
|
55
56
|
gitRemote: nullableText(value["gitRemote"]),
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
|
-
function parseRelationRow(value) {
|
|
59
|
-
if (!isRecord(value)) {
|
|
60
|
-
throw new Error("invalid relation row");
|
|
61
|
-
}
|
|
62
|
-
return {
|
|
63
|
-
id: text(value["id"]),
|
|
64
|
-
projectId: text(value["projectId"]),
|
|
65
|
-
sourceId: text(value["sourceId"]),
|
|
66
|
-
targetId: text(value["targetId"]),
|
|
67
|
-
relation: text(value["relation"]),
|
|
68
|
-
weight: numberValue(value["weight"]),
|
|
69
|
-
repoRoot: text(value["repoRoot"]),
|
|
70
|
-
gitRemote: nullableText(value["gitRemote"]),
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
59
|
function matchesQuery(row, query) {
|
|
74
60
|
const haystack = `${row.label}\n${row.description ?? ""}\n${row.aliases.join("\n")}`.toLowerCase();
|
|
75
61
|
return haystack.includes(query);
|
|
@@ -88,6 +74,8 @@ function toNode(row, selectedId) {
|
|
|
88
74
|
scoreLabel: `${score} ${retentionClass}`,
|
|
89
75
|
refCount: Math.round(row.refCount),
|
|
90
76
|
projectSpread: Math.round(row.projectSpread),
|
|
77
|
+
firstSeen: row.firstSeen,
|
|
78
|
+
lastSeen: row.lastSeen,
|
|
91
79
|
project: projectFrom(row),
|
|
92
80
|
selected: selectedId === row.id,
|
|
93
81
|
};
|
|
@@ -111,19 +99,6 @@ function toDetail(row) {
|
|
|
111
99
|
project: projectFrom(row),
|
|
112
100
|
};
|
|
113
101
|
}
|
|
114
|
-
function toEdge(row) {
|
|
115
|
-
const weight = Number(row.weight.toFixed(2));
|
|
116
|
-
const relation = redactSecrets(row.relation);
|
|
117
|
-
return {
|
|
118
|
-
id: row.id,
|
|
119
|
-
sourceId: row.sourceId,
|
|
120
|
-
targetId: row.targetId,
|
|
121
|
-
relation,
|
|
122
|
-
label: `${relation} ${weight.toFixed(2)}`,
|
|
123
|
-
weight,
|
|
124
|
-
project: projectFrom(row),
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
102
|
export function projectOntologyGraph(options) {
|
|
128
103
|
const db = openMemoryDb(options.dbPath);
|
|
129
104
|
try {
|
|
@@ -148,21 +123,9 @@ export function projectOntologyGraph(options) {
|
|
|
148
123
|
const selectedId = options.selectedId && conceptIds.has(options.selectedId) ? options.selectedId : (conceptRows[0]?.id ?? null);
|
|
149
124
|
const nodes = conceptRows.map((row) => toNode(row, selectedId));
|
|
150
125
|
const selectedRow = selectedId === null ? undefined : conceptRows.find((row) => row.id === selectedId);
|
|
151
|
-
const relationRows = db
|
|
152
|
-
.prepare(`
|
|
153
|
-
SELECT r.id, r.project_id AS projectId, r.source_id AS sourceId, r.target_id AS targetId,
|
|
154
|
-
r.relation, COALESCE(r.weight, 1) AS weight, p.repo_root AS repoRoot, p.git_remote AS gitRemote
|
|
155
|
-
FROM relations r
|
|
156
|
-
JOIN projects p ON p.id = r.project_id
|
|
157
|
-
WHERE r.source_type = 'concept' AND r.target_type = 'concept' AND r.valid_to IS NULL
|
|
158
|
-
ORDER BY lower(r.relation) ASC, r.id ASC
|
|
159
|
-
`)
|
|
160
|
-
.all()
|
|
161
|
-
.map(parseRelationRow)
|
|
162
|
-
.filter((row) => conceptIds.has(row.sourceId) && conceptIds.has(row.targetId));
|
|
163
126
|
return {
|
|
164
127
|
nodes,
|
|
165
|
-
edges:
|
|
128
|
+
edges: readGraphEdges(db, conceptIds),
|
|
166
129
|
detail: selectedRow === undefined ? null : toDetail(selectedRow),
|
|
167
130
|
message: nodes.length === 0 ? "No ontology graph data is available yet." : null,
|
|
168
131
|
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { redactSecrets, sanitizeGitRemote } from "./privacy.js";
|
|
2
|
+
export function readGraphEdges(db, conceptIds) {
|
|
3
|
+
const relationRows = db
|
|
4
|
+
.prepare(`
|
|
5
|
+
SELECT r.id, r.project_id AS projectId, r.source_id AS sourceId, r.target_id AS targetId,
|
|
6
|
+
r.relation, COALESCE(r.weight, 1) AS weight, p.repo_root AS repoRoot, p.git_remote AS gitRemote
|
|
7
|
+
FROM relations r
|
|
8
|
+
JOIN projects p ON p.id = r.project_id
|
|
9
|
+
WHERE r.source_type = 'concept' AND r.target_type = 'concept' AND r.valid_to IS NULL
|
|
10
|
+
ORDER BY lower(r.relation) ASC, r.id ASC
|
|
11
|
+
`)
|
|
12
|
+
.all()
|
|
13
|
+
.map(parseRelationRow)
|
|
14
|
+
.filter((row) => conceptIds.has(row.sourceId) && conceptIds.has(row.targetId));
|
|
15
|
+
const edgeRows = relationRows.length === 0 ? readCoOccurrenceRows(db, conceptIds) : relationRows;
|
|
16
|
+
return edgeRows.map(toEdge);
|
|
17
|
+
}
|
|
18
|
+
function readCoOccurrenceRows(db, conceptIds) {
|
|
19
|
+
return db
|
|
20
|
+
.prepare(`
|
|
21
|
+
SELECT 'co:' || a.target_id || ':' || b.target_id AS id,
|
|
22
|
+
a.project_id AS projectId, a.target_id AS sourceId, b.target_id AS targetId,
|
|
23
|
+
'co_occurs' AS relation, COUNT(*) AS weight, p.repo_root AS repoRoot, p.git_remote AS gitRemote
|
|
24
|
+
FROM memory_references a
|
|
25
|
+
JOIN memory_references b
|
|
26
|
+
ON b.project_id = a.project_id
|
|
27
|
+
AND b.source_type = a.source_type
|
|
28
|
+
AND b.source_id = a.source_id
|
|
29
|
+
AND b.target_type = 'concept'
|
|
30
|
+
AND a.target_id < b.target_id
|
|
31
|
+
JOIN projects p ON p.id = a.project_id
|
|
32
|
+
WHERE a.source_type = 'event' AND a.target_type = 'concept'
|
|
33
|
+
GROUP BY a.project_id, a.target_id, b.target_id
|
|
34
|
+
ORDER BY COUNT(*) DESC, a.target_id ASC, b.target_id ASC
|
|
35
|
+
LIMIT 160
|
|
36
|
+
`)
|
|
37
|
+
.all()
|
|
38
|
+
.map(parseRelationRow)
|
|
39
|
+
.filter((row) => conceptIds.has(row.sourceId) && conceptIds.has(row.targetId));
|
|
40
|
+
}
|
|
41
|
+
function parseRelationRow(value) {
|
|
42
|
+
if (!isRecord(value))
|
|
43
|
+
throw new Error("invalid relation row");
|
|
44
|
+
return {
|
|
45
|
+
id: text(value["id"]),
|
|
46
|
+
projectId: text(value["projectId"]),
|
|
47
|
+
sourceId: text(value["sourceId"]),
|
|
48
|
+
targetId: text(value["targetId"]),
|
|
49
|
+
relation: text(value["relation"]),
|
|
50
|
+
weight: numberValue(value["weight"]),
|
|
51
|
+
repoRoot: text(value["repoRoot"]),
|
|
52
|
+
gitRemote: nullableText(value["gitRemote"]),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function toEdge(row) {
|
|
56
|
+
const weight = Number(row.weight.toFixed(2));
|
|
57
|
+
const relation = redactSecrets(row.relation);
|
|
58
|
+
return {
|
|
59
|
+
id: row.id,
|
|
60
|
+
sourceId: row.sourceId,
|
|
61
|
+
targetId: row.targetId,
|
|
62
|
+
relation,
|
|
63
|
+
label: `${relation} ${weight.toFixed(2)}`,
|
|
64
|
+
weight,
|
|
65
|
+
project: projectFrom(row),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function projectFrom(row) {
|
|
69
|
+
return {
|
|
70
|
+
id: row.projectId,
|
|
71
|
+
repoRoot: redactSecrets(row.repoRoot),
|
|
72
|
+
gitRemote: sanitizeGitRemote(row.gitRemote),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function isRecord(value) {
|
|
76
|
+
return value !== null && typeof value === "object";
|
|
77
|
+
}
|
|
78
|
+
function text(value) {
|
|
79
|
+
return typeof value === "string" ? value : String(value ?? "");
|
|
80
|
+
}
|
|
81
|
+
function nullableText(value) {
|
|
82
|
+
return value === null || value === undefined ? null : text(value);
|
|
83
|
+
}
|
|
84
|
+
function numberValue(value) {
|
|
85
|
+
return typeof value === "number" && Number.isFinite(value) ? value : Number(value ?? 0);
|
|
86
|
+
}
|
|
@@ -118,14 +118,14 @@ During the session, hooks should write concise user-action summaries, task state
|
|
|
118
118
|
}
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
-
This package is the local MCP-to-SQLite router. It does not scrape host transcripts or centralize cloud state. Hosts and adapters must call the MCP tools at their own lifecycle points.
|
|
121
|
+
This package is the local MCP-to-SQLite router. It does not scrape host transcripts, store assistant output, or centralize cloud state. Hosts and adapters must call the MCP tools at their own lifecycle points.
|
|
122
122
|
|
|
123
123
|
Retrieval is opt-in or intent-gated:
|
|
124
124
|
|
|
125
125
|
- Use `memory_recent_events` only when the user explicitly asks for recent OMO Memory context.
|
|
126
126
|
- Use `memory_recall_events` when the current user input has a concrete query that can be matched to recorded summaries, decisions, or evidence.
|
|
127
127
|
- Do not automatically attach the last session to every user prompt.
|
|
128
|
-
-
|
|
128
|
+
- To preserve user intent across sessions, adapters may invoke the packaged `scripts/omo-memory-user-prompt.mjs` UserPromptSubmit helper with the hook payload on stdin. The helper records only the current user prompt as a redacted `user_prompt` event and ignores assistant output.
|
|
129
129
|
|
|
130
130
|
Use these tools:
|
|
131
131
|
|
|
@@ -150,6 +150,8 @@ Use these tools:
|
|
|
150
150
|
- `memory_ontology_supersede`
|
|
151
151
|
- `memory_ontology_recall`
|
|
152
152
|
|
|
153
|
+
CLI updates: normal CLI commands automatically launch a quiet background `npm install -g omo-memory@latest` at most once per day. MCP startup intentionally does not auto-update because stdout/stderr must remain reserved for the protocol. Hosts that need pinned installs should set `OMO_MEMORY_AUTO_UPDATE=0` in the CLI environment.
|
|
154
|
+
|
|
153
155
|
Global migration is copy/import only. Adapters may scan for existing project-local `.omo/memory/state.sqlite` databases and import them into a user-selected global SQLite file, but they must preserve source DBs and retain source provenance in the global store.
|
|
154
156
|
|
|
155
157
|
Global migration also materializes an aggregate OMO schema view inside the global SQLite file. This lets existing ontology extraction, retention scoring, recall, and OpenTUI graph code operate on integrated cross-project events while `global_*` tables retain source database provenance.
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omo-memory",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Host-neutral local SQLite memory and session ledger for OMO adapters, exposed through CLI and MCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"dist",
|
|
8
8
|
"docs",
|
|
9
|
+
"scripts/omo-memory-user-prompt.mjs",
|
|
9
10
|
"README.md"
|
|
10
11
|
],
|
|
11
12
|
"bin": {
|
|
@@ -32,9 +33,10 @@
|
|
|
32
33
|
"start": "node dist/cli.js",
|
|
33
34
|
"prepack": "npm run build",
|
|
34
35
|
"presmoke": "npm run build",
|
|
35
|
-
"smoke": "npm run smoke:cli && npm run smoke:mcp",
|
|
36
|
+
"smoke": "npm run smoke:cli && npm run smoke:mcp && npm run smoke:hook",
|
|
36
37
|
"smoke:cli": "node scripts/smoke-cli.mjs",
|
|
37
38
|
"smoke:mcp": "node scripts/smoke-mcp.mjs",
|
|
39
|
+
"smoke:hook": "node scripts/verify-user-prompt-hook.mjs",
|
|
38
40
|
"mcp": "node dist/cli.js mcp",
|
|
39
41
|
"issue:epic": "node scripts/create-epic-issue.mjs"
|
|
40
42
|
},
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const MAX_SUMMARY_CHARS = 4000;
|
|
6
|
+
|
|
7
|
+
const host = process.env["OMO_MEMORY_HOST"] ?? "unknown";
|
|
8
|
+
const adapter = process.env["OMO_MEMORY_ADAPTER"] ?? "unknown";
|
|
9
|
+
|
|
10
|
+
const input = readStdin();
|
|
11
|
+
const payload = parseJson(input);
|
|
12
|
+
const prompt = extractPrompt(payload);
|
|
13
|
+
|
|
14
|
+
if (prompt === null) process.exit(0);
|
|
15
|
+
|
|
16
|
+
const summary = truncate(prompt.replace(/\s+/g, " ").trim(), MAX_SUMMARY_CHARS);
|
|
17
|
+
if (summary.length === 0) process.exit(0);
|
|
18
|
+
|
|
19
|
+
const hookSessionId = readString(payload, "sessionId") ?? readString(payload, "session_id");
|
|
20
|
+
const workspaceRoot = readString(payload, "workspaceRoot") ?? readString(payload, "cwd");
|
|
21
|
+
const metadata = {
|
|
22
|
+
source: "hook",
|
|
23
|
+
hookEventName: readString(payload, "hookEventName") ?? process.env["GROK_HOOK_EVENT"] ?? process.env["CODEX_HOOK_EVENT"] ?? "UserPromptSubmit",
|
|
24
|
+
host,
|
|
25
|
+
adapter,
|
|
26
|
+
...(hookSessionId === null ? {} : { hookSessionId }),
|
|
27
|
+
...(workspaceRoot === null ? {} : { workspaceRoot }),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const args = ["event", "record", "--type", "user_prompt", "--summary", summary, "--payload-json", JSON.stringify(metadata)];
|
|
31
|
+
|
|
32
|
+
const result = runOmoMemory(args) ?? runNpx(args);
|
|
33
|
+
if (result === undefined || result.status !== 0) process.exit(0);
|
|
34
|
+
|
|
35
|
+
function readStdin() {
|
|
36
|
+
try {
|
|
37
|
+
return readFileSync(0, "utf8");
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error instanceof Error) process.exit(0);
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseJson(raw) {
|
|
45
|
+
if (raw.trim().length === 0) return {};
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(raw);
|
|
48
|
+
return isRecord(parsed) ? parsed : {};
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error instanceof SyntaxError) return {};
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractPrompt(value) {
|
|
56
|
+
if (!isRecord(value)) return null;
|
|
57
|
+
for (const key of ["prompt", "userPrompt", "user_prompt", "message", "text", "input"]) {
|
|
58
|
+
const direct = readString(value, key);
|
|
59
|
+
if (direct !== null) return direct;
|
|
60
|
+
}
|
|
61
|
+
const nestedPrompt =
|
|
62
|
+
readNestedString(value, ["toolInput", "prompt"]) ?? readNestedString(value, ["payload", "prompt"]) ?? readNestedString(value, ["data", "prompt"]);
|
|
63
|
+
if (nestedPrompt !== null) return nestedPrompt;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readNestedString(value, path) {
|
|
68
|
+
let cursor = value;
|
|
69
|
+
for (const key of path) {
|
|
70
|
+
if (!isRecord(cursor)) return null;
|
|
71
|
+
cursor = cursor[key];
|
|
72
|
+
}
|
|
73
|
+
return typeof cursor === "string" ? cursor : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readString(value, key) {
|
|
77
|
+
const item = value[key];
|
|
78
|
+
return typeof item === "string" ? item : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function truncate(value, maxLength) {
|
|
82
|
+
if (value.length <= maxLength) return value;
|
|
83
|
+
return `${value.slice(0, maxLength - 15)} [TRUNCATED]`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function runOmoMemory(args) {
|
|
87
|
+
const command = process.env["OMO_MEMORY_CLI"] ?? "omo-memory";
|
|
88
|
+
return run(command, args);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function runNpx(args) {
|
|
92
|
+
return run("npx", ["-y", "omo-memory", ...args]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function run(command, args) {
|
|
96
|
+
const result = spawnSync(command, args, {
|
|
97
|
+
encoding: "utf8",
|
|
98
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
99
|
+
timeout: 5000,
|
|
100
|
+
});
|
|
101
|
+
if (result.error?.code === "ENOENT") return undefined;
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isRecord(value) {
|
|
106
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
107
|
+
}
|