fluffy-context 0.1.0 → 0.3.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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -136
  3. package/dist/src/agent/api.d.ts +8 -0
  4. package/dist/src/agent/api.js +285 -0
  5. package/dist/src/agent/index.d.ts +3 -0
  6. package/dist/src/agent/index.js +1 -0
  7. package/dist/src/agent/types.d.ts +50 -0
  8. package/dist/src/agent/types.js +1 -0
  9. package/dist/src/capture/context-filter.d.ts +7 -0
  10. package/dist/src/cli/main.d.ts +2 -0
  11. package/dist/src/cli/main.js +382 -30
  12. package/dist/src/git/git-adapter.d.ts +2 -0
  13. package/dist/src/hooks/claude-code.d.ts +1 -0
  14. package/dist/src/hooks/claude-code.js +84 -0
  15. package/dist/src/integrations/claude-code.d.ts +19 -0
  16. package/dist/src/integrations/claude-code.js +122 -0
  17. package/dist/src/mcp/main.d.ts +1 -0
  18. package/dist/src/mcp/main.js +5 -0
  19. package/dist/src/mcp/server.d.ts +2 -0
  20. package/dist/src/mcp/server.js +40 -0
  21. package/dist/src/project/project-resolver.d.ts +1 -0
  22. package/dist/src/runtime/diagnostics.d.ts +6 -0
  23. package/dist/src/runtime/diagnostics.js +34 -5
  24. package/dist/src/runtime/init.d.ts +7 -0
  25. package/dist/src/runtime/knowledge.d.ts +9 -0
  26. package/dist/src/runtime/knowledge.js +179 -12
  27. package/dist/src/runtime/notes.d.ts +6 -0
  28. package/dist/src/runtime/notes.js +173 -0
  29. package/dist/src/runtime/runtime.d.ts +11 -0
  30. package/dist/src/runtime/runtime.js +48 -18
  31. package/dist/src/runtime/types.d.ts +294 -0
  32. package/dist/src/storage/atomic-write.d.ts +1 -0
  33. package/dist/src/storage/json-store.d.ts +6 -0
  34. package/dist/src/storage/layout.d.ts +13 -0
  35. package/dist/src/storage/layout.js +3 -0
  36. package/dist/src/storage/lock.d.ts +1 -0
  37. package/dist/src/version.d.ts +1 -0
  38. package/dist/src/version.js +1 -0
  39. package/package.json +48 -11
  40. package/skills/fluffy-context/SKILL.md +345 -0
@@ -0,0 +1,19 @@
1
+ export interface ClaudeIntegrationStatus {
2
+ projectRoot: string;
3
+ initialized: boolean;
4
+ settingsPath: string;
5
+ mcpPath: string;
6
+ settingsExists: boolean;
7
+ mcpExists: boolean;
8
+ sessionStartInstalled: boolean;
9
+ userPromptInstalled: boolean;
10
+ mcpInstalled: boolean;
11
+ mcpConflict: boolean;
12
+ installationNeeded: boolean;
13
+ }
14
+ export interface ClaudeIntegrationInstallResult extends ClaudeIntegrationStatus {
15
+ applied: boolean;
16
+ targetFiles: string[];
17
+ }
18
+ export declare function inspectClaudeIntegration(startPath: string | undefined): Promise<ClaudeIntegrationStatus>;
19
+ export declare function installClaudeIntegration(startPath: string | undefined, apply: boolean): Promise<ClaudeIntegrationInstallResult>;
@@ -0,0 +1,122 @@
1
+ import { access } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { resolveProjectRoot } from '../project/project-resolver.js';
4
+ import { atomicWriteJson } from '../storage/atomic-write.js';
5
+ import { isRecord, readJson } from '../storage/json-store.js';
6
+ import { manifestPath } from '../storage/layout.js';
7
+ const HOOK_COMMANDS = {
8
+ sessionStart: 'ctx hook claude-code session-start',
9
+ userPrompt: 'ctx hook claude-code user-prompt',
10
+ };
11
+ const MCP_SERVER = { command: 'ctx', args: ['agent', 'serve'] };
12
+ function object(value) {
13
+ return isRecord(value) ? { ...value } : {};
14
+ }
15
+ function hookInstalled(value, command) {
16
+ if (!Array.isArray(value))
17
+ return false;
18
+ return value.some((group) => isRecord(group)
19
+ && Array.isArray(group.hooks)
20
+ && group.hooks.some((hook) => isRecord(hook) && hook.type === 'command' && hook.command === command));
21
+ }
22
+ function addHook(value, command) {
23
+ const groups = Array.isArray(value) ? value.filter(isRecord).map((group) => ({ ...group })) : [];
24
+ if (hookInstalled(groups, command))
25
+ return groups;
26
+ return [...groups, { hooks: [{ type: 'command', command }] }];
27
+ }
28
+ async function exists(filePath) {
29
+ try {
30
+ await access(filePath);
31
+ return true;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ async function loadObject(filePath) {
38
+ if (!await exists(filePath))
39
+ return {};
40
+ return readJson(filePath, isRecord);
41
+ }
42
+ function validateConfig(settings, mcp) {
43
+ if (settings.hooks !== undefined && !isRecord(settings.hooks))
44
+ throw new Error('Claude Code settings hooks must be an object');
45
+ const hooks = object(settings.hooks);
46
+ for (const event of ['SessionStart', 'UserPromptSubmit']) {
47
+ if (hooks[event] !== undefined && !Array.isArray(hooks[event]))
48
+ throw new Error(`Claude Code ${event} hooks must be an array`);
49
+ }
50
+ if (mcp.mcpServers !== undefined && !isRecord(mcp.mcpServers))
51
+ throw new Error('Claude Code MCP servers must be an object');
52
+ }
53
+ function status(projectRoot, settingsPath, mcpPath, settings, mcp, settingsExists, mcpExists, initialized) {
54
+ const hooks = object(settings.hooks);
55
+ const sessionStartInstalled = hookInstalled(hooks.SessionStart, HOOK_COMMANDS.sessionStart);
56
+ const userPromptInstalled = hookInstalled(hooks.UserPromptSubmit, HOOK_COMMANDS.userPrompt);
57
+ const mcpServers = object(mcp.mcpServers);
58
+ const configuredMcp = mcpServers['fluffy-context'];
59
+ const mcpInstalled = isRecord(configuredMcp)
60
+ && configuredMcp.command === MCP_SERVER.command
61
+ && Array.isArray(configuredMcp.args)
62
+ && configuredMcp.args.length === MCP_SERVER.args.length
63
+ && configuredMcp.args.every((value, index) => value === MCP_SERVER.args[index]);
64
+ const mcpConflict = configuredMcp !== undefined && !mcpInstalled;
65
+ return {
66
+ projectRoot,
67
+ initialized,
68
+ settingsPath,
69
+ mcpPath,
70
+ settingsExists,
71
+ mcpExists,
72
+ sessionStartInstalled,
73
+ userPromptInstalled,
74
+ mcpInstalled,
75
+ mcpConflict,
76
+ installationNeeded: !sessionStartInstalled || !userPromptInstalled || !mcpInstalled,
77
+ };
78
+ }
79
+ async function inspect(projectRoot) {
80
+ const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
81
+ const mcpPath = path.join(projectRoot, '.mcp.json');
82
+ const [settingsExists, mcpExists, initialized] = await Promise.all([
83
+ exists(settingsPath),
84
+ exists(mcpPath),
85
+ exists(manifestPath(projectRoot)),
86
+ ]);
87
+ const [settings, mcp] = await Promise.all([loadObject(settingsPath), loadObject(mcpPath)]);
88
+ validateConfig(settings, mcp);
89
+ return { status: status(projectRoot, settingsPath, mcpPath, settings, mcp, settingsExists, mcpExists, initialized), settings, mcp };
90
+ }
91
+ export async function inspectClaudeIntegration(startPath) {
92
+ const projectRoot = await resolveProjectRoot(startPath);
93
+ return (await inspect(projectRoot)).status;
94
+ }
95
+ export async function installClaudeIntegration(startPath, apply) {
96
+ const projectRoot = await resolveProjectRoot(startPath);
97
+ const inspected = await inspect(projectRoot);
98
+ const targetFiles = [inspected.status.settingsPath, inspected.status.mcpPath];
99
+ if (!apply || !inspected.status.installationNeeded)
100
+ return { ...inspected.status, applied: false, targetFiles };
101
+ if (inspected.status.mcpConflict)
102
+ throw new Error('existing fluffy-context MCP server configuration conflicts with the generated integration');
103
+ const settingsHooks = object(inspected.settings.hooks);
104
+ const settings = {
105
+ ...inspected.settings,
106
+ hooks: {
107
+ ...settingsHooks,
108
+ SessionStart: addHook(settingsHooks.SessionStart, HOOK_COMMANDS.sessionStart),
109
+ UserPromptSubmit: addHook(settingsHooks.UserPromptSubmit, HOOK_COMMANDS.userPrompt),
110
+ },
111
+ };
112
+ const mcpServers = object(inspected.mcp.mcpServers);
113
+ const mcp = {
114
+ ...inspected.mcp,
115
+ mcpServers: { ...mcpServers, 'fluffy-context': MCP_SERVER },
116
+ };
117
+ await Promise.all([
118
+ atomicWriteJson(inspected.status.settingsPath, settings),
119
+ atomicWriteJson(inspected.status.mcpPath, mcp),
120
+ ]);
121
+ return { ...(await inspect(projectRoot)).status, applied: true, targetFiles };
122
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ import { serveStdio } from '@modelcontextprotocol/server/stdio';
2
+ import { createMcpServer } from './server.js';
3
+ serveStdio(createMcpServer, {
4
+ onerror: (error) => process.stderr.write(`MCP server error: ${error.message}\n`),
5
+ });
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
+ export declare function createMcpServer(): McpServer;
@@ -0,0 +1,40 @@
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
+ import { z } from 'zod';
3
+ import { contextOrient } from '../agent/api.js';
4
+ import { VERSION } from '../version.js';
5
+ const orientInput = z.object({
6
+ path: z.string().optional(),
7
+ contextId: z.string().optional(),
8
+ query: z.string().optional(),
9
+ scope: z.string().optional(),
10
+ maxChars: z.int().nonnegative().optional(),
11
+ knowledgeLimit: z.int().nonnegative().optional(),
12
+ deadendLimit: z.int().nonnegative().optional(),
13
+ noteLimit: z.int().nonnegative().optional(),
14
+ });
15
+ function errorMessage(error) {
16
+ return error instanceof Error ? error.message : String(error);
17
+ }
18
+ export function createMcpServer() {
19
+ const server = new McpServer({ name: 'fluffy-context', version: VERSION });
20
+ server.registerTool('context_orient', {
21
+ title: 'Orient context',
22
+ description: 'Load bounded, read-only task context with verified consensus and open notes.',
23
+ inputSchema: orientInput,
24
+ }, async ({ path, ...options }) => {
25
+ try {
26
+ const result = await contextOrient(path, options);
27
+ return {
28
+ content: [{ type: 'text', text: JSON.stringify(result) }],
29
+ structuredContent: result,
30
+ };
31
+ }
32
+ catch (error) {
33
+ return {
34
+ isError: true,
35
+ content: [{ type: 'text', text: `context_orient failed: ${errorMessage(error)}` }],
36
+ };
37
+ }
38
+ });
39
+ return server;
40
+ }
@@ -0,0 +1 @@
1
+ export declare function resolveProjectRoot(startPath?: string): Promise<string>;
@@ -0,0 +1,6 @@
1
+ export declare function status(startPath?: string): Promise<Record<string, unknown>>;
2
+ export declare function doctor(startPath?: string): Promise<{
3
+ healthy: boolean;
4
+ projectRoot: string;
5
+ checks: string[];
6
+ }>;
@@ -1,8 +1,10 @@
1
1
  import { access, readdir } from 'node:fs/promises';
2
2
  import { resolveProjectRoot } from '../project/project-resolver.js';
3
3
  import { readJson } from '../storage/json-store.js';
4
- import { contextRoot, contextMetadataPath, contextsRoot, indexPath, manifestPath, snapshotPath } from '../storage/layout.js';
5
- import { rebuildIndex } from './runtime.js';
4
+ import { contextRoot, contextMetadataPath, contextsRoot, indexPath, manifestPath, notesPath, snapshotPath } from '../storage/layout.js';
5
+ import { rebuildIndex, rebuildSnapshot } from './runtime.js';
6
+ import { isValidNote } from './notes.js';
7
+ import { filterPaths } from '../capture/context-filter.js';
6
8
  export async function status(startPath) {
7
9
  const projectRoot = await resolveProjectRoot(startPath);
8
10
  try {
@@ -50,15 +52,42 @@ export async function doctor(startPath) {
50
52
  const entries = await readdir(contextsRoot(projectRoot));
51
53
  for (const contextId of entries) {
52
54
  const context = await readJson(contextMetadataPath(projectRoot, contextId));
53
- if (context.id !== contextId || context.schemaVersion !== 1)
55
+ if (context.id !== contextId || context.schemaVersion !== 1 || typeof context.title !== 'string' || typeof context.projectRoot !== 'string' || typeof context.updatedAt !== 'string') {
54
56
  throw new Error(`invalid context ${contextId}`);
55
- if (context.currentSnapshotId)
57
+ }
58
+ if (context.currentSnapshotId) {
56
59
  await access(snapshotPath(projectRoot, contextId, context.currentSnapshotId));
60
+ await rebuildSnapshot(projectRoot, contextId, context.currentSnapshotId);
61
+ }
62
+ }
63
+ });
64
+ await check('notes', async () => {
65
+ try {
66
+ const notes = await readJson(notesPath(projectRoot));
67
+ if (notes.schemaVersion !== 1 || !Array.isArray(notes.items) || notes.items.some((note) => !isValidNote(note)))
68
+ throw new Error('invalid notes');
69
+ const ids = new Set(notes.items.map((note) => note.noteId));
70
+ if (ids.size !== notes.items.length)
71
+ throw new Error('duplicate note id');
72
+ const contextIds = new Set((await readdir(contextsRoot(projectRoot))).filter((entry) => entry.startsWith('ctx-')));
73
+ for (const note of notes.items) {
74
+ if (note.contextId && !contextIds.has(note.contextId))
75
+ throw new Error(`note ${note.noteId} references missing context`);
76
+ if (note.relatedFiles.length !== (await filterPaths(projectRoot, note.relatedFiles)).length)
77
+ throw new Error(`note ${note.noteId} contains filtered paths`);
78
+ if (note.snapshotId && note.contextId)
79
+ await access(snapshotPath(projectRoot, note.contextId, note.snapshotId));
80
+ }
81
+ }
82
+ catch (error) {
83
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
84
+ return;
85
+ throw error;
57
86
  }
58
87
  });
59
88
  await check('index rebuild', async () => {
60
89
  const stored = await readJson(indexPath(projectRoot));
61
- const rebuilt = await rebuildIndex(projectRoot);
90
+ const rebuilt = await rebuildIndex(projectRoot, false);
62
91
  const rebuiltIds = new Set(rebuilt.contexts.map((entry) => entry.contextId));
63
92
  const storedIds = new Set(stored.contexts.map((entry) => entry.contextId));
64
93
  if (rebuiltIds.size !== rebuilt.contexts.length || storedIds.size !== stored.contexts.length)
@@ -0,0 +1,7 @@
1
+ import type { Manifest } from './types.js';
2
+ export interface InitResult {
3
+ projectRoot: string;
4
+ alreadyInitialized: boolean;
5
+ }
6
+ export declare function initProject(startPath?: string): Promise<InitResult>;
7
+ export declare function loadManifest(projectRoot: string): Promise<Manifest>;
@@ -0,0 +1,9 @@
1
+ import type { Deadend, DeadendDiscoverOptions, DeadendDiscoveryResult, DeadendInput, Knowledge, KnowledgeDiscoverOptions, KnowledgeDiscoveryResult, KnowledgeInput } from './types.js';
2
+ export declare function learnKnowledge(startPath: string | undefined, statement: string, scopeOrInput?: string | KnowledgeInput): Promise<Knowledge>;
3
+ export declare function listKnowledge(startPath?: string, includeUnverified?: boolean): Promise<Knowledge[]>;
4
+ export declare function discoverKnowledge(startPath: string | undefined, query: string, options?: KnowledgeDiscoverOptions): Promise<KnowledgeDiscoveryResult>;
5
+ export declare function verifyKnowledge(startPath: string | undefined, knowledgeId: string): Promise<Knowledge>;
6
+ export declare function recordDeadend(startPath: string | undefined, attempt: string, reason: string, scopeOrInput?: string | DeadendInput): Promise<Deadend>;
7
+ export declare function listDeadends(startPath?: string, includeUnverified?: boolean): Promise<Deadend[]>;
8
+ export declare function discoverDeadends(startPath: string | undefined, query: string, options?: DeadendDiscoverOptions): Promise<DeadendDiscoveryResult>;
9
+ export declare function verifyDeadend(startPath: string | undefined, deadendId: string): Promise<Deadend>;
@@ -1,30 +1,73 @@
1
1
  import crypto from 'node:crypto';
2
- import { access } from 'node:fs/promises';
3
2
  import { resolveProjectRoot } from '../project/project-resolver.js';
4
3
  import { atomicWriteJson } from '../storage/atomic-write.js';
5
4
  import { isRecord, readJson } from '../storage/json-store.js';
6
5
  import { withLock } from '../storage/lock.js';
7
6
  import { contextMetadataPath, deadendsPath, knowledgePath, locksDirectory, snapshotPath } from '../storage/layout.js';
7
+ import { rebuildSnapshot } from './runtime.js';
8
8
  function id(prefix) {
9
9
  return `${prefix}-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`;
10
10
  }
11
11
  function isNotFound(error) {
12
12
  return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
13
13
  }
14
+ function isKnowledge(value) {
15
+ return isRecord(value)
16
+ && typeof value.knowledgeId === 'string'
17
+ && typeof value.kind === 'string' && value.kind.trim().length > 0
18
+ && typeof value.statement === 'string' && value.statement.trim().length > 0
19
+ && typeof value.scope === 'string' && value.scope.trim().length > 0
20
+ && typeof value.confidence === 'number' && value.confidence >= 0 && value.confidence <= 1
21
+ && ['candidate', 'verified', 'deprecated', 'rejected'].includes(value.status)
22
+ && Array.isArray(value.sourceContextIds) && value.sourceContextIds.every((item) => typeof item === 'string')
23
+ && Array.isArray(value.sourceSnapshotIds) && value.sourceSnapshotIds.every((item) => typeof item === 'string')
24
+ && Array.isArray(value.supportingEvidence) && value.supportingEvidence.every((item) => typeof item === 'string')
25
+ && typeof value.createdAt === 'string'
26
+ && typeof value.updatedAt === 'string';
27
+ }
14
28
  function isKnowledgeFile(value) {
15
- return isRecord(value) && value.schemaVersion === 1 && Array.isArray(value.items);
29
+ if (!isRecord(value) || value.schemaVersion !== 1 || !Array.isArray(value.items) || !value.items.every(isKnowledge))
30
+ return false;
31
+ const ids = value.items.map((item) => item.knowledgeId);
32
+ return new Set(ids).size === ids.length;
33
+ }
34
+ function cleanList(values) {
35
+ return [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))];
36
+ }
37
+ function normalized(value) {
38
+ return value.trim().toLocaleLowerCase();
39
+ }
40
+ function isDeadend(value) {
41
+ return isRecord(value)
42
+ && typeof value.deadendId === 'string'
43
+ && typeof value.attempt === 'string'
44
+ && typeof value.reason === 'string'
45
+ && Array.isArray(value.observedEvidence) && value.observedEvidence.every((item) => typeof item === 'string')
46
+ && typeof value.scope === 'string'
47
+ && ['candidate', 'verified', 'obsolete', 'rejected'].includes(value.status)
48
+ && (typeof value.sourceContextId === 'string' || value.sourceContextId === null)
49
+ && (typeof value.sourceSnapshotId === 'string' || value.sourceSnapshotId === null)
50
+ && typeof value.createdAt === 'string'
51
+ && typeof value.updatedAt === 'string';
16
52
  }
17
53
  function isDeadendFile(value) {
18
- return isRecord(value) && value.schemaVersion === 1 && Array.isArray(value.items);
54
+ if (!isRecord(value) || value.schemaVersion !== 1 || !Array.isArray(value.items) || !value.items.every(isDeadend))
55
+ return false;
56
+ const ids = value.items.map((item) => item.deadendId);
57
+ return new Set(ids).size === ids.length;
19
58
  }
20
59
  async function validateSource(projectRoot, contextId, snapshotId) {
21
60
  if (!contextId && snapshotId)
22
61
  throw new Error('source snapshot requires source context');
23
62
  if (!contextId)
24
63
  return;
25
- await access(contextMetadataPath(projectRoot, contextId));
26
- if (snapshotId)
27
- await access(snapshotPath(projectRoot, contextId, snapshotId));
64
+ const context = await readJson(contextMetadataPath(projectRoot, contextId), (value) => isRecord(value) && value.id === contextId && value.schemaVersion === 1);
65
+ if (snapshotId) {
66
+ const snapshot = await readJson(snapshotPath(projectRoot, contextId, snapshotId), (value) => isRecord(value) && value.snapshotId === snapshotId && value.contextId === contextId && value.schemaVersion === 1);
67
+ await rebuildSnapshot(projectRoot, contextId, snapshot.snapshotId);
68
+ }
69
+ if (context.projectRoot !== projectRoot)
70
+ throw new Error(`source context belongs to another project: ${contextId}`);
28
71
  }
29
72
  async function loadKnowledge(projectRoot) {
30
73
  try {
@@ -49,20 +92,29 @@ async function loadDeadends(projectRoot) {
49
92
  export async function learnKnowledge(startPath, statement, scopeOrInput = 'project') {
50
93
  const projectRoot = await resolveProjectRoot(startPath);
51
94
  const input = typeof scopeOrInput === 'string' ? { scope: scopeOrInput } : scopeOrInput;
95
+ const cleanStatement = statement.trim();
96
+ const cleanScope = (input.scope ?? 'project').trim();
97
+ const cleanKind = (input.kind ?? 'fact').trim();
98
+ if (!cleanStatement)
99
+ throw new Error('knowledge statement must not be empty');
100
+ if (!cleanScope)
101
+ throw new Error('knowledge scope must not be empty');
102
+ if (!cleanKind)
103
+ throw new Error('knowledge kind must not be empty');
52
104
  await validateSource(projectRoot, input.sourceContextId, input.sourceSnapshotId);
53
105
  return withLock(`${locksDirectory(projectRoot)}/knowledge.lock`, async () => {
54
106
  const file = await loadKnowledge(projectRoot);
55
107
  const now = new Date().toISOString();
56
108
  const item = {
57
109
  knowledgeId: id('know'),
58
- kind: 'fact',
59
- statement,
60
- scope: input.scope ?? 'project',
110
+ kind: cleanKind,
111
+ statement: cleanStatement,
112
+ scope: cleanScope,
61
113
  confidence: 0.5,
62
114
  status: 'candidate',
63
- sourceContextIds: input.sourceContextId ? [input.sourceContextId] : [],
64
- sourceSnapshotIds: input.sourceSnapshotId ? [input.sourceSnapshotId] : [],
65
- supportingEvidence: input.evidence ?? [],
115
+ sourceContextIds: cleanList(input.sourceContextId ? [input.sourceContextId] : []),
116
+ sourceSnapshotIds: cleanList(input.sourceSnapshotId ? [input.sourceSnapshotId] : []),
117
+ supportingEvidence: cleanList(input.evidence),
66
118
  createdAt: now,
67
119
  updatedAt: now,
68
120
  };
@@ -75,6 +127,78 @@ export async function listKnowledge(startPath, includeUnverified = false) {
75
127
  const file = await loadKnowledge(projectRoot);
76
128
  return includeUnverified ? file.items : file.items.filter((item) => item.status === 'verified');
77
129
  }
130
+ const aliasGroups = [
131
+ ['投保人认证', '投保人身份认证', '身份核验', '实名认证', 'applicant authentication', 'identity verification'],
132
+ ['短信验证码', '验证码', 'sms verification', 'otp'],
133
+ ['证件ocr', '证件识别', 'identity ocr', 'document ocr'],
134
+ ['人脸识别', '人脸认证', 'face recognition', 'facial verification'],
135
+ ];
136
+ function queryTerms(query) {
137
+ const normalizedQuery = normalized(query);
138
+ const terms = new Set(normalizedQuery.split(/\s+/).filter(Boolean));
139
+ for (const group of aliasGroups) {
140
+ if (group.some((alias) => normalizedQuery.includes(normalized(alias)))) {
141
+ for (const alias of group)
142
+ terms.add(normalized(alias));
143
+ }
144
+ }
145
+ return [...terms];
146
+ }
147
+ function matchedFields(knowledge, terms) {
148
+ const values = { statement: normalized(knowledge.statement), kind: normalized(knowledge.kind), scope: normalized(knowledge.scope) };
149
+ const fields = new Set();
150
+ const matched = new Set();
151
+ let score = 0;
152
+ const normalizedQuery = terms.join(' ');
153
+ if (values.statement.includes(normalizedQuery))
154
+ score += 20;
155
+ for (const term of terms) {
156
+ let matchedTerm = false;
157
+ for (const [field, value] of Object.entries(values)) {
158
+ if (value.includes(term)) {
159
+ fields.add(field);
160
+ matchedTerm = true;
161
+ score += field === 'statement' ? 5 : field === 'scope' ? 4 : 3;
162
+ }
163
+ }
164
+ if (matchedTerm)
165
+ matched.add(term);
166
+ }
167
+ if (values.scope === normalizedQuery)
168
+ score += 10;
169
+ return { fields: [...fields], matchedTerms: [...matched], score };
170
+ }
171
+ function limitedKnowledge(knowledge, maxChars) {
172
+ const statement = knowledge.statement.length <= maxChars ? knowledge.statement : `${knowledge.statement.slice(0, Math.max(0, maxChars - 1))}…`;
173
+ return { ...knowledge, statement, supportingEvidence: knowledge.supportingEvidence.map((item) => item.length <= maxChars ? item : `${item.slice(0, Math.max(0, maxChars - 1))}…`) };
174
+ }
175
+ export async function discoverKnowledge(startPath, query, options = {}) {
176
+ const projectRoot = await resolveProjectRoot(startPath);
177
+ const cleanQuery = query.trim();
178
+ if (!cleanQuery)
179
+ throw new Error('knowledge discovery query must not be empty');
180
+ const file = await loadKnowledge(projectRoot);
181
+ const statuses = options.statuses ?? ['verified'];
182
+ const terms = queryTerms(cleanQuery);
183
+ const matches = file.items
184
+ .filter((item) => statuses.includes(item.status))
185
+ .filter((item) => !options.scope || normalized(item.scope) === normalized(options.scope))
186
+ .map((knowledge) => ({ knowledge, ...matchedFields(knowledge, terms) }))
187
+ .filter((item) => item.matchedTerms.length > 0)
188
+ .sort((left, right) => right.score - left.score || Number(right.knowledge.status === 'verified') - Number(left.knowledge.status === 'verified') || right.knowledge.confidence - left.knowledge.confidence || right.knowledge.updatedAt.localeCompare(left.knowledge.updatedAt) || left.knowledge.knowledgeId.localeCompare(right.knowledge.knowledgeId));
189
+ const duplicateMap = new Map();
190
+ for (const item of file.items) {
191
+ const key = `${normalized(item.kind)}|${normalized(item.scope)}|${normalized(item.statement)}`;
192
+ duplicateMap.set(key, [...(duplicateMap.get(key) ?? []), item.knowledgeId]);
193
+ }
194
+ const duplicateIds = [...duplicateMap.values()].filter((ids) => ids.length > 1);
195
+ const possibleConflicts = file.items.flatMap((left, index) => file.items.slice(index + 1).filter((right) => normalized(left.scope) === normalized(right.scope) && normalized(left.statement) !== normalized(right.statement) && queryTerms(left.statement).some((term) => normalized(right.statement).includes(term))).map((right) => ({ scope: left.scope, knowledgeIds: [left.knowledgeId, right.knowledgeId], statements: [left.statement, right.statement] })));
196
+ const limit = options.limit ?? 10;
197
+ const selected = matches.slice(0, limit);
198
+ const maxChars = options.maxChars ?? 4000;
199
+ const hits = selected.map((item) => ({ knowledge: limitedKnowledge(item.knowledge, maxChars), score: item.score, matchedTerms: item.matchedTerms, matchedFields: item.fields, why: item.fields.map((field) => `matched ${field}`) }));
200
+ return { query: cleanQuery, total: matches.length, truncated: matches.length > selected.length, hits, duplicateIds, possibleConflicts };
201
+ }
78
202
  export async function verifyKnowledge(startPath, knowledgeId) {
79
203
  const projectRoot = await resolveProjectRoot(startPath);
80
204
  return withLock(`${locksDirectory(projectRoot)}/knowledge.lock`, async () => {
@@ -117,6 +241,49 @@ export async function listDeadends(startPath, includeUnverified = false) {
117
241
  const file = await loadDeadends(projectRoot);
118
242
  return includeUnverified ? file.items : file.items.filter((item) => item.status === 'verified');
119
243
  }
244
+ function limitedDeadend(deadend, maxChars) {
245
+ const limit = (value) => value.length <= maxChars ? value : `${value.slice(0, Math.max(0, maxChars - 1))}…`;
246
+ return { ...deadend, attempt: limit(deadend.attempt), reason: limit(deadend.reason), observedEvidence: deadend.observedEvidence.map(limit) };
247
+ }
248
+ export async function discoverDeadends(startPath, query, options = {}) {
249
+ const projectRoot = await resolveProjectRoot(startPath);
250
+ const cleanQuery = query.trim();
251
+ if (!cleanQuery)
252
+ throw new Error('deadend discovery query must not be empty');
253
+ const terms = normalized(cleanQuery).split(/\s+/).filter(Boolean);
254
+ const statuses = options.statuses ?? ['verified'];
255
+ const file = await loadDeadends(projectRoot);
256
+ const matches = file.items
257
+ .filter((item) => statuses.includes(item.status))
258
+ .filter((item) => !options.scope || normalized(item.scope) === normalized(options.scope))
259
+ .map((deadend) => {
260
+ const values = { attempt: normalized(deadend.attempt), reason: normalized(deadend.reason), scope: normalized(deadend.scope) };
261
+ const fields = new Set();
262
+ const matchedTerms = new Set();
263
+ let score = 0;
264
+ for (const term of terms) {
265
+ for (const [field, value] of Object.entries(values)) {
266
+ if (value.includes(term)) {
267
+ fields.add(field);
268
+ matchedTerms.add(term);
269
+ score += field === 'attempt' ? 5 : field === 'reason' ? 4 : 3;
270
+ }
271
+ }
272
+ }
273
+ return { deadend, score, matchedTerms: [...matchedTerms], matchedFields: [...fields] };
274
+ })
275
+ .filter((item) => item.matchedTerms.length > 0)
276
+ .sort((left, right) => right.score - left.score || right.deadend.updatedAt.localeCompare(left.deadend.updatedAt) || left.deadend.deadendId.localeCompare(right.deadend.deadendId));
277
+ const limit = options.limit ?? 10;
278
+ const selected = matches.slice(0, limit);
279
+ const maxChars = options.maxChars ?? 4000;
280
+ return {
281
+ query: cleanQuery,
282
+ total: matches.length,
283
+ truncated: matches.length > selected.length,
284
+ hits: selected.map((item) => ({ ...item, deadend: limitedDeadend(item.deadend, maxChars) })),
285
+ };
286
+ }
120
287
  export async function verifyDeadend(startPath, deadendId) {
121
288
  const projectRoot = await resolveProjectRoot(startPath);
122
289
  return withLock(`${locksDirectory(projectRoot)}/deadends.lock`, async () => {
@@ -0,0 +1,6 @@
1
+ import type { ActivityQuery, ActivityResult, Note, NoteInput, NoteQuery } from './types.js';
2
+ export declare function addNote(startPath: string | undefined, message: string, input?: NoteInput): Promise<Note>;
3
+ export declare function listNotes(startPath: string | undefined, query?: NoteQuery): Promise<Note[]>;
4
+ export declare function listActivity(startPath: string | undefined, query?: ActivityQuery): Promise<ActivityResult>;
5
+ export declare function absorbNotes(startPath: string | undefined, contextId: string, snapshotId: string): Promise<number>;
6
+ export declare function isValidNote(value: unknown): value is Note;