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/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
+ }
@@ -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
+ }