@geoql/mdr 0.0.1 → 1.0.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 +3 -3
- package/bin/detect-user.sh +0 -0
- package/bin/index-conversations.ts +2 -2
- package/bin/macrodata-daemon.ts +1 -1
- package/bin/macrodata-hook.sh +1 -1
- package/dist/bin/index-conversations.js +0 -0
- package/dist/bin/macrodata-daemon.js +0 -0
- package/dist/opencode/skills/macrodata-distill/SKILL.md +1 -1
- package/opencode/context.ts +44 -44
- package/opencode/conversations.ts +20 -20
- package/opencode/index.ts +26 -26
- package/opencode/journal.ts +24 -24
- package/opencode/logger.ts +8 -8
- package/opencode/search.ts +31 -31
- package/opencode/skills/macrodata-distill/SKILL.md +1 -1
- package/opencode/tools.ts +62 -62
- package/package.json +17 -18
- package/src/config.ts +13 -13
- package/src/conversations.ts +61 -61
- package/src/daemon.ts +71 -71
- package/src/detect-user.ts +24 -24
- package/src/embeddings.ts +25 -25
- package/src/index.ts +129 -129
- package/src/indexer.ts +36 -36
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@geoql/mdr)
|
|
6
6
|
[](https://jsr.io/@geoql/mdr)
|
|
7
7
|
[](https://github.com/geoql/mdr/actions/workflows/pipeline.yml)
|
|
8
|
-
[](https://codecov.io/gh/geoql/mdr)
|
|
9
9
|
[](./LICENSE)
|
|
10
10
|
|
|
11
11
|
Macrodata gives an AI coding agent layered, local-first memory: a searchable journal, entity files for people and projects, always-injected state files, semantic search across every past session, a background daemon for scheduled reminders, and overnight self-maintenance. All memory is plain markdown and JSON on disk — nothing phones home, and the whole system runs inside the agent's existing permission model with no new APIs or attack surface.
|
|
@@ -65,7 +65,7 @@ Every session starts with context injection — identity, current projects, dail
|
|
|
65
65
|
## Architecture
|
|
66
66
|
|
|
67
67
|
```
|
|
68
|
-
|
|
68
|
+
packages/macrodata/
|
|
69
69
|
├── .claude-plugin/plugin.json # Claude Code plugin manifest (hooks + MCP server)
|
|
70
70
|
├── opencode/ # OpenCode plugin variant
|
|
71
71
|
│ ├── index.ts # Plugin entry: context injection + tools + daemon supervision
|
|
@@ -137,7 +137,7 @@ Macrodata runs inside the agent's existing permission model. It uses only the to
|
|
|
137
137
|
pnpm install
|
|
138
138
|
pnpm run lint # oxlint
|
|
139
139
|
pnpm run typecheck # oxlint --type-aware --type-check
|
|
140
|
-
pnpm run build #
|
|
140
|
+
pnpm run build # vite-plus (vp pack)
|
|
141
141
|
pnpm run coverage # vitest run --coverage (100% gate)
|
|
142
142
|
```
|
|
143
143
|
|
package/bin/detect-user.sh
CHANGED
|
File without changes
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Called by hooks at session end / after compact to keep the conversation index fresh.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { updateConversationIndex } from
|
|
8
|
+
import { updateConversationIndex } from '../src/conversations.js';
|
|
9
9
|
|
|
10
10
|
export async function main(
|
|
11
11
|
update: typeof updateConversationIndex = updateConversationIndex,
|
|
@@ -17,7 +17,7 @@ export async function main(
|
|
|
17
17
|
);
|
|
18
18
|
return 0;
|
|
19
19
|
} catch (err) {
|
|
20
|
-
console.error(
|
|
20
|
+
console.error('Failed to index conversations:', err);
|
|
21
21
|
return 1;
|
|
22
22
|
}
|
|
23
23
|
}
|
package/bin/macrodata-daemon.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* MACRODATA_ROOT=/path/to/state
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { runDaemon } from
|
|
17
|
+
import { runDaemon } from '../src/daemon.js';
|
|
18
18
|
|
|
19
19
|
export function isRunAsMain(argv1: string | undefined, moduleUrl: string): boolean {
|
|
20
20
|
return Boolean(argv1) && moduleUrl === `file://${argv1}`;
|
package/bin/macrodata-hook.sh
CHANGED
|
@@ -152,7 +152,7 @@ get_usage() {
|
|
|
152
152
|
- **flags.md** is how things reach the user across sessions — one line + pointer. Surface, don't hoard.
|
|
153
153
|
- **journal** (`log_journal`) is durable and retrievable via `search_memory`. Writing it down is remembering, not forgetting — put detail here.
|
|
154
154
|
- **entities/** hold persistent per-project/topic notes (searchable). Eviction is expected: resolved/aging items leave state for the journal/entity, leaving a pointer.
|
|
155
|
-
- Search before claiming ignorance. Full guide: `
|
|
155
|
+
- Search before claiming ignorance. Full guide: `packages/macrodata/USAGE.md`.
|
|
156
156
|
USAGE
|
|
157
157
|
}
|
|
158
158
|
|
|
File without changes
|
|
File without changes
|
|
@@ -148,7 +148,7 @@ macrodata_log_journal(topic="distill-summary", content="Processed N sessions. Ex
|
|
|
148
148
|
"distilled_actions": [
|
|
149
149
|
{
|
|
150
150
|
"summary": "Added /distill skill to macrodata plugin",
|
|
151
|
-
"files": ["
|
|
151
|
+
"files": ["packages/macrodata/skills/distill/SKILL.md"],
|
|
152
152
|
"outcome": "Skill extracts facts from conversations via sub-agents"
|
|
153
153
|
}
|
|
154
154
|
],
|
package/opencode/context.ts
CHANGED
|
@@ -4,21 +4,21 @@
|
|
|
4
4
|
* Reads state files and formats them for injection into conversations
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, readFileSync, readdirSync, mkdirSync, unlinkSync } from
|
|
7
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync, unlinkSync } from 'fs';
|
|
8
8
|
|
|
9
|
-
import { join } from
|
|
10
|
-
import { getStateRoot, getJournalDir, getRemindersDir } from
|
|
11
|
-
import { detectUser } from
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { getStateRoot, getJournalDir, getRemindersDir } from '../src/config.js';
|
|
11
|
+
import { detectUser } from '../src/detect-user.js';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Read and clear pending context from daemon
|
|
15
15
|
*/
|
|
16
16
|
export function consumePendingContext(): string | null {
|
|
17
|
-
const pendingPath = join(getStateRoot(),
|
|
17
|
+
const pendingPath = join(getStateRoot(), '.pending-context');
|
|
18
18
|
if (!existsSync(pendingPath)) return null;
|
|
19
19
|
|
|
20
20
|
try {
|
|
21
|
-
const content = readFileSync(pendingPath,
|
|
21
|
+
const content = readFileSync(pendingPath, 'utf-8').trim();
|
|
22
22
|
unlinkSync(pendingPath);
|
|
23
23
|
return content || null;
|
|
24
24
|
} catch {
|
|
@@ -27,7 +27,7 @@ export function consumePendingContext(): string | null {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
// Re-export for compatibility
|
|
30
|
-
export { getStateRoot } from
|
|
30
|
+
export { getStateRoot } from '../src/config.js';
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Initialize state directory structure (directories only, no default files)
|
|
@@ -39,12 +39,12 @@ export function initializeStateRoot(): void {
|
|
|
39
39
|
// Create directories only - files created during onboarding
|
|
40
40
|
const dirs = [
|
|
41
41
|
stateRoot,
|
|
42
|
-
join(stateRoot,
|
|
43
|
-
join(stateRoot,
|
|
44
|
-
join(stateRoot,
|
|
45
|
-
join(stateRoot,
|
|
46
|
-
join(stateRoot,
|
|
47
|
-
join(stateRoot,
|
|
42
|
+
join(stateRoot, 'state'),
|
|
43
|
+
join(stateRoot, 'journal'),
|
|
44
|
+
join(stateRoot, 'entities'),
|
|
45
|
+
join(stateRoot, 'entities', 'people'),
|
|
46
|
+
join(stateRoot, 'entities', 'projects'),
|
|
47
|
+
join(stateRoot, 'topics'),
|
|
48
48
|
];
|
|
49
49
|
|
|
50
50
|
for (const dir of dirs) {
|
|
@@ -57,12 +57,12 @@ export function initializeStateRoot(): void {
|
|
|
57
57
|
function readFileOrEmpty(path: string): string {
|
|
58
58
|
try {
|
|
59
59
|
if (existsSync(path)) {
|
|
60
|
-
return readFileSync(path,
|
|
60
|
+
return readFileSync(path, 'utf-8');
|
|
61
61
|
}
|
|
62
62
|
} catch {
|
|
63
63
|
// Ignore
|
|
64
64
|
}
|
|
65
|
-
return
|
|
65
|
+
return '';
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
interface JournalEntry {
|
|
@@ -80,15 +80,15 @@ function getRecentJournal(count: number): JournalEntry[] {
|
|
|
80
80
|
|
|
81
81
|
try {
|
|
82
82
|
const files = readdirSync(journalDir)
|
|
83
|
-
.filter((f) => f.endsWith(
|
|
83
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
84
84
|
.sort()
|
|
85
85
|
.reverse();
|
|
86
86
|
|
|
87
87
|
for (const file of files) {
|
|
88
88
|
if (entries.length >= count) break;
|
|
89
89
|
|
|
90
|
-
const content = readFileSync(join(journalDir, file),
|
|
91
|
-
const lines = content.trim().split(
|
|
90
|
+
const content = readFileSync(join(journalDir, file), 'utf-8');
|
|
91
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
92
92
|
|
|
93
93
|
for (const line of lines.reverse()) {
|
|
94
94
|
if (entries.length >= count) break;
|
|
@@ -108,7 +108,7 @@ function getRecentJournal(count: number): JournalEntry[] {
|
|
|
108
108
|
|
|
109
109
|
interface Schedule {
|
|
110
110
|
id: string;
|
|
111
|
-
type:
|
|
111
|
+
type: 'cron' | 'once';
|
|
112
112
|
expression: string;
|
|
113
113
|
description: string;
|
|
114
114
|
payload: string;
|
|
@@ -121,10 +121,10 @@ function getSchedules(): Schedule[] {
|
|
|
121
121
|
|
|
122
122
|
const schedules: Schedule[] = [];
|
|
123
123
|
try {
|
|
124
|
-
const files = readdirSync(remindersDir).filter((f) => f.endsWith(
|
|
124
|
+
const files = readdirSync(remindersDir).filter((f) => f.endsWith('.json'));
|
|
125
125
|
for (const file of files) {
|
|
126
126
|
try {
|
|
127
|
-
const content = readFileSync(join(remindersDir, file),
|
|
127
|
+
const content = readFileSync(join(remindersDir, file), 'utf-8');
|
|
128
128
|
schedules.push(JSON.parse(content));
|
|
129
129
|
} catch {
|
|
130
130
|
// Skip malformed files
|
|
@@ -153,7 +153,7 @@ interface FormatOptions {
|
|
|
153
153
|
export async function formatContextForPrompt(options: FormatOptions = {}): Promise<string | null> {
|
|
154
154
|
const { forCompaction = false, client } = options;
|
|
155
155
|
const stateRoot = getStateRoot();
|
|
156
|
-
const identityPath = join(stateRoot,
|
|
156
|
+
const identityPath = join(stateRoot, 'state', 'identity.md');
|
|
157
157
|
const isFirstRun = !existsSync(identityPath);
|
|
158
158
|
|
|
159
159
|
// First run - return minimal context with onboarding pointer and detected user info
|
|
@@ -179,54 +179,54 @@ Use this pre-detected info during onboarding instead of running detection script
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
const identity = readFileOrEmpty(identityPath);
|
|
182
|
-
const today = readFileOrEmpty(join(stateRoot,
|
|
183
|
-
const human = readFileOrEmpty(join(stateRoot,
|
|
184
|
-
const workspace = readFileOrEmpty(join(stateRoot,
|
|
182
|
+
const today = readFileOrEmpty(join(stateRoot, 'state', 'today.md'));
|
|
183
|
+
const human = readFileOrEmpty(join(stateRoot, 'state', 'human.md'));
|
|
184
|
+
const workspace = readFileOrEmpty(join(stateRoot, 'state', 'workspace.md'));
|
|
185
185
|
|
|
186
186
|
// Get recent journal
|
|
187
187
|
const journalEntries = getRecentJournal(forCompaction ? 10 : 5);
|
|
188
188
|
const journalFormatted = journalEntries
|
|
189
189
|
.map((e) => {
|
|
190
190
|
const ts = new Date(e.timestamp);
|
|
191
|
-
const date = isNaN(ts.getTime()) ?
|
|
192
|
-
return `- [${e.topic}] ${e.content.split(
|
|
191
|
+
const date = isNaN(ts.getTime()) ? 'unknown' : ts.toLocaleDateString();
|
|
192
|
+
return `- [${e.topic}] ${e.content.split('\n')[0]} (${date})`;
|
|
193
193
|
})
|
|
194
|
-
.join(
|
|
194
|
+
.join('\n');
|
|
195
195
|
|
|
196
196
|
// Get schedules
|
|
197
197
|
const schedules = getSchedules();
|
|
198
198
|
const schedulesFormatted =
|
|
199
199
|
schedules.length > 0
|
|
200
|
-
? schedules.map((s) => `- ${s.description} (${s.type}: ${s.expression})`).join(
|
|
201
|
-
:
|
|
200
|
+
? schedules.map((s) => `- ${s.description} (${s.type}: ${s.expression})`).join('\n')
|
|
201
|
+
: '_No active schedules_';
|
|
202
202
|
|
|
203
203
|
const sections = [
|
|
204
|
-
`<macrodata-identity>\n${identity ||
|
|
205
|
-
`<macrodata-today>\n${today ||
|
|
206
|
-
`<macrodata-human>\n${human ||
|
|
204
|
+
`<macrodata-identity>\n${identity || '_Not configured_'}\n</macrodata-identity>`,
|
|
205
|
+
`<macrodata-today>\n${today || '_Empty_'}\n</macrodata-today>`,
|
|
206
|
+
`<macrodata-human>\n${human || '_Empty_'}\n</macrodata-human>`,
|
|
207
207
|
];
|
|
208
208
|
|
|
209
209
|
if (workspace) {
|
|
210
210
|
sections.push(`<macrodata-workspace>\n${workspace}\n</macrodata-workspace>`);
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
sections.push(`<macrodata-journal>\n${journalFormatted ||
|
|
213
|
+
sections.push(`<macrodata-journal>\n${journalFormatted || '_No entries_'}\n</macrodata-journal>`);
|
|
214
214
|
|
|
215
215
|
if (!forCompaction) {
|
|
216
216
|
sections.push(`<macrodata-schedules>\n${schedulesFormatted}\n</macrodata-schedules>`);
|
|
217
217
|
|
|
218
218
|
// List state files
|
|
219
|
-
const stateDir = join(stateRoot,
|
|
219
|
+
const stateDir = join(stateRoot, 'state');
|
|
220
220
|
/* v8 ignore next -- unreachable: this path only runs post-first-run, which
|
|
221
221
|
means identity.md exists under stateDir, so stateDir always exists. */
|
|
222
222
|
const stateFiles = existsSync(stateDir)
|
|
223
223
|
? readdirSync(stateDir)
|
|
224
|
-
.filter((f) => f.endsWith(
|
|
224
|
+
.filter((f) => f.endsWith('.md'))
|
|
225
225
|
.map((f) => `state/${f}`)
|
|
226
226
|
: [];
|
|
227
227
|
|
|
228
228
|
// List entity files (scan all subdirs dynamically)
|
|
229
|
-
const entitiesDir = join(stateRoot,
|
|
229
|
+
const entitiesDir = join(stateRoot, 'entities');
|
|
230
230
|
const entityFiles: string[] = [];
|
|
231
231
|
if (existsSync(entitiesDir)) {
|
|
232
232
|
for (const subdir of readdirSync(entitiesDir)) {
|
|
@@ -235,7 +235,7 @@ Use this pre-detected info during onboarding instead of running detection script
|
|
|
235
235
|
/* v8 ignore next -- redundant guard: subdir came from readdirSync so it
|
|
236
236
|
exists, and a non-directory throws below and is caught, not skipped here. */
|
|
237
237
|
if (!existsSync(dir) || !readdirSync(dir)) continue;
|
|
238
|
-
for (const f of readdirSync(dir).filter((f) => f.endsWith(
|
|
238
|
+
for (const f of readdirSync(dir).filter((f) => f.endsWith('.md'))) {
|
|
239
239
|
entityFiles.push(`entities/${subdir}/${f}`);
|
|
240
240
|
}
|
|
241
241
|
} catch {
|
|
@@ -248,13 +248,13 @@ Use this pre-detected info during onboarding instead of running detection script
|
|
|
248
248
|
/* v8 ignore next -- unreachable: post-first-run always has state files
|
|
249
249
|
(identity.md etc.), so allFiles is never empty here. */
|
|
250
250
|
const filesFormatted =
|
|
251
|
-
allFiles.length > 0 ? allFiles.map((f) => `- ${f}`).join(
|
|
251
|
+
allFiles.length > 0 ? allFiles.map((f) => `- ${f}`).join('\n') : '_No files yet_';
|
|
252
252
|
|
|
253
253
|
// Read usage from shared file
|
|
254
|
-
const usagePath = new URL(
|
|
254
|
+
const usagePath = new URL('../USAGE.md', import.meta.url).pathname;
|
|
255
255
|
/* v8 ignore next -- USAGE.md is always shipped alongside the built plugin,
|
|
256
256
|
so the empty-usage fallback is defensive only. */
|
|
257
|
-
const usage = existsSync(usagePath) ? readFileSync(usagePath,
|
|
257
|
+
const usage = existsSync(usagePath) ? readFileSync(usagePath, 'utf-8').trim() : '';
|
|
258
258
|
|
|
259
259
|
/* v8 ignore next 3 -- usage is always populated (USAGE.md ships with the
|
|
260
260
|
plugin), so the no-usage skip is defensive only. */
|
|
@@ -288,7 +288,7 @@ Use this pre-detected info during onboarding instead of running detection script
|
|
|
288
288
|
allModels.push({
|
|
289
289
|
fullId: `${provider.id}/${modelId}`,
|
|
290
290
|
family: m.family || `${provider.id}/${modelId}`,
|
|
291
|
-
releaseDate: m.release_date ||
|
|
291
|
+
releaseDate: m.release_date || '1970-01-01',
|
|
292
292
|
});
|
|
293
293
|
}
|
|
294
294
|
}
|
|
@@ -308,7 +308,7 @@ Use this pre-detected info during onboarding instead of running detection script
|
|
|
308
308
|
.sort();
|
|
309
309
|
if (models.length > 0) {
|
|
310
310
|
sections.push(
|
|
311
|
-
`<macrodata-models>\nAvailable models for scheduling: ${models.join(
|
|
311
|
+
`<macrodata-models>\nAvailable models for scheduling: ${models.join(', ')}\n</macrodata-models>`,
|
|
312
312
|
);
|
|
313
313
|
}
|
|
314
314
|
}
|
|
@@ -318,5 +318,5 @@ Use this pre-detected info during onboarding instead of running detection script
|
|
|
318
318
|
}
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
-
return `<macrodata>\n${sections.join(
|
|
321
|
+
return `<macrodata>\n${sections.join('\n\n')}\n</macrodata>`;
|
|
322
322
|
}
|
|
@@ -11,18 +11,18 @@
|
|
|
11
11
|
* - project: id, worktree
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { existsSync, mkdirSync } from
|
|
15
|
-
import { join, basename } from
|
|
16
|
-
import { homedir } from
|
|
17
|
-
import { DatabaseSync } from
|
|
18
|
-
import { LocalIndex } from
|
|
19
|
-
import { embedBatch, embedQuery } from
|
|
20
|
-
import { getStateRoot } from
|
|
21
|
-
import { logger } from
|
|
14
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
15
|
+
import { join, basename } from 'path';
|
|
16
|
+
import { homedir } from 'os';
|
|
17
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
18
|
+
import { LocalIndex } from 'vectra';
|
|
19
|
+
import { embedBatch, embedQuery } from '../src/embeddings.js';
|
|
20
|
+
import { getStateRoot } from './context.js';
|
|
21
|
+
import { logger } from './logger.js';
|
|
22
22
|
|
|
23
23
|
const OPENCODE_DB_PATH =
|
|
24
24
|
process.env.MACRODATA_OPENCODE_DB_PATH ||
|
|
25
|
-
join(homedir(),
|
|
25
|
+
join(homedir(), '.local', 'share', 'opencode', 'opencode.db');
|
|
26
26
|
|
|
27
27
|
// Conversation index singleton
|
|
28
28
|
let convIndex: LocalIndex | null = null;
|
|
@@ -36,9 +36,9 @@ async function getConversationIndex(): Promise<LocalIndex> {
|
|
|
36
36
|
if (convIndex) return convIndex;
|
|
37
37
|
|
|
38
38
|
const stateRoot = getStateRoot();
|
|
39
|
-
const indexPath = join(stateRoot,
|
|
39
|
+
const indexPath = join(stateRoot, '.index', 'oc-conversations');
|
|
40
40
|
|
|
41
|
-
const indexDir = join(stateRoot,
|
|
41
|
+
const indexDir = join(stateRoot, '.index');
|
|
42
42
|
if (!existsSync(indexDir)) {
|
|
43
43
|
mkdirSync(indexDir, { recursive: true });
|
|
44
44
|
}
|
|
@@ -46,7 +46,7 @@ async function getConversationIndex(): Promise<LocalIndex> {
|
|
|
46
46
|
convIndex = new LocalIndex(indexPath);
|
|
47
47
|
|
|
48
48
|
if (!(await convIndex.isIndexCreated())) {
|
|
49
|
-
logger.log(
|
|
49
|
+
logger.log('Creating new conversation index...');
|
|
50
50
|
await convIndex.createIndex();
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -110,7 +110,7 @@ interface ExchangeRow {
|
|
|
110
110
|
export function queryExchanges(db: DatabaseSync, sinceMs?: number): ExchangeRow[] {
|
|
111
111
|
// Interpolated inside the user_messages CTE body, where only `m` and `s`
|
|
112
112
|
// are in scope (`um` is the outer query's alias and must not be used here).
|
|
113
|
-
const whereClause = sinceMs ?
|
|
113
|
+
const whereClause = sinceMs ? 'AND m.time_created > ?' : '';
|
|
114
114
|
const params = sinceMs ? [sinceMs] : [];
|
|
115
115
|
|
|
116
116
|
// Get user-assistant pairs with their text content.
|
|
@@ -190,11 +190,11 @@ function rowsToExchanges(rows: ExchangeRow[]): ConversationExchange[] {
|
|
|
190
190
|
return rows.map((row) => {
|
|
191
191
|
// Use project worktree, but fall back to session directory for "global" sessions
|
|
192
192
|
// where worktree is "/" (the root filesystem, not a real project)
|
|
193
|
-
const worktree = row.worktree && row.worktree !==
|
|
194
|
-
const directory = row.directory ||
|
|
193
|
+
const worktree = row.worktree && row.worktree !== '/' ? row.worktree : '';
|
|
194
|
+
const directory = row.directory || '';
|
|
195
195
|
const projectPath = worktree || directory;
|
|
196
|
-
const name = projectPath ? basename(projectPath) :
|
|
197
|
-
const projectName = name ||
|
|
196
|
+
const name = projectPath ? basename(projectPath) : '';
|
|
197
|
+
const projectName = name || 'unknown';
|
|
198
198
|
|
|
199
199
|
return {
|
|
200
200
|
id: `oc-${row.session_id}-${row.user_msg_id}`,
|
|
@@ -217,7 +217,7 @@ let rebuildInProgress: Promise<{ exchangeCount: number }> | null = null;
|
|
|
217
217
|
*/
|
|
218
218
|
export async function rebuildConversationIndex(): Promise<{ exchangeCount: number }> {
|
|
219
219
|
if (rebuildInProgress) {
|
|
220
|
-
logger.log(
|
|
220
|
+
logger.log('Conversation index rebuild already in progress, waiting...');
|
|
221
221
|
return rebuildInProgress;
|
|
222
222
|
}
|
|
223
223
|
|
|
@@ -230,7 +230,7 @@ export async function rebuildConversationIndex(): Promise<{ exchangeCount: numbe
|
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
async function doRebuildConversationIndex(): Promise<{ exchangeCount: number }> {
|
|
233
|
-
logger.log(
|
|
233
|
+
logger.log('Rebuilding OpenCode conversation index...');
|
|
234
234
|
const startTime = Date.now();
|
|
235
235
|
|
|
236
236
|
const db = openDb();
|
|
@@ -392,7 +392,7 @@ export async function getConversationIndexStats(): Promise<{ exchangeCount: numb
|
|
|
392
392
|
* Incrementally update conversation index (only new exchanges)
|
|
393
393
|
*/
|
|
394
394
|
export async function updateConversationIndex(): Promise<{ newCount: number; totalCount: number }> {
|
|
395
|
-
logger.log(
|
|
395
|
+
logger.log('Updating OpenCode conversation index...');
|
|
396
396
|
const startTime = Date.now();
|
|
397
397
|
|
|
398
398
|
const db = openDb();
|
package/opencode/index.ts
CHANGED
|
@@ -7,19 +7,19 @@
|
|
|
7
7
|
* - Custom `macrodata` tool for memory operations
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { Plugin, PluginInput } from
|
|
11
|
-
import { existsSync, mkdirSync, cpSync, readdirSync, readFileSync, openSync } from
|
|
12
|
-
import { join } from
|
|
13
|
-
import { homedir } from
|
|
14
|
-
import { spawn } from
|
|
15
|
-
import { memoryTools } from
|
|
10
|
+
import type { Plugin, PluginInput } from '@opencode-ai/plugin';
|
|
11
|
+
import { existsSync, mkdirSync, cpSync, readdirSync, readFileSync, openSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { homedir } from 'os';
|
|
14
|
+
import { spawn } from 'child_process';
|
|
15
|
+
import { memoryTools } from './tools.js';
|
|
16
16
|
import {
|
|
17
17
|
formatContextForPrompt,
|
|
18
18
|
consumePendingContext,
|
|
19
19
|
initializeStateRoot,
|
|
20
20
|
getStateRoot,
|
|
21
|
-
} from
|
|
22
|
-
import { logger } from
|
|
21
|
+
} from './context.js';
|
|
22
|
+
import { logger } from './logger.js';
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Check if a process with given PID is running
|
|
@@ -37,13 +37,13 @@ function isProcessRunning(pid: number): boolean {
|
|
|
37
37
|
* Send SIGHUP to the daemon to reload config
|
|
38
38
|
*/
|
|
39
39
|
function signalDaemonReload(): void {
|
|
40
|
-
const pidFile = join(homedir(),
|
|
40
|
+
const pidFile = join(homedir(), '.config', 'macrodata', '.daemon.pid');
|
|
41
41
|
if (!existsSync(pidFile)) return;
|
|
42
42
|
|
|
43
43
|
try {
|
|
44
|
-
const pid = parseInt(readFileSync(pidFile,
|
|
44
|
+
const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
45
45
|
if (isProcessRunning(pid)) {
|
|
46
|
-
process.kill(pid,
|
|
46
|
+
process.kill(pid, 'SIGHUP');
|
|
47
47
|
}
|
|
48
48
|
} catch {
|
|
49
49
|
// Ignore errors
|
|
@@ -58,22 +58,22 @@ const HEARTBEAT_STALE_MS = 15 * 60_000;
|
|
|
58
58
|
* the heartbeat file is stale (wedged daemon, see #25).
|
|
59
59
|
*/
|
|
60
60
|
function ensureDaemonRunning(): void {
|
|
61
|
-
const configDir = join(homedir(),
|
|
62
|
-
const pidFile = join(configDir,
|
|
61
|
+
const configDir = join(homedir(), '.config', 'macrodata');
|
|
62
|
+
const pidFile = join(configDir, '.daemon.pid');
|
|
63
63
|
const stateRoot = getStateRoot();
|
|
64
|
-
const heartbeatFile = join(stateRoot,
|
|
65
|
-
const daemonScript = join(import.meta.dirname,
|
|
64
|
+
const heartbeatFile = join(stateRoot, '.daemon.heartbeat');
|
|
65
|
+
const daemonScript = join(import.meta.dirname, '..', 'bin', 'macrodata-daemon.js');
|
|
66
66
|
|
|
67
67
|
if (existsSync(pidFile)) {
|
|
68
68
|
try {
|
|
69
|
-
const pid = parseInt(readFileSync(pidFile,
|
|
69
|
+
const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
70
70
|
if (isProcessRunning(pid)) {
|
|
71
71
|
if (!isHeartbeatStale(heartbeatFile)) {
|
|
72
72
|
return;
|
|
73
73
|
}
|
|
74
74
|
logger.warn(`Daemon PID ${pid} alive but heartbeat stale, restarting`);
|
|
75
75
|
try {
|
|
76
|
-
process.kill(pid,
|
|
76
|
+
process.kill(pid, 'SIGKILL');
|
|
77
77
|
} catch {
|
|
78
78
|
// Already gone
|
|
79
79
|
}
|
|
@@ -88,13 +88,13 @@ function ensureDaemonRunning(): void {
|
|
|
88
88
|
// Ensure config dir exists for PID file
|
|
89
89
|
mkdirSync(configDir, { recursive: true });
|
|
90
90
|
|
|
91
|
-
const logFile = join(getStateRoot(),
|
|
92
|
-
const out = openSync(logFile,
|
|
93
|
-
const err = openSync(logFile,
|
|
91
|
+
const logFile = join(getStateRoot(), '.daemon.log');
|
|
92
|
+
const out = openSync(logFile, 'a');
|
|
93
|
+
const err = openSync(logFile, 'a');
|
|
94
94
|
|
|
95
95
|
const child = spawn(process.execPath, [daemonScript], {
|
|
96
96
|
detached: true,
|
|
97
|
-
stdio: [
|
|
97
|
+
stdio: ['ignore', out, err],
|
|
98
98
|
env: { ...process.env, MACRODATA_ROOT: stateRoot },
|
|
99
99
|
});
|
|
100
100
|
child.unref();
|
|
@@ -108,7 +108,7 @@ function isHeartbeatStale(heartbeatFile: string): boolean {
|
|
|
108
108
|
return false;
|
|
109
109
|
}
|
|
110
110
|
try {
|
|
111
|
-
const lastBeat = parseInt(readFileSync(heartbeatFile,
|
|
111
|
+
const lastBeat = parseInt(readFileSync(heartbeatFile, 'utf-8').trim(), 10);
|
|
112
112
|
return Number.isFinite(lastBeat) && Date.now() - lastBeat > HEARTBEAT_STALE_MS;
|
|
113
113
|
} catch {
|
|
114
114
|
return false;
|
|
@@ -120,9 +120,9 @@ function isHeartbeatStale(heartbeatFile: string): boolean {
|
|
|
120
120
|
* Skills are copied from the plugin's skills directory on first load
|
|
121
121
|
*/
|
|
122
122
|
function installSkills(): void {
|
|
123
|
-
const globalSkillsDir = join(homedir(),
|
|
123
|
+
const globalSkillsDir = join(homedir(), '.config', 'opencode', 'skills');
|
|
124
124
|
// import.meta.dirname is the opencode/ folder
|
|
125
|
-
const pluginSkillsDir = join(import.meta.dirname,
|
|
125
|
+
const pluginSkillsDir = join(import.meta.dirname, 'skills');
|
|
126
126
|
|
|
127
127
|
/* v8 ignore next 3 -- the skills/ directory always ships next to the built
|
|
128
128
|
plugin, so this missing-dir bail-out is defensive only. */
|
|
@@ -168,7 +168,7 @@ export const MacrodataPlugin: Plugin = async (ctx: PluginInput) => {
|
|
|
168
168
|
|
|
169
169
|
return {
|
|
170
170
|
// Inject memory context into system prompt
|
|
171
|
-
|
|
171
|
+
'experimental.chat.system.transform': async (_input, output) => {
|
|
172
172
|
try {
|
|
173
173
|
const pendingContext = consumePendingContext();
|
|
174
174
|
if (pendingContext) {
|
|
@@ -187,7 +187,7 @@ export const MacrodataPlugin: Plugin = async (ctx: PluginInput) => {
|
|
|
187
187
|
},
|
|
188
188
|
|
|
189
189
|
// Inject memory context before compaction
|
|
190
|
-
|
|
190
|
+
'experimental.session.compacting': async (_input, output) => {
|
|
191
191
|
try {
|
|
192
192
|
const memoryContext = await formatContextForPrompt({ forCompaction: true });
|
|
193
193
|
|