Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-initial-file-mention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Fix file mentions returning no matches while drafting the first message in a workspace.
31 changes: 31 additions & 0 deletions apps/kimi-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,37 @@ export class DaemonKimiWebApi implements KimiWebApi {
};
}

async searchWorkspaceFiles(
workspaceId: string,
input: { query: string; limit?: number },
): Promise<{
items: Array<{
path: string;
name: string;
kind: 'file' | 'directory' | 'symlink';
score: number;
matchPositions: number[];
}>;
truncated: boolean;
}> {
const body: Record<string, unknown> = { query: input.query };
if (input.limit !== undefined) body['limit'] = input.limit;
const data = await this.http.post<WireSearchFilesResult>(
`/workspaces/${encodeURIComponent(workspaceId)}/fs:search`,
body,
);
return {
items: data.items.map((item) => ({
path: item.path,
name: item.name,
kind: item.kind,
score: item.score,
matchPositions: item.match_positions,
})),
truncated: data.truncated,
};
}

async grepFiles(
sessionId: string,
input: { pattern: string; regex?: boolean; caseSensitive?: boolean },
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,7 @@ export interface KimiWebApi {
listDirectory(sessionId: string, input: { path?: string; depth?: number; includeGitStatus?: boolean }): Promise<{ items: FsEntry[]; childrenByPath?: Record<string, FsEntry[]>; truncated: boolean }>;
readFile(sessionId: string, input: { path: string; offset?: number; length?: number }): Promise<{ path: string; content: string; encoding: 'utf-8' | 'base64'; size: number; truncated: boolean; etag: string; mime: string; languageId?: string; lineCount?: number; isBinary: boolean }>;
searchFiles(sessionId: string, input: { query: string; limit?: number }): Promise<{ items: Array<{ path: string; name: string; kind: FsKind; score: number; matchPositions: number[] }>; truncated: boolean }>;
searchWorkspaceFiles(workspaceId: string, input: { query: string; limit?: number }): Promise<{ items: Array<{ path: string; name: string; kind: FsKind; score: number; matchPositions: number[] }>; truncated: boolean }>;
grepFiles(sessionId: string, input: { pattern: string; regex?: boolean; caseSensitive?: boolean }): Promise<{ files: Array<{ path: string; matches: Array<{ line: number; col: number; text: string; before: string[]; after: string[] }> }>; filesScanned: number; truncated: boolean; elapsedMs: number }>;
getGitStatus(sessionId: string, paths?: string[]): Promise<{ branch: string; ahead: number; behind: number; entries: Record<string, string>; additions: number; deletions: number; pullRequest: { number: number; state: string; url: string } | null }>;
getFileDiff(sessionId: string, path: string): Promise<{ path: string; diff: string }>;
Expand Down
19 changes: 15 additions & 4 deletions apps/kimi-web/src/composables/client/useWorkspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2732,16 +2732,27 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
}
}

function resolveActiveWorkspaceId(): string | null {
const raw = rawState.activeWorkspaceId;
if (raw && workspacesView.value.some((w) => w.id === raw)) return raw;
return workspacesView.value[0]?.id ?? null;
}

/**
* Search files in the active session using the daemon searchFiles endpoint.
* Returns {path, name}[] — defensive, returns [] on error or no active session.
* Search files in the active session, or in the active workspace while the
* composer is still drafting the first prompt.
*/
async function searchFiles(query: string): Promise<Array<{ path: string; name: string }>> {
const sid = rawState.activeSessionId;
if (!sid) return [];
try {
const api = getKimiWebApi();
const result = await api.searchFiles(sid, { query, limit: 20 });
if (sid !== undefined) {
const result = await api.searchFiles(sid, { query, limit: 20 });
return result.items.map((item) => ({ path: item.path, name: item.name }));
}
const workspaceId = resolveActiveWorkspaceId();
if (workspaceId === null) return [];
const result = await api.searchWorkspaceFiles(workspaceId, { query, limit: 20 });
return result.items.map((item) => ({ path: item.path, name: item.name }));
} catch {
return [];
Expand Down
36 changes: 3 additions & 33 deletions packages/agent-core-v2/src/session/sessionFs/fsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import {
type FsMkdirResponse,
type FsReadRequest,
type FsReadResponse,
type FsSearchHit,
type FsSearchRequest,
type FsSearchResponse,
type FsStatManyRequest,
Expand Down Expand Up @@ -85,16 +84,14 @@ import { readStream, runCommand } from './fsProcess';
import { ensureRgPath, type RgProbe, type RgResolution } from './rgLocator';
import {
compileGrepPattern,
computeFuzzyScore,
computeMatchPositions,
matchesAnyGlob,
type RgJsonRecord,
rgPath,
rgText,
stripTrailingNewline,
} from './fsSearch';
import { searchWorkspaceFiles } from './workspaceSearch';

const SEARCH_HARD_CAP = 500;
const GREP_TIMEOUT_MS = 30_000;
const WALK_MAX_DEPTH = 64;

Expand Down Expand Up @@ -432,36 +429,9 @@ export class SessionFsService implements ISessionFsService {
}

async search(req: FsSearchRequest): Promise<FsSearchResponse> {
const matcher = req.follow_gitignore ? await this.matcher() : undefined;
const candidates: FsSearchHit[] = [];
const queryLower = req.query.toLowerCase();

await this.walk('', matcher, async (relPath, name, kind) => {
const score = computeFuzzyScore(name, queryLower);
if (score <= 0) return;
if (req.include_globs && !matchesAnyGlob(relPath, req.include_globs)) {
return;
}
if (req.exclude_globs && matchesAnyGlob(relPath, req.exclude_globs)) {
return;
}
candidates.push({
path: relPath,
name,
kind,
score,
match_positions: computeMatchPositions(relPath, queryLower),
});
return searchWorkspaceFiles(this.hostFs, this.workspace.workDir, req, {
gitignoreCache: this.gitignoreCache,
});

candidates.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.path.localeCompare(b.path);
});

const effectiveCap = Math.min(req.limit, SEARCH_HARD_CAP);
const truncated = candidates.length > effectiveCap;
return { items: candidates.slice(0, effectiveCap), truncated };
}

async grep(req: FsGrepRequest): Promise<FsGrepResponse> {
Expand Down
122 changes: 122 additions & 0 deletions packages/agent-core-v2/src/session/sessionFs/workspaceSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* `sessionFs` domain (L2) — reusable workspace filename search helper.
*
* Searches a concrete workspace root through the app-level `os` filesystem
* primitive and returns the session filesystem search wire shape. Used by the
* Session-scoped filesystem service and by server workspace routes that need
* the same filename-search behavior before a Session scope exists.
*/

import { join } from 'node:path';

import ignore, { type Ignore } from 'ignore';

import type { IHostFileSystem, HostDirEntry } from '#/os/interface/hostFileSystem';

import type { FsSearchHit, FsSearchRequest, FsSearchResponse } from './fs';
import { computeFuzzyScore, computeMatchPositions, matchesAnyGlob } from './fsSearch';

const SEARCH_HARD_CAP = 500;
const WALK_MAX_DEPTH = 64;

export async function searchWorkspaceFiles(
hostFs: IHostFileSystem,
root: string,
req: FsSearchRequest,
options: { readonly gitignoreCache?: Map<string, Ignore> } = {},
): Promise<FsSearchResponse> {
const matcher = req.follow_gitignore
? await workspaceMatcher(hostFs, root, options.gitignoreCache)
: undefined;
const candidates: FsSearchHit[] = [];
const queryLower = req.query.toLowerCase();

await walkWorkspaceFiles(hostFs, root, '', matcher, async (relPath, name, kind) => {
const score = computeFuzzyScore(name, queryLower);
if (score <= 0) return;
if (req.include_globs && !matchesAnyGlob(relPath, req.include_globs)) {
return;
}
if (req.exclude_globs && matchesAnyGlob(relPath, req.exclude_globs)) {
return;
}
candidates.push({
path: relPath,
name,
kind,
score,
match_positions: computeMatchPositions(relPath, queryLower),
});
});

candidates.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.path.localeCompare(b.path);
});

const effectiveCap = Math.min(req.limit, SEARCH_HARD_CAP);
const truncated = candidates.length > effectiveCap;
return { items: candidates.slice(0, effectiveCap), truncated };
}

async function walkWorkspaceFiles(
hostFs: IHostFileSystem,
root: string,
rootRel: string,
matcher: Ignore | undefined,
visit: (
relPath: string,
name: string,
kind: 'file' | 'directory' | 'symlink',
) => Promise<void>,
depth = 0,
): Promise<void> {
if (depth > WALK_MAX_DEPTH) return;
let entries: readonly HostDirEntry[];
try {
entries = await hostFs.readdir(absOf(root, rootRel));
} catch {
return;
}
for (const entry of entries) {
const { name } = entry;
if (name === '.git') continue;
const childRel = rootRel === '' ? name : `${rootRel}/${name}`;
const isDir = entry.isDirectory && entry.isSymbolicLink !== true;
if (matcher) {
const probe = isDir ? `${childRel}/` : childRel;
if (matcher.ignores(probe)) continue;
}
const kind: 'file' | 'directory' | 'symlink' = entry.isSymbolicLink
? 'symlink'
: isDir
? 'directory'
: 'file';
await visit(childRel, name, kind);
if (isDir) {
await walkWorkspaceFiles(hostFs, root, childRel, matcher, visit, depth + 1);
}
}
}

async function workspaceMatcher(
hostFs: IHostFileSystem,
root: string,
cache: Map<string, Ignore> | undefined,
): Promise<Ignore> {
const cached = cache?.get(root);
if (cached !== undefined) return cached;
const ig = ignore();
ig.add('.git/');
try {
const contents = await hostFs.readText(join(root, '.gitignore'));
ig.add(contents);
} catch {
}
cache?.set(root, ig);
return ig;
}

function absOf(root: string, rel: string): string {
return rel === '' || rel === '.' ? root : join(root, rel);
}
71 changes: 71 additions & 0 deletions packages/kap-server/src/routes/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* POST /workspaces register (idempotent on root)
* PATCH /workspaces/{workspace_id} rename (display name only)
* DELETE /workspaces/{workspace_id} unregister
* POST /workspaces/{workspace_id}/fs:search
* search workspace files without a session
*
* **Wire fidelity**: the v1 `workspaceSchema` carries more fields than v2's
* `Workspace` (`{ id, root, name, createdAt, lastOpenedAt }`). The handler
Expand All @@ -29,6 +31,11 @@ import {
type Scope,
type Workspace,
} from '@moonshot-ai/agent-core-v2';
import {
fsSearchRequestSchema,
fsSearchResponseSchema,
} from '@moonshot-ai/agent-core-v2/session/sessionFs/fs';
import { searchWorkspaceFiles } from '@moonshot-ai/agent-core-v2/session/sessionFs/workspaceSearch';
import { isAbsolute } from 'node:path';

import { z } from 'zod';
Expand Down Expand Up @@ -149,6 +156,70 @@ export function registerWorkspacesRoutes(app: WorkspaceRouteHost, core: Scope):
createRoute.handler as Parameters<WorkspaceRouteHost['post']>[2],
);

const searchRoute = defineRoute(
{
method: 'POST',
path: '/workspaces/{workspace_id}/fs::search',
params: workspaceIdParamSchema,
body: fsSearchRequestSchema,
success: { data: fsSearchResponseSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
[ErrorCode.WORKSPACE_NOT_FOUND]: {},
[ErrorCode.FS_PATH_NOT_FOUND]: {},
},
description: 'Search files in a workspace before a session exists',
tags: ['workspaces'],
operationId: 'workspaceFsSearch',
},
async (req, reply) => {
const { workspace_id } = req.params;
const ws = await core.accessor.get(IWorkspaceService).get(workspace_id);
if (ws === undefined) {
reply.send(
errEnvelope(
ErrorCode.WORKSPACE_NOT_FOUND,
`workspace ${workspace_id} does not exist`,
req.id,
),
);
return;
}

const hostFs = core.accessor.get(IHostFileSystem);
try {
const stat = await hostFs.stat(ws.root);
if (!stat.isDirectory) {
reply.send(
errEnvelope(
ErrorCode.FS_PATH_NOT_FOUND,
`workspace root ${ws.root} is not a directory`,
req.id,
),
);
return;
}
} catch {
reply.send(
errEnvelope(
ErrorCode.FS_PATH_NOT_FOUND,
`workspace root ${ws.root} does not exist`,
req.id,
),
);
return;
}

const data = await searchWorkspaceFiles(hostFs, ws.root, req.body);
reply.send(okEnvelope(data, req.id));
},
);
app.post(
searchRoute.path,
searchRoute.options,
searchRoute.handler as Parameters<WorkspaceRouteHost['post']>[2],
);

const updateRoute = defineRoute(
{
method: 'PATCH',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e
"POST",
"/api/v1/workspaces",
],
[
"POST",
"/api/v1/workspaces/{workspace_id}/fs:search",
],
[
"PUT",
"/api/v1/providers/{provider_id}",
Expand Down
Loading