omnimem 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -0
- package/bin/omnimem +5 -0
- package/db/schema.sql +78 -0
- package/docs/quickstart-10min.md +35 -0
- package/omnimem/__init__.py +2 -0
- package/omnimem/adapters.py +123 -0
- package/omnimem/cli.py +579 -0
- package/omnimem/core.py +826 -0
- package/omnimem/webui.py +602 -0
- package/package.json +37 -0
- package/scripts/attach_project.sh +25 -0
- package/scripts/bootstrap.sh +77 -0
- package/scripts/detach_project.sh +21 -0
- package/scripts/install.sh +94 -0
- package/scripts/uninstall.sh +13 -0
- package/scripts/verify_phase_a.sh +52 -0
- package/scripts/verify_phase_b.sh +21 -0
- package/scripts/verify_phase_c.sh +19 -0
- package/scripts/verify_phase_d.sh +28 -0
- package/spec/changelog.md +14 -0
- package/spec/memory-envelope.schema.json +111 -0
- package/spec/memory-event.schema.json +31 -0
- package/spec/protocol.md +77 -0
- package/templates/project-minimal/.omnimem-ignore +8 -0
- package/templates/project-minimal/.omnimem-session.md +13 -0
- package/templates/project-minimal/.omnimem.json +10 -0
package/omnimem/webui.py
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import parse_qs, urlparse
|
|
11
|
+
|
|
12
|
+
from .core import ensure_storage, find_memories, resolve_paths, save_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
HTML_PAGE = """<!doctype html>
|
|
16
|
+
<html lang=\"en\">
|
|
17
|
+
<head>
|
|
18
|
+
<meta charset=\"utf-8\" />
|
|
19
|
+
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
|
|
20
|
+
<title>OmniMem WebUI</title>
|
|
21
|
+
<style>
|
|
22
|
+
:root { --bg:#f3f4f6; --card:#ffffff; --ink:#0f172a; --muted:#475569; --line:#e2e8f0; --accent:#0f766e; --tab:#e6fffb; }
|
|
23
|
+
body { margin:0; font-family: 'IBM Plex Sans', 'Helvetica Neue', sans-serif; background: radial-gradient(circle at top right,#ecfeff,#f8fafc 50%,#f3f4f6); color:var(--ink); }
|
|
24
|
+
.wrap { max-width: 1080px; margin: 22px auto; padding: 0 16px 36px; }
|
|
25
|
+
.hero { padding: 18px; border:1px solid var(--line); background:var(--card); border-radius: 14px; }
|
|
26
|
+
h1 { margin: 0 0 6px; font-size: 28px; letter-spacing: .2px; }
|
|
27
|
+
.small { font-size:12px; color:var(--muted); }
|
|
28
|
+
.hero-head { display:flex; justify-content:space-between; gap:12px; align-items:center; flex-wrap:wrap; }
|
|
29
|
+
.lang { border:1px solid var(--line); border-radius:10px; padding:6px 8px; background:#fff; }
|
|
30
|
+
.tabs { display:flex; gap:8px; margin-top:14px; flex-wrap:wrap; }
|
|
31
|
+
.tab-btn { border:1px solid var(--line); background:#fff; color:#0f172a; border-radius: 10px; padding:8px 12px; cursor:pointer; }
|
|
32
|
+
.tab-btn.active { background:var(--tab); border-color:#99f6e4; color:#115e59; }
|
|
33
|
+
.panel { display:none; margin-top:14px; }
|
|
34
|
+
.panel.active { display:block; }
|
|
35
|
+
.grid { display:grid; grid-template-columns: 1fr 1fr; gap:16px; }
|
|
36
|
+
.card { border:1px solid var(--line); background:var(--card); border-radius: 14px; padding:16px; box-shadow: 0 4px 16px rgba(15,23,42,.03); }
|
|
37
|
+
.wide { grid-column: 1 / -1; }
|
|
38
|
+
label { display:block; font-size:12px; margin-top:8px; color:var(--muted); }
|
|
39
|
+
input { width:100%; box-sizing:border-box; border:1px solid #cbd5e1; background:#fff; border-radius:10px; padding:9px 10px; margin-top:4px; }
|
|
40
|
+
button { border:0; background:var(--accent); color:#fff; border-radius:10px; padding:10px 14px; margin-top:10px; cursor:pointer; }
|
|
41
|
+
.row-btn { display:flex; gap:10px; flex-wrap:wrap; }
|
|
42
|
+
table { width:100%; border-collapse: collapse; font-size: 14px; }
|
|
43
|
+
th, td { padding:8px; border-bottom:1px solid var(--line); text-align:left; }
|
|
44
|
+
.ok { color:#047857; }
|
|
45
|
+
.err { color:#b91c1c; }
|
|
46
|
+
.warn { color:#92400e; }
|
|
47
|
+
@media (max-width: 920px) { .grid { grid-template-columns:1fr; } }
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<div class=\"wrap\">
|
|
52
|
+
<div class=\"hero\">
|
|
53
|
+
<div class=\"hero-head\">
|
|
54
|
+
<div>
|
|
55
|
+
<h1 data-i18n=\"title\">OmniMem WebUI</h1>
|
|
56
|
+
<div class=\"small\" data-i18n=\"subtitle\">Simple mode: Status & Actions / Configuration / Memory</div>
|
|
57
|
+
</div>
|
|
58
|
+
<div>
|
|
59
|
+
<label class=\"small\" data-i18n=\"language\">Language</label>
|
|
60
|
+
<select id=\"langSelect\" class=\"lang\">
|
|
61
|
+
<option value=\"en\">English</option>
|
|
62
|
+
<option value=\"zh\">中文</option>
|
|
63
|
+
<option value=\"ja\">日本語</option>
|
|
64
|
+
<option value=\"de\">Deutsch</option>
|
|
65
|
+
<option value=\"fr\">Français</option>
|
|
66
|
+
<option value=\"ru\">Русский</option>
|
|
67
|
+
<option value=\"it\">Italiano</option>
|
|
68
|
+
<option value=\"ko\">한국어</option>
|
|
69
|
+
</select>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
<div id=\"status\" class=\"small\"></div>
|
|
73
|
+
<div id=\"daemonState\" class=\"small\"></div>
|
|
74
|
+
<div class=\"tabs\">
|
|
75
|
+
<button class=\"tab-btn active\" data-tab=\"statusTab\" data-i18n=\"tab_status\">Status & Actions</button>
|
|
76
|
+
<button class=\"tab-btn\" data-tab=\"configTab\" data-i18n=\"tab_config\">Configuration</button>
|
|
77
|
+
<button class=\"tab-btn\" data-tab=\"memoryTab\" data-i18n=\"tab_memory\">Memory</button>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div id=\"statusTab\" class=\"panel active\">
|
|
82
|
+
<div class=\"grid\">
|
|
83
|
+
<div class=\"card\">
|
|
84
|
+
<h3 data-i18n=\"system_status\">System Status</h3>
|
|
85
|
+
<div id=\"initState\" class=\"small\"></div>
|
|
86
|
+
<div id=\"syncHint\" class=\"small\" style=\"margin-top:8px\"></div>
|
|
87
|
+
</div>
|
|
88
|
+
<div class=\"card\">
|
|
89
|
+
<h3 data-i18n=\"actions\">Actions</h3>
|
|
90
|
+
<div class=\"row-btn\">
|
|
91
|
+
<button onclick=\"runSync('github-status')\" data-i18n=\"btn_status\">Check Sync Status</button>
|
|
92
|
+
<button onclick=\"runSync('github-bootstrap')\" data-i18n=\"btn_bootstrap\">Bootstrap Device Sync</button>
|
|
93
|
+
<button onclick=\"runSync('github-push')\" data-i18n=\"btn_push\">Push</button>
|
|
94
|
+
<button onclick=\"runSync('github-pull')\" data-i18n=\"btn_pull\">Pull</button>
|
|
95
|
+
</div>
|
|
96
|
+
<div class=\"row-btn\">
|
|
97
|
+
<button onclick=\"toggleDaemon(true)\" data-i18n=\"btn_daemon_on\">Enable Daemon</button>
|
|
98
|
+
<button onclick=\"toggleDaemon(false)\" data-i18n=\"btn_daemon_off\">Disable Daemon</button>
|
|
99
|
+
</div>
|
|
100
|
+
<pre id=\"syncOut\" class=\"small\"></pre>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div id=\"configTab\" class=\"panel\">
|
|
106
|
+
<div class=\"grid\">
|
|
107
|
+
<div class=\"card wide\">
|
|
108
|
+
<h3 data-i18n=\"config_title\">Configuration</h3>
|
|
109
|
+
<form id=\"cfgForm\">
|
|
110
|
+
<label data-i18n=\"cfg_path\">Config Path<input name=\"config_path\" readonly /></label>
|
|
111
|
+
<label data-i18n=\"cfg_home\">Home<input name=\"home\" /></label>
|
|
112
|
+
<label data-i18n=\"cfg_markdown\">Markdown Path<input name=\"markdown\" /></label>
|
|
113
|
+
<label data-i18n=\"cfg_jsonl\">JSONL Path<input name=\"jsonl\" /></label>
|
|
114
|
+
<label data-i18n=\"cfg_sqlite\">SQLite Path<input name=\"sqlite\" /></label>
|
|
115
|
+
<label data-i18n=\"cfg_remote_name\">Git Remote Name<input name=\"remote_name\" /></label>
|
|
116
|
+
<label data-i18n=\"cfg_remote_url\">Git Remote URL<input name=\"remote_url\" placeholder=\"git@github.com:user/repo.git\" /></label>
|
|
117
|
+
<label data-i18n=\"cfg_branch\">Git Branch<input name=\"branch\" /></label>
|
|
118
|
+
<button type=\"submit\" data-i18n=\"btn_save\">Save Configuration</button>
|
|
119
|
+
</form>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div id=\"memoryTab\" class=\"panel\">
|
|
125
|
+
<div class=\"grid\">
|
|
126
|
+
<div class=\"card wide\">
|
|
127
|
+
<h3 data-i18n=\"mem_recent\">Recent Memories</h3>
|
|
128
|
+
<div class=\"small\" data-i18n=\"mem_hint\">Click an ID to open full content</div>
|
|
129
|
+
<table>
|
|
130
|
+
<thead>
|
|
131
|
+
<tr>
|
|
132
|
+
<th data-i18n=\"th_id\">ID</th>
|
|
133
|
+
<th data-i18n=\"th_layer\">Layer</th>
|
|
134
|
+
<th data-i18n=\"th_kind\">Kind</th>
|
|
135
|
+
<th data-i18n=\"th_summary\">Summary</th>
|
|
136
|
+
<th data-i18n=\"th_updated\">Updated At</th>
|
|
137
|
+
</tr>
|
|
138
|
+
</thead>
|
|
139
|
+
<tbody id=\"memBody\"></tbody>
|
|
140
|
+
</table>
|
|
141
|
+
</div>
|
|
142
|
+
<div class=\"card wide\">
|
|
143
|
+
<h3 data-i18n=\"mem_content\">Memory Content</h3>
|
|
144
|
+
<pre id=\"memView\" style=\"white-space:pre-wrap\"></pre>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<script>
|
|
151
|
+
const I18N = {
|
|
152
|
+
en: {
|
|
153
|
+
title: 'OmniMem WebUI', subtitle: 'Simple mode: Status & Actions / Configuration / Memory', language: 'Language',
|
|
154
|
+
tab_status: 'Status & Actions', tab_config: 'Configuration', tab_memory: 'Memory',
|
|
155
|
+
system_status: 'System Status', actions: 'Actions',
|
|
156
|
+
btn_status: 'Check Sync Status', btn_bootstrap: 'Bootstrap Device Sync', btn_push: 'Push', btn_pull: 'Pull',
|
|
157
|
+
btn_daemon_on: 'Enable Daemon', btn_daemon_off: 'Disable Daemon',
|
|
158
|
+
config_title: 'Configuration', cfg_path: 'Config Path', cfg_home: 'Home', cfg_markdown: 'Markdown Path', cfg_jsonl: 'JSONL Path', cfg_sqlite: 'SQLite Path', cfg_remote_name: 'Git Remote Name', cfg_remote_url: 'Git Remote URL', cfg_branch: 'Git Branch', btn_save: 'Save Configuration',
|
|
159
|
+
mem_recent: 'Recent Memories', mem_hint: 'Click an ID to open full content', mem_content: 'Memory Content',
|
|
160
|
+
th_id: 'ID', th_layer: 'Layer', th_kind: 'Kind', th_summary: 'Summary', th_updated: 'Updated At',
|
|
161
|
+
cfg_saved: 'Configuration saved', cfg_failed: 'Save failed',
|
|
162
|
+
init_ok: 'Config state: initialized', init_hint_ok: 'Daemon runs quasi-realtime sync in background (can be disabled).',
|
|
163
|
+
init_missing: 'Config state: not initialized (save configuration first)', init_hint_missing: 'Daemon is disabled until configuration is initialized.',
|
|
164
|
+
daemon_state: (d) => `Daemon: ${d.running ? 'running' : 'stopped'}, enabled=${d.enabled}, initialized=${d.initialized}`
|
|
165
|
+
},
|
|
166
|
+
zh: {
|
|
167
|
+
title: 'OmniMem 网页控制台', subtitle: '简洁模式:状态与动作 / 配置 / 记忆', language: '语言',
|
|
168
|
+
tab_status: '状态与动作', tab_config: '配置', tab_memory: '记忆',
|
|
169
|
+
system_status: '系统状态', actions: '动作',
|
|
170
|
+
btn_status: '检查同步状态', btn_bootstrap: '首次设备对齐', btn_push: '推送', btn_pull: '拉取',
|
|
171
|
+
btn_daemon_on: '开启守护', btn_daemon_off: '关闭守护',
|
|
172
|
+
config_title: '配置', cfg_path: '配置路径', cfg_home: '主目录', cfg_markdown: 'Markdown 路径', cfg_jsonl: 'JSONL 路径', cfg_sqlite: 'SQLite 路径', cfg_remote_name: 'Git 远端名', cfg_remote_url: 'Git 远端 URL', cfg_branch: 'Git 分支', btn_save: '保存配置',
|
|
173
|
+
mem_recent: '最近记忆', mem_hint: '点击 ID 查看正文', mem_content: '记忆正文',
|
|
174
|
+
th_id: 'ID', th_layer: '层级', th_kind: '类型', th_summary: '摘要', th_updated: '更新时间',
|
|
175
|
+
cfg_saved: '配置已保存', cfg_failed: '保存失败',
|
|
176
|
+
init_ok: '配置状态:已初始化', init_hint_ok: '后台守护进程会自动准实时同步(可关闭)。',
|
|
177
|
+
init_missing: '配置状态:未初始化(请先保存配置)', init_hint_missing: '未初始化前不会启动守护进程。',
|
|
178
|
+
daemon_state: (d) => `守护进程:${d.running ? '运行中' : '已停止'},启用=${d.enabled},初始化=${d.initialized}`
|
|
179
|
+
},
|
|
180
|
+
ja: {
|
|
181
|
+
title: 'OmniMem WebUI', subtitle: 'シンプルモード:状態と操作 / 設定 / メモリ', language: '言語',
|
|
182
|
+
tab_status: '状態と操作', tab_config: '設定', tab_memory: 'メモリ',
|
|
183
|
+
system_status: 'システム状態', actions: '操作',
|
|
184
|
+
btn_status: '同期状態を確認', btn_bootstrap: '初回デバイス同期', btn_push: 'Push', btn_pull: 'Pull',
|
|
185
|
+
btn_daemon_on: 'デーモン有効', btn_daemon_off: 'デーモン無効',
|
|
186
|
+
config_title: '設定', cfg_path: '設定パス', cfg_home: 'ホーム', cfg_markdown: 'Markdown パス', cfg_jsonl: 'JSONL パス', cfg_sqlite: 'SQLite パス', cfg_remote_name: 'Git リモート名', cfg_remote_url: 'Git リモート URL', cfg_branch: 'Git ブランチ', btn_save: '設定を保存',
|
|
187
|
+
mem_recent: '最近のメモリ', mem_hint: 'ID をクリックして本文を表示', mem_content: 'メモリ内容',
|
|
188
|
+
th_id: 'ID', th_layer: 'レイヤー', th_kind: '種類', th_summary: '要約', th_updated: '更新日時',
|
|
189
|
+
cfg_saved: '設定を保存しました', cfg_failed: '保存に失敗しました',
|
|
190
|
+
init_ok: '設定状態:初期化済み', init_hint_ok: 'デーモンがバックグラウンドで準リアルタイム同期します。',
|
|
191
|
+
init_missing: '設定状態:未初期化(先に保存してください)', init_hint_missing: '初期化されるまでデーモンは無効です。',
|
|
192
|
+
daemon_state: (d) => `Daemon: ${d.running ? 'running' : 'stopped'}, enabled=${d.enabled}, initialized=${d.initialized}`
|
|
193
|
+
},
|
|
194
|
+
de: {
|
|
195
|
+
title: 'OmniMem WebUI', subtitle: 'Einfachmodus: Status & Aktionen / Konfiguration / Speicher', language: 'Sprache',
|
|
196
|
+
tab_status: 'Status & Aktionen', tab_config: 'Konfiguration', tab_memory: 'Speicher',
|
|
197
|
+
system_status: 'Systemstatus', actions: 'Aktionen',
|
|
198
|
+
btn_status: 'Sync-Status prüfen', btn_bootstrap: 'Erstsynchronisierung', btn_push: 'Push', btn_pull: 'Pull',
|
|
199
|
+
btn_daemon_on: 'Daemon aktivieren', btn_daemon_off: 'Daemon deaktivieren',
|
|
200
|
+
config_title: 'Konfiguration', cfg_path: 'Konfigurationspfad', cfg_home: 'Home', cfg_markdown: 'Markdown-Pfad', cfg_jsonl: 'JSONL-Pfad', cfg_sqlite: 'SQLite-Pfad', cfg_remote_name: 'Git Remote-Name', cfg_remote_url: 'Git Remote-URL', cfg_branch: 'Git-Branch', btn_save: 'Konfiguration speichern',
|
|
201
|
+
mem_recent: 'Aktuelle Speicher', mem_hint: 'ID anklicken, um Inhalt zu öffnen', mem_content: 'Speicherinhalt',
|
|
202
|
+
th_id: 'ID', th_layer: 'Ebene', th_kind: 'Typ', th_summary: 'Zusammenfassung', th_updated: 'Aktualisiert',
|
|
203
|
+
cfg_saved: 'Konfiguration gespeichert', cfg_failed: 'Speichern fehlgeschlagen',
|
|
204
|
+
init_ok: 'Konfigurationsstatus: initialisiert', init_hint_ok: 'Daemon synchronisiert quasi in Echtzeit im Hintergrund.',
|
|
205
|
+
init_missing: 'Konfigurationsstatus: nicht initialisiert', init_hint_missing: 'Daemon ist deaktiviert, bis gespeichert wird.',
|
|
206
|
+
daemon_state: (d) => `Daemon: ${d.running ? 'running' : 'stopped'}, enabled=${d.enabled}, initialized=${d.initialized}`
|
|
207
|
+
},
|
|
208
|
+
fr: {
|
|
209
|
+
title: 'OmniMem WebUI', subtitle: 'Mode simple : État et actions / Configuration / Mémoire', language: 'Langue',
|
|
210
|
+
tab_status: 'État et actions', tab_config: 'Configuration', tab_memory: 'Mémoire',
|
|
211
|
+
system_status: 'État du système', actions: 'Actions',
|
|
212
|
+
btn_status: 'Vérifier la sync', btn_bootstrap: 'Sync initiale appareil', btn_push: 'Push', btn_pull: 'Pull',
|
|
213
|
+
btn_daemon_on: 'Activer le daemon', btn_daemon_off: 'Désactiver le daemon',
|
|
214
|
+
config_title: 'Configuration', cfg_path: 'Chemin config', cfg_home: 'Home', cfg_markdown: 'Chemin Markdown', cfg_jsonl: 'Chemin JSONL', cfg_sqlite: 'Chemin SQLite', cfg_remote_name: 'Nom remote Git', cfg_remote_url: 'URL remote Git', cfg_branch: 'Branche Git', btn_save: 'Enregistrer',
|
|
215
|
+
mem_recent: 'Mémoires récentes', mem_hint: 'Cliquez un ID pour ouvrir le contenu', mem_content: 'Contenu mémoire',
|
|
216
|
+
th_id: 'ID', th_layer: 'Couche', th_kind: 'Type', th_summary: 'Résumé', th_updated: 'Mise à jour',
|
|
217
|
+
cfg_saved: 'Configuration enregistrée', cfg_failed: 'Échec de l\'enregistrement',
|
|
218
|
+
init_ok: 'État config : initialisée', init_hint_ok: 'Le daemon synchronise en quasi temps réel.',
|
|
219
|
+
init_missing: 'État config : non initialisée', init_hint_missing: 'Le daemon reste désactivé avant initialisation.',
|
|
220
|
+
daemon_state: (d) => `Daemon: ${d.running ? 'running' : 'stopped'}, enabled=${d.enabled}, initialized=${d.initialized}`
|
|
221
|
+
},
|
|
222
|
+
ru: {
|
|
223
|
+
title: 'OmniMem WebUI', subtitle: 'Простой режим: статус и действия / конфигурация / память', language: 'Язык',
|
|
224
|
+
tab_status: 'Статус и действия', tab_config: 'Конфигурация', tab_memory: 'Память',
|
|
225
|
+
system_status: 'Состояние системы', actions: 'Действия',
|
|
226
|
+
btn_status: 'Проверить синхронизацию', btn_bootstrap: 'Первичная синхронизация', btn_push: 'Push', btn_pull: 'Pull',
|
|
227
|
+
btn_daemon_on: 'Включить daemon', btn_daemon_off: 'Выключить daemon',
|
|
228
|
+
config_title: 'Конфигурация', cfg_path: 'Путь к конфигу', cfg_home: 'Home', cfg_markdown: 'Путь Markdown', cfg_jsonl: 'Путь JSONL', cfg_sqlite: 'Путь SQLite', cfg_remote_name: 'Имя remote Git', cfg_remote_url: 'URL remote Git', cfg_branch: 'Ветка Git', btn_save: 'Сохранить',
|
|
229
|
+
mem_recent: 'Последняя память', mem_hint: 'Нажмите ID, чтобы открыть содержимое', mem_content: 'Содержимое памяти',
|
|
230
|
+
th_id: 'ID', th_layer: 'Слой', th_kind: 'Тип', th_summary: 'Сводка', th_updated: 'Обновлено',
|
|
231
|
+
cfg_saved: 'Конфигурация сохранена', cfg_failed: 'Ошибка сохранения',
|
|
232
|
+
init_ok: 'Состояние конфига: инициализировано', init_hint_ok: 'Daemon выполняет квази-реальную синхронизацию.',
|
|
233
|
+
init_missing: 'Состояние конфига: не инициализировано', init_hint_missing: 'Daemon отключён до инициализации.',
|
|
234
|
+
daemon_state: (d) => `Daemon: ${d.running ? 'running' : 'stopped'}, enabled=${d.enabled}, initialized=${d.initialized}`
|
|
235
|
+
},
|
|
236
|
+
it: {
|
|
237
|
+
title: 'OmniMem WebUI', subtitle: 'Modalità semplice: stato e azioni / configurazione / memoria', language: 'Lingua',
|
|
238
|
+
tab_status: 'Stato e azioni', tab_config: 'Configurazione', tab_memory: 'Memoria',
|
|
239
|
+
system_status: 'Stato sistema', actions: 'Azioni',
|
|
240
|
+
btn_status: 'Controlla sync', btn_bootstrap: 'Bootstrap sync dispositivo', btn_push: 'Push', btn_pull: 'Pull',
|
|
241
|
+
btn_daemon_on: 'Abilita daemon', btn_daemon_off: 'Disabilita daemon',
|
|
242
|
+
config_title: 'Configurazione', cfg_path: 'Percorso config', cfg_home: 'Home', cfg_markdown: 'Percorso Markdown', cfg_jsonl: 'Percorso JSONL', cfg_sqlite: 'Percorso SQLite', cfg_remote_name: 'Nome remote Git', cfg_remote_url: 'URL remote Git', cfg_branch: 'Branch Git', btn_save: 'Salva configurazione',
|
|
243
|
+
mem_recent: 'Memorie recenti', mem_hint: 'Clicca un ID per aprire il contenuto', mem_content: 'Contenuto memoria',
|
|
244
|
+
th_id: 'ID', th_layer: 'Livello', th_kind: 'Tipo', th_summary: 'Sommario', th_updated: 'Aggiornato',
|
|
245
|
+
cfg_saved: 'Configurazione salvata', cfg_failed: 'Salvataggio fallito',
|
|
246
|
+
init_ok: 'Stato config: inizializzata', init_hint_ok: 'Daemon sincronizza quasi in tempo reale.',
|
|
247
|
+
init_missing: 'Stato config: non inizializzata', init_hint_missing: 'Daemon disabilitato fino all\'inizializzazione.',
|
|
248
|
+
daemon_state: (d) => `Daemon: ${d.running ? 'running' : 'stopped'}, enabled=${d.enabled}, initialized=${d.initialized}`
|
|
249
|
+
},
|
|
250
|
+
ko: {
|
|
251
|
+
title: 'OmniMem WebUI', subtitle: '간단 모드: 상태/작업 · 설정 · 메모리', language: '언어',
|
|
252
|
+
tab_status: '상태/작업', tab_config: '설정', tab_memory: '메모리',
|
|
253
|
+
system_status: '시스템 상태', actions: '작업',
|
|
254
|
+
btn_status: '동기화 상태 확인', btn_bootstrap: '초기 장치 동기화', btn_push: 'Push', btn_pull: 'Pull',
|
|
255
|
+
btn_daemon_on: '데몬 켜기', btn_daemon_off: '데몬 끄기',
|
|
256
|
+
config_title: '설정', cfg_path: '설정 경로', cfg_home: '홈', cfg_markdown: 'Markdown 경로', cfg_jsonl: 'JSONL 경로', cfg_sqlite: 'SQLite 경로', cfg_remote_name: 'Git 원격 이름', cfg_remote_url: 'Git 원격 URL', cfg_branch: 'Git 브랜치', btn_save: '설정 저장',
|
|
257
|
+
mem_recent: '최근 메모리', mem_hint: 'ID를 클릭해 본문 열기', mem_content: '메모리 본문',
|
|
258
|
+
th_id: 'ID', th_layer: '레이어', th_kind: '유형', th_summary: '요약', th_updated: '업데이트 시각',
|
|
259
|
+
cfg_saved: '설정이 저장되었습니다', cfg_failed: '저장 실패',
|
|
260
|
+
init_ok: '설정 상태: 초기화됨', init_hint_ok: '데몬이 백그라운드에서 준실시간 동기화합니다.',
|
|
261
|
+
init_missing: '설정 상태: 미초기화', init_hint_missing: '초기화 전에는 데몬이 비활성화됩니다.',
|
|
262
|
+
daemon_state: (d) => `Daemon: ${d.running ? 'running' : 'stopped'}, enabled=${d.enabled}, initialized=${d.initialized}`
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
let currentLang = localStorage.getItem('omnimem.lang') || 'en';
|
|
267
|
+
let daemonCache = { running:false, enabled:false, initialized:false };
|
|
268
|
+
|
|
269
|
+
function t(key) {
|
|
270
|
+
const dict = I18N[currentLang] || I18N.en;
|
|
271
|
+
return dict[key] || I18N.en[key] || key;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function applyI18n() {
|
|
275
|
+
document.documentElement.lang = currentLang;
|
|
276
|
+
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
277
|
+
const key = el.getAttribute('data-i18n');
|
|
278
|
+
el.textContent = t(key);
|
|
279
|
+
});
|
|
280
|
+
document.getElementById('langSelect').value = currentLang;
|
|
281
|
+
renderDaemonState();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function jget(url) { const r = await fetch(url); return await r.json(); }
|
|
285
|
+
async function jpost(url, obj) { const r = await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(obj)}); return await r.json(); }
|
|
286
|
+
|
|
287
|
+
function renderInitState(initialized) {
|
|
288
|
+
const initEl = document.getElementById('initState');
|
|
289
|
+
const hintEl = document.getElementById('syncHint');
|
|
290
|
+
if (initialized) {
|
|
291
|
+
initEl.innerHTML = `<span class=\"ok\">${t('init_ok')}</span>`;
|
|
292
|
+
hintEl.textContent = t('init_hint_ok');
|
|
293
|
+
} else {
|
|
294
|
+
initEl.innerHTML = `<span class=\"warn\">${t('init_missing')}</span>`;
|
|
295
|
+
hintEl.textContent = t('init_hint_missing');
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function renderDaemonState() {
|
|
300
|
+
const dict = I18N[currentLang] || I18N.en;
|
|
301
|
+
const fn = dict.daemon_state || I18N.en.daemon_state;
|
|
302
|
+
document.getElementById('daemonState').textContent = fn(daemonCache);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function loadCfg() {
|
|
306
|
+
const d = await jget('/api/config');
|
|
307
|
+
const f = document.getElementById('cfgForm');
|
|
308
|
+
for (const k of ['config_path','home','markdown','jsonl','sqlite','remote_name','remote_url','branch']) {
|
|
309
|
+
f.elements[k].value = d[k] || '';
|
|
310
|
+
}
|
|
311
|
+
renderInitState(Boolean(d.initialized));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function loadMem() {
|
|
315
|
+
const d = await jget('/api/memories?limit=20');
|
|
316
|
+
const b = document.getElementById('memBody');
|
|
317
|
+
b.innerHTML = '';
|
|
318
|
+
(d.items || []).forEach(x => {
|
|
319
|
+
const tr = document.createElement('tr');
|
|
320
|
+
tr.innerHTML = `<td><a href=\"#\" data-id=\"${x.id}\">${x.id.slice(0,10)}...</a></td><td>${x.layer}</td><td>${x.kind}</td><td>${x.summary}</td><td>${x.updated_at}</td>`;
|
|
321
|
+
tr.querySelector('a').onclick = async (e) => {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
const m = await jget('/api/memory?id=' + encodeURIComponent(x.id));
|
|
324
|
+
document.getElementById('memView').textContent = m.body || m.error || '';
|
|
325
|
+
};
|
|
326
|
+
b.appendChild(tr);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
document.getElementById('cfgForm').onsubmit = async (e) => {
|
|
331
|
+
e.preventDefault();
|
|
332
|
+
const f = e.target;
|
|
333
|
+
const payload = {};
|
|
334
|
+
for (const k of ['home','markdown','jsonl','sqlite','remote_name','remote_url','branch']) payload[k] = f.elements[k].value;
|
|
335
|
+
const d = await jpost('/api/config', payload);
|
|
336
|
+
document.getElementById('status').innerHTML = d.ok ? `<span class=\"ok\">${t('cfg_saved')}</span>` : `<span class=\"err\">${t('cfg_failed')}</span>`;
|
|
337
|
+
await loadCfg();
|
|
338
|
+
await loadDaemon();
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
async function runSync(mode) {
|
|
342
|
+
const d = await jpost('/api/sync', {mode});
|
|
343
|
+
document.getElementById('syncOut').textContent = JSON.stringify(d, null, 2);
|
|
344
|
+
await loadMem();
|
|
345
|
+
await loadDaemon();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function loadDaemon() {
|
|
349
|
+
const d = await jget('/api/daemon');
|
|
350
|
+
daemonCache = d;
|
|
351
|
+
renderDaemonState();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function toggleDaemon(enabled) {
|
|
355
|
+
await jpost('/api/daemon/toggle', {enabled});
|
|
356
|
+
await loadDaemon();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function bindTabs() {
|
|
360
|
+
const btns = document.querySelectorAll('.tab-btn');
|
|
361
|
+
btns.forEach(btn => {
|
|
362
|
+
btn.onclick = () => {
|
|
363
|
+
btns.forEach(x => x.classList.remove('active'));
|
|
364
|
+
btn.classList.add('active');
|
|
365
|
+
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
|
366
|
+
document.getElementById(btn.dataset.tab).classList.add('active');
|
|
367
|
+
};
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
document.getElementById('langSelect').onchange = (e) => {
|
|
372
|
+
currentLang = e.target.value;
|
|
373
|
+
localStorage.setItem('omnimem.lang', currentLang);
|
|
374
|
+
applyI18n();
|
|
375
|
+
loadCfg();
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
bindTabs();
|
|
379
|
+
applyI18n();
|
|
380
|
+
loadCfg();
|
|
381
|
+
loadMem();
|
|
382
|
+
loadDaemon();
|
|
383
|
+
</script>
|
|
384
|
+
</body>
|
|
385
|
+
</html>
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _cfg_to_ui(cfg: dict[str, Any], cfg_path: Path) -> dict[str, Any]:
|
|
390
|
+
storage = cfg.get("storage", {})
|
|
391
|
+
gh = cfg.get("sync", {}).get("github", {})
|
|
392
|
+
return {
|
|
393
|
+
"ok": True,
|
|
394
|
+
"initialized": cfg_path.exists(),
|
|
395
|
+
"config_path": str(cfg_path),
|
|
396
|
+
"home": cfg.get("home", ""),
|
|
397
|
+
"markdown": storage.get("markdown", ""),
|
|
398
|
+
"jsonl": storage.get("jsonl", ""),
|
|
399
|
+
"sqlite": storage.get("sqlite", ""),
|
|
400
|
+
"remote_name": gh.get("remote_name", "origin"),
|
|
401
|
+
"remote_url": gh.get("remote_url", ""),
|
|
402
|
+
"branch": gh.get("branch", "main"),
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def run_webui(
|
|
407
|
+
*,
|
|
408
|
+
host: str,
|
|
409
|
+
port: int,
|
|
410
|
+
cfg: dict[str, Any],
|
|
411
|
+
cfg_path: Path,
|
|
412
|
+
schema_sql_path: Path,
|
|
413
|
+
sync_runner,
|
|
414
|
+
daemon_runner=None,
|
|
415
|
+
enable_daemon: bool = True,
|
|
416
|
+
daemon_scan_interval: int = 8,
|
|
417
|
+
daemon_pull_interval: int = 30,
|
|
418
|
+
) -> None:
|
|
419
|
+
paths = resolve_paths(cfg)
|
|
420
|
+
ensure_storage(paths, schema_sql_path)
|
|
421
|
+
daemon_state: dict[str, Any] = {
|
|
422
|
+
"initialized": cfg_path.exists(),
|
|
423
|
+
"enabled": bool(enable_daemon and cfg_path.exists()),
|
|
424
|
+
"manually_disabled": False,
|
|
425
|
+
"running": False,
|
|
426
|
+
"last_result": {},
|
|
427
|
+
"scan_interval": daemon_scan_interval,
|
|
428
|
+
"pull_interval": daemon_pull_interval,
|
|
429
|
+
}
|
|
430
|
+
stop_event = threading.Event()
|
|
431
|
+
|
|
432
|
+
def daemon_loop() -> None:
|
|
433
|
+
if daemon_runner is None:
|
|
434
|
+
return
|
|
435
|
+
daemon_state["running"] = True
|
|
436
|
+
while not stop_event.is_set():
|
|
437
|
+
if not daemon_state.get("initialized", False):
|
|
438
|
+
time.sleep(1)
|
|
439
|
+
continue
|
|
440
|
+
if not daemon_state.get("enabled", True):
|
|
441
|
+
time.sleep(1)
|
|
442
|
+
continue
|
|
443
|
+
try:
|
|
444
|
+
gh = cfg.get("sync", {}).get("github", {})
|
|
445
|
+
result = daemon_runner(
|
|
446
|
+
paths=paths,
|
|
447
|
+
schema_sql_path=schema_sql_path,
|
|
448
|
+
remote_name=gh.get("remote_name", "origin"),
|
|
449
|
+
branch=gh.get("branch", "main"),
|
|
450
|
+
remote_url=gh.get("remote_url"),
|
|
451
|
+
scan_interval=daemon_scan_interval,
|
|
452
|
+
pull_interval=daemon_pull_interval,
|
|
453
|
+
once=True,
|
|
454
|
+
)
|
|
455
|
+
daemon_state["last_result"] = result
|
|
456
|
+
except Exception as exc: # pragma: no cover
|
|
457
|
+
daemon_state["last_result"] = {"ok": False, "error": str(exc)}
|
|
458
|
+
time.sleep(max(1, daemon_scan_interval))
|
|
459
|
+
daemon_state["running"] = False
|
|
460
|
+
|
|
461
|
+
daemon_thread: threading.Thread | None = None
|
|
462
|
+
if enable_daemon and daemon_runner is not None:
|
|
463
|
+
daemon_thread = threading.Thread(target=daemon_loop, name="omnimem-daemon", daemon=True)
|
|
464
|
+
daemon_thread.start()
|
|
465
|
+
|
|
466
|
+
class Handler(BaseHTTPRequestHandler):
|
|
467
|
+
def _send_json(self, data: dict[str, Any], code: int = 200) -> None:
|
|
468
|
+
b = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
469
|
+
self.send_response(code)
|
|
470
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
471
|
+
self.send_header("Content-Length", str(len(b)))
|
|
472
|
+
self.end_headers()
|
|
473
|
+
self.wfile.write(b)
|
|
474
|
+
|
|
475
|
+
def _send_html(self, html: str, code: int = 200) -> None:
|
|
476
|
+
b = html.encode("utf-8")
|
|
477
|
+
self.send_response(code)
|
|
478
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
479
|
+
self.send_header("Content-Length", str(len(b)))
|
|
480
|
+
self.end_headers()
|
|
481
|
+
self.wfile.write(b)
|
|
482
|
+
|
|
483
|
+
def do_GET(self) -> None: # noqa: N802
|
|
484
|
+
parsed = urlparse(self.path)
|
|
485
|
+
if parsed.path == "/":
|
|
486
|
+
self._send_html(HTML_PAGE)
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
if parsed.path == "/api/config":
|
|
490
|
+
self._send_json(_cfg_to_ui(cfg, cfg_path))
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
if parsed.path == "/api/daemon":
|
|
494
|
+
self._send_json({"ok": True, **daemon_state})
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
if parsed.path == "/api/memories":
|
|
498
|
+
q = parse_qs(parsed.query)
|
|
499
|
+
limit = int(q.get("limit", ["20"])[0])
|
|
500
|
+
items = find_memories(paths, schema_sql_path, query="", layer=None, limit=limit)
|
|
501
|
+
self._send_json({"ok": True, "items": items})
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
if parsed.path == "/api/memory":
|
|
505
|
+
q = parse_qs(parsed.query)
|
|
506
|
+
mem_id = q.get("id", [""])[0]
|
|
507
|
+
if not mem_id:
|
|
508
|
+
self._send_json({"ok": False, "error": "missing id"}, 400)
|
|
509
|
+
return
|
|
510
|
+
try:
|
|
511
|
+
with sqlite3.connect(paths.sqlite_path) as conn:
|
|
512
|
+
row = conn.execute(
|
|
513
|
+
"SELECT body_md_path FROM memories WHERE id = ?",
|
|
514
|
+
(mem_id,),
|
|
515
|
+
).fetchone()
|
|
516
|
+
if not row:
|
|
517
|
+
self._send_json({"ok": False, "error": "not found"}, 404)
|
|
518
|
+
return
|
|
519
|
+
md_path = paths.markdown_root / row[0]
|
|
520
|
+
self._send_json({"ok": True, "body": md_path.read_text(encoding="utf-8")})
|
|
521
|
+
except Exception as exc: # pragma: no cover
|
|
522
|
+
self._send_json({"ok": False, "error": str(exc)}, 500)
|
|
523
|
+
return
|
|
524
|
+
|
|
525
|
+
self._send_json({"ok": False, "error": "not found"}, 404)
|
|
526
|
+
|
|
527
|
+
def do_POST(self) -> None: # noqa: N802
|
|
528
|
+
parsed = urlparse(self.path)
|
|
529
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
530
|
+
raw = self.rfile.read(length) if length else b"{}"
|
|
531
|
+
data = json.loads(raw.decode("utf-8") or "{}")
|
|
532
|
+
|
|
533
|
+
if parsed.path == "/api/config":
|
|
534
|
+
cfg["home"] = data.get("home", cfg.get("home", ""))
|
|
535
|
+
cfg.setdefault("storage", {})
|
|
536
|
+
cfg["storage"]["markdown"] = data.get("markdown", cfg["storage"].get("markdown", ""))
|
|
537
|
+
cfg["storage"]["jsonl"] = data.get("jsonl", cfg["storage"].get("jsonl", ""))
|
|
538
|
+
cfg["storage"]["sqlite"] = data.get("sqlite", cfg["storage"].get("sqlite", ""))
|
|
539
|
+
cfg.setdefault("sync", {}).setdefault("github", {})
|
|
540
|
+
cfg["sync"]["github"]["remote_name"] = data.get("remote_name", "origin")
|
|
541
|
+
cfg["sync"]["github"]["remote_url"] = data.get("remote_url", "")
|
|
542
|
+
cfg["sync"]["github"]["branch"] = data.get("branch", "main")
|
|
543
|
+
try:
|
|
544
|
+
save_config(cfg_path, cfg)
|
|
545
|
+
nonlocal paths
|
|
546
|
+
paths = resolve_paths(cfg)
|
|
547
|
+
ensure_storage(paths, schema_sql_path)
|
|
548
|
+
was_initialized = daemon_state.get("initialized", False)
|
|
549
|
+
daemon_state["initialized"] = True
|
|
550
|
+
if not was_initialized and enable_daemon:
|
|
551
|
+
daemon_state["enabled"] = not daemon_state.get("manually_disabled", False)
|
|
552
|
+
self._send_json({"ok": True})
|
|
553
|
+
except Exception as exc: # pragma: no cover
|
|
554
|
+
self._send_json({"ok": False, "error": str(exc)}, 500)
|
|
555
|
+
return
|
|
556
|
+
|
|
557
|
+
if parsed.path == "/api/sync":
|
|
558
|
+
if not daemon_state.get("initialized", False):
|
|
559
|
+
self._send_json({"ok": False, "error": "config not initialized; save config first"}, 400)
|
|
560
|
+
return
|
|
561
|
+
mode = data.get("mode", "github-status")
|
|
562
|
+
gh = cfg.get("sync", {}).get("github", {})
|
|
563
|
+
try:
|
|
564
|
+
out = sync_runner(
|
|
565
|
+
paths,
|
|
566
|
+
schema_sql_path,
|
|
567
|
+
mode,
|
|
568
|
+
remote_name=gh.get("remote_name", "origin"),
|
|
569
|
+
branch=gh.get("branch", "main"),
|
|
570
|
+
remote_url=gh.get("remote_url"),
|
|
571
|
+
commit_message="chore(memory): sync from webui",
|
|
572
|
+
)
|
|
573
|
+
self._send_json(out)
|
|
574
|
+
except Exception as exc: # pragma: no cover
|
|
575
|
+
self._send_json({"ok": False, "error": str(exc)}, 500)
|
|
576
|
+
return
|
|
577
|
+
|
|
578
|
+
if parsed.path == "/api/daemon/toggle":
|
|
579
|
+
desired = bool(data.get("enabled", True))
|
|
580
|
+
daemon_state["manually_disabled"] = not desired
|
|
581
|
+
daemon_state["enabled"] = bool(desired and daemon_state.get("initialized", False))
|
|
582
|
+
self._send_json(
|
|
583
|
+
{
|
|
584
|
+
"ok": True,
|
|
585
|
+
"enabled": daemon_state["enabled"],
|
|
586
|
+
"initialized": daemon_state["initialized"],
|
|
587
|
+
"running": daemon_state["running"],
|
|
588
|
+
"last_result": daemon_state.get("last_result", {}),
|
|
589
|
+
}
|
|
590
|
+
)
|
|
591
|
+
return
|
|
592
|
+
|
|
593
|
+
self._send_json({"ok": False, "error": "not found"}, 404)
|
|
594
|
+
|
|
595
|
+
server = ThreadingHTTPServer((host, port), Handler)
|
|
596
|
+
print(f"WebUI running on http://{host}:{port} (daemon={'on' if enable_daemon else 'off'})")
|
|
597
|
+
try:
|
|
598
|
+
server.serve_forever()
|
|
599
|
+
finally:
|
|
600
|
+
stop_event.set()
|
|
601
|
+
if daemon_thread is not None:
|
|
602
|
+
daemon_thread.join(timeout=1.5)
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "omnimem",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "OmniMem CLI and bootstrap runner",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"omnimem": "bin/omnimem"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"omnimem",
|
|
13
|
+
"scripts",
|
|
14
|
+
"templates",
|
|
15
|
+
"db",
|
|
16
|
+
"spec",
|
|
17
|
+
"README.md",
|
|
18
|
+
"docs/quickstart-10min.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"prepack": "find omnimem -name '__pycache__' -type d -prune -exec rm -rf {} +",
|
|
22
|
+
"start": "./bin/omnimem start --host 127.0.0.1 --port 8765",
|
|
23
|
+
"verify": "bash scripts/verify_phase_d.sh",
|
|
24
|
+
"pack:check": "npm pack --dry-run"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/NoPKT/omnimem.git"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/NoPKT/omnimem#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/NoPKT/omnimem/issues"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
}
|
|
37
|
+
}
|