anchormd 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/LICENSE +21 -0
- package/README.md +132 -0
- package/bin/anchormd +2 -0
- package/package.json +44 -0
- package/skill/SKILL.md +42 -0
- package/src/cli.ts +362 -0
- package/src/config.ts +91 -0
- package/src/entities.ts +125 -0
- package/src/format.ts +134 -0
- package/src/index-graph.ts +123 -0
- package/src/links.ts +71 -0
- package/src/plan.ts +142 -0
- package/src/qmd.ts +136 -0
- package/src/scaffold.ts +97 -0
- package/src/types.ts +73 -0
package/src/qmd.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QMD integration layer
|
|
3
|
+
*
|
|
4
|
+
* Integrates @tobilu/qmd for hybrid search over plan files.
|
|
5
|
+
* Requires Bun runtime (QMD uses better-sqlite3 and sqlite-vec).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { createStore, type QMDStore } from '@tobilu/qmd';
|
|
10
|
+
import type { AnchorConfig, SearchResult } from './types.js';
|
|
11
|
+
|
|
12
|
+
export type QmdStore = QMDStore;
|
|
13
|
+
|
|
14
|
+
// Cache the store instance to avoid recreating it on every call
|
|
15
|
+
let cachedStore: QMDStore | null = null;
|
|
16
|
+
let cachedStoreKey: string | null = null;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get a QMD store instance.
|
|
20
|
+
*
|
|
21
|
+
* Creates a QMD store backed by `.anchor/search.sqlite` with a single
|
|
22
|
+
* collection pointing at `.anchor/plans/`. The store is cached so
|
|
23
|
+
* subsequent calls with the same anchorDir reuse the same instance.
|
|
24
|
+
*
|
|
25
|
+
* Returns null when QMD is disabled in config.
|
|
26
|
+
*/
|
|
27
|
+
export async function getQmdStore(anchorDir: string, config: AnchorConfig): Promise<QMDStore | null> {
|
|
28
|
+
if (config.qmd === false) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const dbPath = path.join(anchorDir, 'search.sqlite');
|
|
33
|
+
const plansPath = path.join(anchorDir, 'plans');
|
|
34
|
+
|
|
35
|
+
// Return cached store if it matches the same anchor directory
|
|
36
|
+
if (cachedStore && cachedStoreKey === anchorDir) {
|
|
37
|
+
return cachedStore;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Close previous store if switching directories
|
|
41
|
+
if (cachedStore) {
|
|
42
|
+
await cachedStore.close();
|
|
43
|
+
cachedStore = null;
|
|
44
|
+
cachedStoreKey = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const store = await createStore({
|
|
48
|
+
dbPath,
|
|
49
|
+
config: {
|
|
50
|
+
collections: {
|
|
51
|
+
plans: {
|
|
52
|
+
path: plansPath,
|
|
53
|
+
pattern: '**/*.md',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
cachedStore = store;
|
|
60
|
+
cachedStoreKey = anchorDir;
|
|
61
|
+
|
|
62
|
+
return store;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reindex the QMD search database.
|
|
67
|
+
*
|
|
68
|
+
* Scans the plans directory for new/changed/removed files and updates
|
|
69
|
+
* the FTS and vector indexes.
|
|
70
|
+
*
|
|
71
|
+
* No-op when QMD is disabled or unavailable.
|
|
72
|
+
*/
|
|
73
|
+
export async function reindexQmd(store: QMDStore | null): Promise<void> {
|
|
74
|
+
if (store === null) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await store.update();
|
|
79
|
+
await store.embed();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Search using QMD.
|
|
84
|
+
*
|
|
85
|
+
* Supports three modes:
|
|
86
|
+
* - lexical: BM25 full-text search (fast, no LLM)
|
|
87
|
+
* - semantic: vector similarity search (uses embedding model)
|
|
88
|
+
* - hybrid: full pipeline with query expansion, multi-signal retrieval, and LLM reranking
|
|
89
|
+
*
|
|
90
|
+
* Throws a helpful error when QMD is not available.
|
|
91
|
+
*/
|
|
92
|
+
export async function searchQmd(
|
|
93
|
+
store: QMDStore | null,
|
|
94
|
+
query: string,
|
|
95
|
+
options: { mode: 'lexical' | 'semantic' | 'hybrid'; limit: number }
|
|
96
|
+
): Promise<SearchResult[]> {
|
|
97
|
+
if (store === null) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'QMD search is not available. Ensure QMD is enabled in your project config.\n' +
|
|
100
|
+
'Run `anchormd init` (without --no-qmd) to enable QMD search.\n' +
|
|
101
|
+
'Use `anchormd read <plan>` or `anchormd ls` to find plans manually.'
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
switch (options.mode) {
|
|
106
|
+
case 'lexical': {
|
|
107
|
+
const results = await store.searchLex(query, { limit: options.limit });
|
|
108
|
+
return results.map(r => ({
|
|
109
|
+
path: r.displayPath,
|
|
110
|
+
score: r.score,
|
|
111
|
+
content: r.body,
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
case 'semantic': {
|
|
116
|
+
const results = await store.searchVector(query, { limit: options.limit });
|
|
117
|
+
return results.map(r => ({
|
|
118
|
+
path: r.displayPath,
|
|
119
|
+
score: r.score,
|
|
120
|
+
content: r.body,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case 'hybrid': {
|
|
125
|
+
const results = await store.search({
|
|
126
|
+
query,
|
|
127
|
+
limit: options.limit,
|
|
128
|
+
});
|
|
129
|
+
return results.map(r => ({
|
|
130
|
+
path: r.displayPath,
|
|
131
|
+
score: r.score,
|
|
132
|
+
content: r.bestChunk,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/scaffold.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project initialization scaffold
|
|
3
|
+
*
|
|
4
|
+
* Creates the .anchor/ directory structure and template files
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { saveConfig, getAnchorDir, getPlansDir } from './config.js';
|
|
10
|
+
import { writeIndex } from './index-graph.js';
|
|
11
|
+
import { getQmdStore, reindexQmd } from './qmd.js';
|
|
12
|
+
import { color } from './format.js';
|
|
13
|
+
import type { IndexGraph } from './types.js';
|
|
14
|
+
|
|
15
|
+
const TEMPLATE_ANCHOR_PLAN = `---
|
|
16
|
+
name: anchor
|
|
17
|
+
description: Project overview and architecture
|
|
18
|
+
status: planned
|
|
19
|
+
---
|
|
20
|
+
# Project Overview
|
|
21
|
+
|
|
22
|
+
Describe your project here.
|
|
23
|
+
|
|
24
|
+
## Architecture
|
|
25
|
+
|
|
26
|
+
## Key Decisions
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Initialize AnchorMD in a project directory.
|
|
31
|
+
*
|
|
32
|
+
* Creates:
|
|
33
|
+
* - .anchor/ directory
|
|
34
|
+
* - .anchor/plans/ directory with template anchor.md
|
|
35
|
+
* - .anchor/config.json
|
|
36
|
+
* - .anchor/index.json (empty graph)
|
|
37
|
+
* - Appends to .gitignore
|
|
38
|
+
*/
|
|
39
|
+
export async function scaffold(
|
|
40
|
+
projectRoot: string,
|
|
41
|
+
options: { qmd: boolean }
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
const anchorDir = getAnchorDir(projectRoot);
|
|
44
|
+
const plansDir = getPlansDir(projectRoot);
|
|
45
|
+
|
|
46
|
+
// Create directories
|
|
47
|
+
mkdirSync(plansDir, { recursive: true });
|
|
48
|
+
|
|
49
|
+
// Write config
|
|
50
|
+
saveConfig(projectRoot, { qmd: options.qmd });
|
|
51
|
+
|
|
52
|
+
// Write template plan
|
|
53
|
+
const templatePath = path.join(plansDir, 'anchor.md');
|
|
54
|
+
if (!existsSync(templatePath)) {
|
|
55
|
+
writeFileSync(templatePath, TEMPLATE_ANCHOR_PLAN, 'utf-8');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Write empty index
|
|
59
|
+
const emptyGraph: IndexGraph = {
|
|
60
|
+
nodes: {},
|
|
61
|
+
lastBuilt: new Date().toISOString(),
|
|
62
|
+
};
|
|
63
|
+
writeIndex(anchorDir, emptyGraph);
|
|
64
|
+
|
|
65
|
+
// Append to .gitignore
|
|
66
|
+
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
67
|
+
const gitignoreEntry = '.anchor/search.sqlite';
|
|
68
|
+
|
|
69
|
+
if (existsSync(gitignorePath)) {
|
|
70
|
+
const content = readFileSync(gitignorePath, 'utf-8');
|
|
71
|
+
if (!content.includes(gitignoreEntry)) {
|
|
72
|
+
appendFileSync(gitignorePath, `\n${gitignoreEntry}\n`, 'utf-8');
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
writeFileSync(gitignorePath, `${gitignoreEntry}\n`, 'utf-8');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Initialize QMD if enabled
|
|
79
|
+
if (options.qmd) {
|
|
80
|
+
const store = await getQmdStore(anchorDir, { qmd: true });
|
|
81
|
+
await reindexQmd(store);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Print success message
|
|
85
|
+
console.log(color.green('AnchorMD initialized successfully!'));
|
|
86
|
+
console.log('');
|
|
87
|
+
console.log(' Created:');
|
|
88
|
+
console.log(` ${color.dim('.anchor/config.json')}`);
|
|
89
|
+
console.log(` ${color.dim('.anchor/plans/anchor.md')}`);
|
|
90
|
+
console.log(` ${color.dim('.anchor/index.json')}`);
|
|
91
|
+
console.log('');
|
|
92
|
+
console.log(' Next steps:');
|
|
93
|
+
console.log(` 1. Edit ${color.bold('.anchor/plans/anchor.md')} with your project overview`);
|
|
94
|
+
console.log(` 2. Run ${color.bold('anchormd write <plan-name>')} to create more plans`);
|
|
95
|
+
console.log(` 3. Run ${color.bold('anchormd context')} to see your project context`);
|
|
96
|
+
console.log('');
|
|
97
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for AnchorMD
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Valid plan status values */
|
|
6
|
+
export type PlanStatus = 'planned' | 'in-progress' | 'built' | 'deprecated';
|
|
7
|
+
|
|
8
|
+
/** All valid status values for runtime validation */
|
|
9
|
+
export const VALID_STATUSES: PlanStatus[] = ['planned', 'in-progress', 'built', 'deprecated'];
|
|
10
|
+
|
|
11
|
+
/** Frontmatter metadata for a plan file */
|
|
12
|
+
export interface PlanFrontmatter {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
status: PlanStatus;
|
|
16
|
+
tags?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A parsed plan file */
|
|
20
|
+
export interface PlanFile {
|
|
21
|
+
frontmatter: PlanFrontmatter;
|
|
22
|
+
body: string;
|
|
23
|
+
filename: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A strong link: [[target]] */
|
|
27
|
+
export interface StrongLink {
|
|
28
|
+
target: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A deep link: [[target#section]] */
|
|
32
|
+
export interface DeepLink {
|
|
33
|
+
target: string;
|
|
34
|
+
section: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A link is either a strong link or a deep link */
|
|
38
|
+
export type Link = StrongLink | DeepLink;
|
|
39
|
+
|
|
40
|
+
/** Entity types extractable from plan content */
|
|
41
|
+
export type EntityType = 'file' | 'model' | 'route' | 'script';
|
|
42
|
+
|
|
43
|
+
/** An extracted entity reference */
|
|
44
|
+
export interface Entity {
|
|
45
|
+
type: EntityType;
|
|
46
|
+
value: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A node in the index graph */
|
|
50
|
+
export interface IndexGraphNode {
|
|
51
|
+
name: string;
|
|
52
|
+
links: string[];
|
|
53
|
+
entities: Entity[];
|
|
54
|
+
weakEdges: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The full index graph */
|
|
58
|
+
export interface IndexGraph {
|
|
59
|
+
nodes: Record<string, IndexGraphNode>;
|
|
60
|
+
lastBuilt: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Project configuration stored in .anchor/config.json */
|
|
64
|
+
export interface AnchorConfig {
|
|
65
|
+
qmd: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Search result from QMD or fallback */
|
|
69
|
+
export interface SearchResult {
|
|
70
|
+
path: string;
|
|
71
|
+
score: number;
|
|
72
|
+
content?: string;
|
|
73
|
+
}
|